diff --git a/app/javascript/Tables/ExportButton.tsx b/app/javascript/Tables/ExportButton.tsx index 224ad80f8cb..9331ff2d4b6 100644 --- a/app/javascript/Tables/ExportButton.tsx +++ b/app/javascript/Tables/ExportButton.tsx @@ -1,16 +1,15 @@ -import { useMemo } from 'react'; +import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; +import { ErrorDisplay } from '@neinteractiveliterature/litform'; import { reactTableFiltersToTableResultsFilters, reactTableSortToTableResultsSort } from './TableUtils'; import { ColumnFiltersState, SortingState } from '@tanstack/react-table'; +import { AuthenticationManagerContext } from '../Authentication/authenticationManager'; +import useAsyncFunction from '../useAsyncFunction'; export type URLParamSerializableScalar = string | number | boolean; export type URLParamSerializable = - | null - | undefined - | URLParamSerializableScalar - | URLParamSerializable[] - | { [key: string]: URLParamSerializable }; + null | undefined | URLParamSerializableScalar | URLParamSerializable[] | { [key: string]: URLParamSerializable }; function dataToKeyPathValuePairs(data: URLParamSerializable, prependKeys: string[] = []): [string[], string][] { if (data == null) { @@ -89,6 +88,46 @@ export type ReactTableExportButtonProps = { columns?: string[]; }; +function filenameFromContentDisposition(contentDisposition: string | null): string | null { + const match = contentDisposition ? /filename="([^"]+)"/.exec(contentDisposition) : null; + return match ? match[1] : null; +} + +// The export routes are authenticated the same way GraphQL requests are (a bearer token +// from AuthenticationManager, since OIDC sign-in never establishes a cookie session Rails +// can see). A plain navigation can't attach that header, so we have to fetch the +// CSV ourselves and hand the browser a blob to download instead. +async function downloadExport(url: string, token: string | undefined) { + const headers: Record = {}; + if (token) { + // eslint-disable-next-line i18next/no-literal-string + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { credentials: 'same-origin', headers }); + if (!response.ok) { + throw new Error(`Export failed: HTTP ${response.status}`); + } + + const blob = await response.blob(); + const filename = + filenameFromContentDisposition(response.headers.get('Content-Disposition')) ?? + // eslint-disable-next-line i18next/no-literal-string + 'export.csv'; + const objectUrl = URL.createObjectURL(blob); + + try { + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + function ReactTableExportButton({ exportUrl, filters, @@ -96,17 +135,22 @@ function ReactTableExportButton({ columns, }: ReactTableExportButtonProps): React.JSX.Element { const { t } = useTranslation(); - const href = useMemo( - () => getExportUrl(exportUrl, { filters, sortBy, columns }), - [columns, exportUrl, filters, sortBy], - ); + const authenticationManager = useContext(AuthenticationManagerContext); + const [exportAsync, error, inProgress] = useAsyncFunction(downloadExport, { suppressError: true }); + + const onClick = async () => { + const url = getExportUrl(exportUrl, { filters, sortBy, columns }); + const token = await authenticationManager.ensureFreshAccessToken(); + await exportAsync(url, token); + }; return ( - - <> + <> + + + ); } diff --git a/test/controllers/csv_exports_controller_test.rb b/test/controllers/csv_exports_controller_test.rb index 5104913a30e..d5d444870b1 100644 --- a/test/controllers/csv_exports_controller_test.rb +++ b/test/controllers/csv_exports_controller_test.rb @@ -1,5 +1,96 @@ # frozen_string_literal: true +# rubocop:disable Metrics/BlockLength require "test_helper" +require "csv" + +class CsvExportsControllerTest < ActionDispatch::IntegrationTest + let(:convention) { create(:convention) } + let(:event) { create(:event, convention: convention) } + let(:signup_run) { create(:run, event: event) } + let(:con_admin_staff_position) { create(:admin_staff_position, convention: convention) } + let(:con_admin_profile) do + profile = create(:user_con_profile, convention: convention) + con_admin_staff_position.user_con_profiles << profile + profile + end + let(:con_admin) { con_admin_profile.user } + + setup do + host! convention.domain + sign_in con_admin + end + + describe "GET signup_changes" do + it "exports the convention's signup change log as CSV" do + signup = create(:signup, run: signup_run) + signup.log_signup_change!(action: "self_service_signup") + + get csv_exports_signup_changes_path + + assert_response :ok + csv = CSV.parse(response.body, headers: true) + assert_equal 1, csv.size + assert_equal event.title, csv.first["Event"] + assert_equal "self_service_signup", csv.first["Action"] + end + end + + describe "GET signup_changes when not signed in" do + it "does not crash" do + sign_out con_admin + create(:signup, run: signup_run).log_signup_change!(action: "self_service_signup") + + get csv_exports_signup_changes_path + + assert_response :ok + csv = CSV.parse(response.body, headers: true) + assert_equal 0, csv.size + end + end + + # The SPA's OAuth/OIDC login (OAuthSessionsController#exchange) never calls Devise's + # sign_in -- it only mints a Doorkeeper access token, held in memory and sent as an + # `Authorization: Bearer` header on GraphQL requests. A plain navigation (which + # is how the CSV export link works) carries neither that header nor any cookie Devise + # recognizes, so it hits the server unauthenticated even though the user is "signed in" + # from their own perspective. + describe "GET signup_changes for an OAuth/OIDC-only session (no Devise session)" do + let(:frontend_app) { create(:oauth_application, is_intercode_frontend: true) } + # Matches the scope string the real OIDC login flow requests (see + # app/javascript/Authentication/openid.ts). + let(:access_token) do + Doorkeeper::AccessToken.create!( + application: frontend_app, + resource_owner_id: con_admin.id, + scopes: "public openid email profile read_profile read_signups read_events read_conventions", + expires_in: 2.hours, + use_refresh_token: true + ) + end + + setup do + sign_out con_admin + create(:signup, run: signup_run).log_signup_change!(action: "self_service_signup") + access_token + end + + it "comes back empty when the bearer token isn't attached (plain link navigation)" do + get csv_exports_signup_changes_path + + assert_response :ok + csv = CSV.parse(response.body, headers: true) + assert_equal 0, csv.size, "expected an empty export, matching the reported bug" + end + + it "returns real data when the same bearer token is attached (as GraphQL requests do)" do + get csv_exports_signup_changes_path, headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } + + assert_response :ok + csv = CSV.parse(response.body, headers: true) + assert_equal 1, csv.size, "the same user's data is visible once the bearer token is actually sent" + end + end +end describe CsvExportsController::RunSignupsFilenameFinder do let(:finder) { CsvExportsController::RunSignupsFilenameFinder.new } @@ -24,3 +115,4 @@ end end end +# rubocop:enable Metrics/BlockLength