Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 58 additions & 14 deletions app/javascript/Tables/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -89,24 +88,69 @@ 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 <a href> 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<string, string> = {};
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,
sortBy,
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 (
<a className="btn btn-outline-primary" href={href}>
<>
<>
<button type="button" className="btn btn-outline-primary" onClick={onClick} disabled={inProgress}>
<i className="bi-file-earmark-spreadsheet" /> {t('tables.exportCSV.buttonText')}
</>
</a>
</button>
<ErrorDisplay stringError={error?.message} />
</>
);
}

Expand Down
92 changes: 92 additions & 0 deletions test/controllers/csv_exports_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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 <a href> 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 }
Expand All @@ -24,3 +115,4 @@
end
end
end
# rubocop:enable Metrics/BlockLength