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
4 changes: 4 additions & 0 deletions packages/functional-tests/pages/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export class SettingsPage extends SettingsLayout {
return this.lazyRow('primary-email', PrimaryEmailRow);
}

get errorLoadingApp() {
return this.page.getByTestId('error-loading-app');
}

get secondaryEmail() {
return this.lazyRow('secondary-email', SecondaryEmailRow);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* 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 { expect, test } from '../../lib/fixtures/standard';

test.describe('severity-2', () => {
test.beforeEach(
async ({
target,
page,
pages: { signin, settings },
testAccountTracker,
}) => {
const credentials = await testAccountTracker.signUp();
await page.goto(target.contentServerUrl);
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);
await expect(settings.settingsHeading).toBeVisible();
}
);

test('settings shows the error dialog when the account fetch fails', async ({
page,
pages: { settings },
}) => {
await page.route('**/v1/account', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ code: 500, errno: 999, error: 'Internal' }),
})
);
await settings.goto();

await expect(settings.errorLoadingApp).toBeVisible();
await expect(settings.settingsHeading).toBeHidden();
});

test('password row shows no created date without passwordCreatedAt', async ({
page,
pages: { settings },
}) => {
await page.route('**/v1/account', async (route) => {
const response = await route.fetch();
const body = await response.json();
delete body.passwordCreatedAt;
await route.fulfill({ response, json: body });
});
await settings.goto();

await expect(settings.password.status).toHaveText('••••••••••••••••••');
await expect(page.getByTestId('settings-security')).not.toContainText(
'Created'
);
});
});
84 changes: 74 additions & 10 deletions packages/fxa-auth-server/lib/routes/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4023,6 +4023,10 @@ describe('/account', () => {
Container.set(CapabilityService, {});
});

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

