Skip to content

Add CSV export to RA Management search results - #521

Open
kayjoosten wants to merge 4 commits into
mainfrom
feature/issue-498-ra-management-csv-export
Open

Add CSV export to RA Management search results#521
kayjoosten wants to merge 4 commits into
mainfrom
feature/issue-498-ra-management-csv-export

Conversation

@kayjoosten

@kayjoosten kayjoosten commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds an Export button next to Search on the RA Management page, matching the existing Tokens (Second Factors) page pattern.
  • RaListingService::export() pages through the existing paginated middleware search (no unpaginated export endpoint exists for RA listings) and streams the aggregated results as CSV via a new RaListingExport service.
  • Export filename follows the role filter: ra_export_YYYY-MM-DD, raa_export_YYYY-MM-DD, or ra-raa-export_YYYY-MM-DD when unfiltered.
  • Renames the blank Role filter option to a "RA/RAA" placeholder.
  • export() keeps its own ROLE_RAA authorization check, independent of manage().

Test plan

  • php -l on all changed/new PHP files
  • XML validation of both translation xliff files
  • YAML validation of services.yml
  • Manually verified in a local docker-devconf environment (smoketest mode): logged in as RAA, submitted search with no/RA/RAA role filters, clicked Export each time, confirmed correct filenames and CSV row content/filtering
  • Confirmed a plain RA (no RAA) user cannot access the export button or action
  • Unit tests for ExportRaListingCommand::fromSearchCommand, RaListingService::export, RaListingExport::export (not yet added)

Closes #498

If applied, this commit will let RAA users export the current RA
Management search results as a CSV file, and rename the blank Role
filter option to a descriptive label.

Why is this change needed?
Prior to this change, the RA Management page had no export option,
unlike the existing Tokens (Second Factors) page. Institution admins
had no way to get an offline copy of their RA(A) listing. The Role
filter dropdown also had an unlabeled blank option between "RA" and
"RAA", which was unclear to users.

How does it address the issue?
This change adds an Export button next to Search, following the same
button-group pattern used on the Tokens page. Since RA listings have
no unpaginated export endpoint in the middleware client (unlike Second
Factors), RaListingService::export() pages through the existing
paginated search results and aggregates them before handing off to the
new RaListingExport service, which streams the results as CSV via
fputcsv. The exported filename is derived from the active role filter:
ra_export_YYYY-MM-DD, raa_export_YYYY-MM-DD, or
ra-raa-export_YYYY-MM-DD when no role filter is set. The Role filter's
blank option now shows a "RA/RAA" placeholder instead of being empty.
The export action keeps its own ROLE_RAA authorization check
independent of the search action, so it stays protected even if the
search page's access level changes later. Institution-scoping and
existing authorization boundaries are unchanged.

Links / references:
#498
If applied, this commit will make the CI QA pipeline pass for the RA
Management CSV export feature.

Why is this change needed?
Prior to this change, CI's phpstan step failed on the new export code:
an untyped/uninitialized $actorId property, a stale baseline entry for
a booleanAnd.leftAlwaysTrue error that the buildQuery() refactor already
fixed by making its $roleAtInstitution parameter nullable, unchecked
resource|false returns from fopen() in RaListingExport, a missing
iterable value type on getColumnNames(), and a new getClickedButton()
call on FormInterface that needed the same baseline treatment already
used for the equivalent Second Factors export form.

How does it address the issue?
This change adds a `@var string` docblock to
ExportRaListingCommand::$actorId (matching the existing
ExportRaSecondFactorsCommand convention), removes the now-stale
booleanAnd.leftAlwaysTrue baseline entry for RaListingService, adds a
baseline entry for RaManagementController's getClickedButton() call
mirroring SecondFactorController's, guards the fopen() result in
RaListingExport before using it, and adds a `@return string[]` docblock
to getColumnNames().
@kayjoosten
kayjoosten requested review from johanib and pmeulen August 3, 2026 08:02
Covers ExportRaListingCommand, RaListingExport and RaListingService.
$form = $this->createForm(SearchRaListingType::class, $command, ['method' => 'get']);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->getClickedButton()?->getName() === 'export') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 The getClickedButton does seem to be officially supported (https://symfony.com/doc/current/forms.html#handling-multiple-submit-buttons)

But it still needs a phpstan exception. Is the $this->createForm() typehint correct?

nitpick

Comment on lines +73 to +80
$query = $this->buildQuery(
$command->actorId,
$pageNumber,
$command->name,
$command->email,
$command->institution,
$command->roleAtInstitution,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export pages through an unordered result set, so rows can be duplicated or dropped

export() calls buildQuery() without orderBy/orderDirection. On the middleware side RaListingController sets $query->orderBy = $request->query->getString('orderBy') (so '' when absent), and RaListingRepository::createSearchQuery() returns the query with no ORDER BY in that case. Each page is then a separate LIMIT/OFFSET query over an authorization-filtered join, and MariaDB gives no ordering guarantee between those separate statements, so the aggregated CSV can contain the same RA twice and silently miss others. The paginated UI hides this because it only ever fetches one page.

Suggested approach: force a deterministic sort for the export (commonName is the only column the middleware accepts), or add an unpaginated export endpoint like the token export has.

$query = $this->buildQuery(
    $command->actorId,
    $pageNumber,
    $command->name,
    $command->email,
    $command->institution,
    $command->roleAtInstitution,
    'commonName',
    'asc',
);

Comment on lines +48 to +50
fputcsv($handle, $columnNames);
foreach ($raListings as $raListing) {
fputcsv($handle, [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fputcsv() is called without $escape

PHP 8.4 deprecates the implicit default (the new tests emit two deprecations on newer PHP), and the legacy \\ escape produces non-RFC4180 output for values ending in a backslash. The test itself reads back with str_getcsv(..., escape: ''), which shows the asymmetry.

Suggested approach: pass escape: '' explicitly to both fputcsv() calls.

Comment on lines +46 to +52
->with(Mockery::on(fn (RaListingSearchQuery $query) => str_contains($query->toHttpQuery(), 'p=1')))
->andReturn($firstPage);
$apiService
->shouldReceive('search')
->once()
->with(Mockery::on(fn (RaListingSearchQuery $query) => str_contains($query->toHttpQuery(), 'p=2')))
->andReturn($secondPage);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export tests only assert page numbers, not filter propagation

Both tests match on p=1/p=2 only, so a regression that drops name, email, institution or the role filter from the export query would still pass while leaking rows outside the requested filter into the CSV.

Suggested approach: assert on the full toHttpQuery() string for at least one fully-populated ExportRaListingCommand.

The RA/RAA listing CSV export wrote identity fields straight into the
file, so a value starting with =, -, @, tab or CR could be executed as
a formula when the export is opened in Excel, LibreOffice or Google
Sheets. Cell values are now sanitized before being written, following
the OWASP CSV injection mitigation. A leading + is intentionally left
untouched, since Contact Information routinely holds international
phone numbers.

The export also ignored the orderBy/orderDirection the user selected
in the search screen, always falling back to the API default sort.
ExportRaListingCommand now copies these fields from the search command,
matching the existing pattern used by ExportRaSecondFactorsCommand.

Finally, export() eagerly merged every page into memory before handing
it to the CSV writer, despite the response being served as a streamed
download. Fetching is now done through a generator, so pages are only
requested as the streamed response is actually written.

Updated the accompanying tests to match: iterable/generator handling in
mocks, order-by/order-direction propagation and a CSV injection
regression test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add CSV export functionality to RA Management search results

2 participants