Skip to content
Open
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
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,6 @@
"packages/*"
],
"resolutions": {
"@react-pdf/layout": "3.9.1",
"@react-pdf/textkit": "4.3.0",
"@types/react": "18.2.14",
"asn1.js": ">=5.4.1",
"axios": "^1.13.4",
Expand Down
2 changes: 0 additions & 2 deletions packages/functional-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@
"devDependencies": {
"@playwright/test": "1.61.1",
"@types/eslint": "^8",
"@types/pdf-parse": "^1.1.4",
"@types/upng-js": "^2",
"eslint": "^8.56.0",
"eslint-plugin-playwright": "^0.22.2",
"fxa-auth-client": "workspace:*",
"fxa-content-server": "workspace:*",
"fxa-settings": "workspace:*",
"jsqr": "^1.4.0",
"pdf-parse": "^1.1.1",
"typescript": "5.5.3",
"upng-js": "^2.1.0"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ test.describe('severity-1 #smoke', () => {
const keyDownload = await resetPassword.downloadRecoveryKey();
const filename = keyDownload.suggestedFilename();
expect(filename).toMatch(
new RegExp(`Mozilla-Recovery-Key_[0-9-]{10}_${credentials.email}.pdf`)
new RegExp(`Mozilla-Recovery-Key_[0-9-]{10}_${credentials.email}.txt`)
);
expect(filename.length).toBeGreaterThan(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ test.describe('severity-1 #smoke', () => {
const keyDownload = await resetPassword.downloadRecoveryKey();
const filename = keyDownload.suggestedFilename();
expect(filename).toMatch(
new RegExp(`Mozilla-Recovery-Key_[0-9-]{10}_${credentials.email}.pdf`)
new RegExp(`Mozilla-Recovery-Key_[0-9-]{10}_${credentials.email}.txt`)
);
expect(filename.length).toBeGreaterThan(0);

Expand Down
16 changes: 4 additions & 12 deletions packages/functional-tests/tests/settings/recoveryKey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import fs from 'fs';
import pdfParse from 'pdf-parse';
import { Page, expect, test } from '../../lib/fixtures/standard';
import { BaseTarget, Credentials } from '../../lib/targets/base';
import { TestAccountTracker } from '../../lib/testAccountTracker';
Expand Down Expand Up @@ -45,7 +44,7 @@ test.describe('severity-1 #smoke', () => {
expect(clipboard).toEqual(newKey);
});

test('can download recovery key as PDF', async ({
test('can download recovery key as a text file', async ({
target,
pages: { page, recoveryKey, settings, signin },
testAccountTracker,
Expand Down Expand Up @@ -80,7 +79,7 @@ test.describe('severity-1 #smoke', () => {
const filename = dl.suggestedFilename();
expect(filename.length).toBeLessThanOrEqual(75);
expect(filename).toBe(
`Mozilla-Recovery-Key_${date}_${credentials.email}.pdf`
`Mozilla-Recovery-Key_${date}_${credentials.email}.txt`
);

// Test uses try/finally to ensure the downloaded file is deleted after tests
Expand All @@ -90,15 +89,8 @@ test.describe('severity-1 #smoke', () => {
await dl.saveAs(filename);
expect(fs.existsSync(filename)).toBeTruthy();

const getPDF = async (file: fs.PathOrFileDescriptor) => {
const readFileSync = fs.readFileSync(file);
const pdfExtract = await pdfParse(readFileSync);
// Verify downloaded file contains key
expect(pdfExtract.text).toContain(newKey);
// Verify the PDF file contains only one page
expect(pdfExtract.numpages).toEqual(1);
};
getPDF(filename);
const contents = await fs.promises.readFile(filename, 'utf8');
expect(contents).toBe(newKey);

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.

👍🏽

} finally {
// Delete the downloaded file
await fs.promises.unlink(filename);
Expand Down
3 changes: 0 additions & 3 deletions packages/fxa-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@
"@emotion/styled": "^11.13.0",
"@material-ui/core": "v5.0.0-alpha.24",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
"@react-pdf/renderer": "3.2.1",
"@svgr/webpack": "^8.1.0",
"@types/material-ui": "^0.21.8",
"babel-jest": "^29.7.0",
Expand All @@ -153,7 +152,6 @@
"eslint": "^8.3.0",
"eslint-webpack-plugin": "^4.2.0",
"file-loader": "^6.2.0",
"file-saver": "^2.0.5",
"fs-extra": "^11.2.0",
"fxa-auth-client": "workspace:*",
"fxa-pairing-channel": "1.0.2",
Expand Down Expand Up @@ -201,7 +199,6 @@
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/user-event": "^14.6.0",
"@types/file-saver": "^2.0.5",
"@types/jest": "26.0.23",
"@types/node": "^22.13.5",
"@types/prop-types": "^15",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## ButtonDownloadRecoveryKey
## Clicking on this button downloads a plain text file that contains the user's account recovery key
## The account recovery key can be used to recover data when users forget their account password

# Button to download the account recovery key as a plain text file and navigate to the next step
# The next (and final) step is an optional prompt to save a storage hint
recovery-key-download-button-v3 = Download and continue

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.

Does this still need a .title attribute? If so it seems it was removed. If not, then we'll need to update the string ID since adding/removing an attribute requires a new ID.


# Error message shown in a banner if the account recovery key download failed.
# The id keeps "pdf" from when this was a PDF, to preserve existing translations.
recovery-key-pdf-download-error = Sorry, there was a problem downloading your account recovery key.
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,22 @@

import { Meta } from '@storybook/react';
import AppLayout from '../AppLayout';
import ButtonDownloadRecoveryKeyPDF from '.';
import ButtonDownloadRecoveryKey from '.';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { MOCK_EMAIL } from '../../pages/mocks';

export default {
title: 'Components/ButtonDownloadRecoveryKeyPDF',
component: ButtonDownloadRecoveryKeyPDF,
title: 'Components/ButtonDownloadRecoveryKey',
component: ButtonDownloadRecoveryKey,
decorators: [withLocalization],
} as Meta;

const recoveryKeyValue = 'ABCD 1234 ABCD 1234 ABCD 1234 ABCD O0O0';
const viewName = 'settings.recovery-key';

const storyWithAccount = (email = MOCK_EMAIL) => {
const story = () => (
<AppLayout>
<ButtonDownloadRecoveryKeyPDF
{...{ recoveryKeyValue, viewName, email }}
/>
<ButtonDownloadRecoveryKey {...{ recoveryKeyValue, email }} />
</AppLayout>
);
return story;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { ButtonDownloadRecoveryKey, getFilename } from '.';
import { downloadTextFile } from '../../lib/download';
import * as Sentry from '@sentry/browser';
import { MOCK_EMAIL } from '../../pages/mocks';

jest.mock('../../lib/download', () => ({
downloadTextFile: jest.fn(),
}));

jest.mock('@sentry/browser', () => ({
captureException: jest.fn(),
}));

const MOCK_RECOVERY_KEY = 'WXYZ WXYZ WXYZ WXYZ WXYZ WXYZ WXYZ WXYZ';

const renderButton = (navigateForward?: () => void) =>
renderWithLocalizationProvider(
<ButtonDownloadRecoveryKey
recoveryKeyValue={MOCK_RECOVERY_KEY}
email={MOCK_EMAIL}
{...{ navigateForward }}
/>
);

const getDownloadButton = () =>
screen.getByRole('button', { name: 'Download and continue' });

const renderAndClickDownload = async (navigateForward?: () => void) => {
const user = userEvent.setup();
renderButton(navigateForward);
await user.click(getDownloadButton());
};

const mockDownloadFailure = () =>
jest.mocked(downloadTextFile).mockImplementationOnce(() => {
throw new Error('download failed');
});

beforeEach(() => {
jest.clearAllMocks();
// clearAllMocks leaves implementations queued; reset so an unconsumed
// failure cannot leak into the next test.
jest.mocked(downloadTextFile).mockReset();
});

describe('ButtonDownloadRecoveryKey', () => {
it('renders the download button', () => {
renderButton();

expect(getDownloadButton()).toBeInTheDocument();
});

it('tags the button for Glean click metrics', () => {
renderButton();

expect(getDownloadButton()).toHaveAttribute(
'data-glean-id',
'account_pref_recovery_key_download'
);
});

it('downloads the recovery key as a text file when clicked', async () => {
await renderAndClickDownload();

const today = new Date().toISOString().split('T')[0];
expect(downloadTextFile).toHaveBeenCalledWith(
MOCK_RECOVERY_KEY,
`Mozilla-Recovery-Key_${today}_${MOCK_EMAIL}.txt`
);
});

it('shows an inline error banner when the download throws', async () => {
mockDownloadFailure();

await renderAndClickDownload();

expect(screen.getByRole('alert')).toHaveTextContent(
'Sorry, there was a problem downloading your account recovery key.'
);
});

it('reports the failure to Sentry when the download throws', async () => {
mockDownloadFailure();

await renderAndClickDownload();

expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
});

it('does not navigate forward when the download throws', async () => {
mockDownloadFailure();
const navigateForward = jest.fn();

await renderAndClickDownload(navigateForward);

expect(navigateForward).not.toHaveBeenCalled();
});

it('navigates forward when the download succeeds', async () => {
const navigateForward = jest.fn();

await renderAndClickDownload(navigateForward);

expect(navigateForward).toHaveBeenCalledTimes(1);
});

it('clears a previous error once a retry succeeds', async () => {
const user = userEvent.setup();
mockDownloadFailure();
renderButton();

await user.click(getDownloadButton());
expect(screen.getByRole('alert')).toBeInTheDocument();

await user.click(getDownloadButton());

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('renders no error banner before any download is attempted', () => {
renderButton();

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

describe('getFilename', () => {
const MOCK_DATE = '2026-08-10';

beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date(`${MOCK_DATE}T12:00:00.000Z`));
});

afterEach(() => {
jest.useRealTimers();
});

it('builds the filename from the prefix, current date and email', () => {
expect(getFilename(MOCK_EMAIL)).toBe(
`Mozilla-Recovery-Key_${MOCK_DATE}_${MOCK_EMAIL}.txt`
);
});

it('replaces characters that are unsafe in a filename', () => {
expect(getFilename('a/b@example.com')).toBe(
`Mozilla-Recovery-Key_${MOCK_DATE}_a_b@example.com.txt`
);
});

it('truncates a very long email but keeps the prefix, date and extension', () => {
const longEmail =
'supercalifragilisticexpialidocious@marypoppins.superfan.conference.com';
const filename = getFilename(longEmail);

expect(filename).toBe(
`Mozilla-Recovery-Key_${MOCK_DATE}_supercalifragilisticexpialidocious@mary.txt`
);
});
});
Loading
Loading