describe('web subscriptions', () => {
beforeEach(() => {
mockCustomer = {
Expand Down Expand Up @@ -4076,20 +4080,36 @@ describe('/account', () => {
});
});

it('should propagate other errors from stripe.customer', async () => {
it('should return empty subscriptions and report other errors from stripe.customer', async () => {
const sentryModule = require('../sentry');
const reportSpy = jest
.spyOn(sentryModule, 'reportSentryError')
.mockReturnValue({});
const stripeError = error.unexpectedError();
mockStripeHelper.fetchCustomer = jest.fn(() => {
throw error.unexpectedError();
throw stripeError;
});

let failed = false;
try {
await runTest(buildRoute(), request, () => {});
} catch (err: any) {
failed = true;
expect(err.errno).toBe(error.ERRNO.UNEXPECTED_ERROR);
}
await runTest(buildRoute(), request, (result: any) => {
expect(result.subscriptions).toEqual([]);
});
expect(log.error).toHaveBeenCalledWith(
'Account.get.subscriptions.error',
{ err: stripeError }
);
expect(reportSpy).toHaveBeenCalledWith(stripeError, request);
});

it('should not log or report unknownCustomer errors', async () => {
const sentryModule = require('../sentry');
const reportSpy = jest.spyOn(sentryModule, 'reportSentryError');
mockStripeHelper.fetchCustomer = jest.fn(() => {
throw error.unknownCustomer();
});

expect(failed).toBe(true);
await runTest(buildRoute(), request, () => {});
expect(log.error).not.toHaveBeenCalled();
expect(reportSpy).not.toHaveBeenCalled();
});

it('should not return stripe.customer result when subscriptions are disabled', () => {
Expand Down Expand Up @@ -4239,6 +4259,28 @@ describe('/account', () => {
);
});

it('should return empty subscriptions when the Play read fails after Stripe succeeds', async () => {
const sentryModule = require('../sentry');
const reportSpy = jest
.spyOn(sentryModule, 'reportSentryError')
.mockReturnValue({});
mockCustomer = { id: 1234, subscriptions: ['fake'] };
mockWebSubscriptionsResponse = [webSubscription];
const playError = new Error('play unavailable');
mockPlaySubscriptions.getSubscriptions = jest.fn(async () => {
throw playError;
});

await runTest(
buildRoute(subscriptionsEnabled, playSubscriptionsEnabled),
request,
(result: any) => {
expect(result.subscriptions).toEqual([]);
}
);
expect(reportSpy).toHaveBeenCalledWith(playError, request);
});

it('should not return Play subscriptions when Play subscriptions are disabled', () => {
playSubscriptionsEnabled = false;
mockCustomer = {
Expand Down Expand Up @@ -4395,6 +4437,28 @@ describe('/account', () => {
);
});

it('should return empty subscriptions when the App Store read fails after Stripe succeeds', async () => {
const sentryModule = require('../sentry');
const reportSpy = jest
.spyOn(sentryModule, 'reportSentryError')
.mockReturnValue({});
mockCustomer = { id: 1234, subscriptions: ['fake'] };
mockWebSubscriptionsResponse = [webSubscription];
const appStoreError = new Error('app store unavailable');
mockAppStoreSubscriptions.getSubscriptions = jest.fn(async () => {
throw appStoreError;
});

await runTest(
buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled),
request,
(result: any) => {
expect(result.subscriptions).toEqual([]);
}
);
expect(reportSpy).toHaveBeenCalledWith(appStoreError, request);
});

it('should not return App Store subscriptions when App Store subscriptions are disabled', () => {
appStoreSubscriptionsEnabled = false;
mockCustomer = {
Expand Down
8 changes: 7 additions & 1 deletion packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
escapeLikePattern,
} from '@fxa/accounts/email-sender';
import { getClientServiceTags } from '../metrics/client-tags';
import { reportSentryError } from '../sentry';

const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema;

Expand Down Expand Up @@ -2258,8 +2259,13 @@ export class AccountHandler {
).map(appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO);
}
} catch (err) {
// Subscriptions are optional here; a failed read must not fail the whole account response.
webSubscriptions = [];
iapGooglePlaySubscriptions = [];
iapAppStoreSubscriptions = [];
Comment on lines 2261 to +2265
if (err.errno !== error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER) {
throw err;
this.log.error('Account.get.subscriptions.error', { err });
reportSentryError(err, request);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ export const Default = storyWithAccount({
},
});

export const PasswordWithoutCreatedDate = storyWithAccount(
{
recoveryKey: { exists: false },
totp: { exists: false, verified: false },
hasPassword: true,
backupCodes: {
hasBackupCodes: false,
},
},
{ storyName: 'Password set, no created date' }
);

export const SecurityFeaturesEnabled = storyWithAccount(
{
recoveryKey: { exists: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ describe('Security', () => {
);
});

it('does not render a created date when passwordCreated is missing', async () => {
const account = {
recoveryKey: { exists: false },
totp: { exists: false },
backupCodes: { hasBackupCodes: false, count: 0 },
primaryEmail: {
email: MOCK_EMAIL,
},
passwordCreated: 0,
hasPassword: true,
} as unknown as Account;
renderWithRouter(
<AppContext.Provider value={mockAppContext({ account })}>
<Security />
</AppContext.Provider>
);

await screen.findByText('••••••••••••••••••');
expect(screen.queryByText(/Created/)).not.toBeInTheDocument();
});

it('renders as expected when account does not have a password', async () => {
const account = {
recoveryKey: { exists: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const Security = forwardRef<HTMLDivElement>((_, ref) => {
}}
>
{hasPassword ? (
<PwdDate {...{ passwordCreated }} />
passwordCreated > 0 && <PwdDate {...{ passwordCreated }} />
) : (
<Localized id="security-set-password">
<p className="text-sm mt-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { renderHook, act } from '@testing-library/react';
import * as Sentry from '@sentry/browser';
import { useAccountData } from '.';
import { sessionToken as getSessionToken } from '../../cache';

Expand Down Expand Up @@ -46,4 +47,36 @@ describe('useAccountData', () => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error?.message).toBe('No session token available');
});

it('sets error and keeps default account data when the account fetch fails', async () => {
(getSessionToken as jest.Mock).mockReturnValue('tok');
authClient.account.mockRejectedValue(new Error('Unexpected error'));
authClient.attachedClients.mockResolvedValue([]);
authClient.createOAuthToken.mockRejectedValue(new Error('no token'));

let result: any;
await act(async () => {
({ result } = renderHook(() => useAccountData({ authClient })));
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error?.message).toBe('Unexpected error');
expect(mockSetAccountData).not.toHaveBeenCalled();
expect(Sentry.captureMessage).not.toHaveBeenCalled();
});

it('keeps account data when only profile and attached clients fail', async () => {
(getSessionToken as jest.Mock).mockReturnValue('tok');
authClient.account.mockResolvedValue({ passwordCreatedAt: 1234 });
authClient.attachedClients.mockRejectedValue(new Error('clients down'));
authClient.createOAuthToken.mockRejectedValue(new Error('no token'));

let result: any;
await act(async () => {
({ result } = renderHook(() => useAccountData({ authClient })));
});
expect(result.current.error).toBeNull();
expect(mockSetAccountData).toHaveBeenCalledWith(
expect.objectContaining({ passwordCreated: 1234, attachedClients: [] })
);
});
});
16 changes: 5 additions & 11 deletions packages/fxa-settings/src/lib/hooks/useAccountData/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,19 +263,13 @@ export function useAccountData({
throw new InvalidTokenError();
}

let accountData: Partial<AccountState> = {};

if (accountResult.status === 'fulfilled') {
accountData = {
...accountData,
...transformAccountResponse(accountResult.value),
};
} else {
Sentry.captureMessage(
`Failed to fetch account: ${accountResult.reason}`
);
// Without account data, Settings would render defaults that look like lost account state.
if (accountResult.status === 'rejected') {
throw accountResult.reason;
}

const accountData = transformAccountResponse(accountResult.value);

if (profileResult.status === 'fulfilled') {
const { displayName, avatar } = profileResult.value;
if (displayName !== null) accountData.displayName = displayName;
Expand Down
Loading