fix(fxa-settings): Show retryAfter value in error when resetPassword is throttled

Because:

* We want to show the retryAfterLocalized value in localized error messages when ResetPassword requests are throttled (maxEmails reached)

This commit:

* Add retryAfter and retryAfterLocalized from Auth server errors to GraphQl errors
* Make this value available to the localized throttled error message for the ResetPassword page.
* Add ResetPassword stories to show error examples.
* Enable customs-server by default in dev environment, but disable for CircleCi builds.
* Add documentation explaining how to disable/enable customs-server for local testing.
* Use ipv4 for the customs-server localhost (prevent from using ipv6, favoured by Node18)

Closes #FXA-6875
This commit is contained in:
Valerie Pomerleau 2023-04-04 12:12:52 -07:00
parent 0319b1732a
commit 60e2e9c214
No known key found for this signature in database
GPG Key ID: 33A451F0BB2180B4
10 changed files with 207 additions and 25 deletions

View File

@ -71,6 +71,7 @@ executors:
environment:
NODE_ENV: development
FIRESTORE_EMULATOR_HOST: localhost:9090
CUSTOMS_SERVER_URL: none
# Contains minimal image for running common jobs like linting or unit tests.
# This image requires a restored workspace state.
@ -85,6 +86,7 @@ executors:
environment:
NODE_ENV: development
FIRESTORE_EMULATOR_HOST: localhost:9090
CUSTOMS_SERVER_URL: none
# A minimal image for anything job needs infrastructure. Perfect for integration tests.
# This image requires a restored workspace state.
@ -104,6 +106,7 @@ executors:
environment:
NODE_ENV: development
FIRESTORE_EMULATOR_HOST: localhost:9090
CUSTOMS_SERVER_URL: none
# For anything that needs a full stack to run and needs browsers available for
# ui test automation. This image requires a restored workspace state.
@ -141,6 +144,7 @@ executors:
REACT_CONVERSION_POST_VERIFY_ADD_RECOVERY_KEY_ROUTES: true
REACT_CONVERSION_POST_VERIFY_CAD_VIA_QR_ROUTES: true
REACT_CONVERSION_SIGNIN_VERIFICATION_VIA_PUSH_ROUTES: true
CUSTOMS_SERVER_URL: none
# Contains a pre-installed fxa stack and browsers for doing ui test
# automation. Perfect for running smoke tests against remote targets.
@ -154,6 +158,7 @@ executors:
- image: mozilla/fxa-circleci:ci-functional-test-runner
environment:
NODE_ENV: development
CUSTOMS_SERVER_URL: none
commands:

View File

@ -271,6 +271,12 @@ can be overridden in two ways:
export CONFIG_FILES="~/fxa-content-server.json,~/fxa-db.json"
```
### Rate-limiting config
Rate-limiting and blocking is handled by fxa-customs-server. By default, these policies are _enabled_ in dev environment via `"customsUrl":"http://localhost:7000"` in `fxa-auth-server/config/dev.json`, but disabled for circleCI. Enabling the customs server allows error messages to be displayed when rate limiting occurs. Default rate-limiting values are found in `fxa-customs-server/lib/config/config.js` and can be modified with environment variables or by adding a `dev.json` file to `fxa-customs-server/config/`.
The customs-server can be disabled for testing by changing the dev config to `"customsUrl":"none"`.
### Email config
There is also some live config

View File

@ -2,7 +2,7 @@
"contentServer": {
"url": "http://localhost:3030"
},
"customsUrl": "none",
"customsUrl": "http://localhost:7000",
"lockoutEnabled": true,
"log": {
"fmt": "pretty",

View File

@ -15,6 +15,8 @@ module.exports = {
max_restarts: '1',
env: {
NODE_ENV: 'dev',
// By default, Node18 favours ipv6 for localhost
NODE_OPTIONS: '--dns-result-order=ipv4first',
PORT: 7000,
PATH,
SENTRY_ENV: 'local',

View File

@ -4,6 +4,21 @@
import { ApolloError } from 'apollo-server';
export class ThrottledError extends ApolloError {
constructor(
message: string,
code: string,
extensions: {
errno: number;
info: string;
retryAfter: number;
retryAfterLocalized: string;
}
) {
super(message, code, extensions);
}
}
export const PROFILE_INFO_URL =
'https://github.com/mozilla/fxa/blob/main/packages/fxa-profile-server/docs/API.md#errors';
@ -17,8 +32,22 @@ export function CatchGatewayError(
try {
return await originalMethod.apply(this, args);
} catch (err) {
if (err.code && err.errno && err.message) {
// Auth Server error
if (
err.code &&
err.errno &&
err.message &&
err.retryAfter &&
err.retryAfterLocalized
) {
// Auth Server error when throttled (too many requests error)
throw new ThrottledError(err.message, err.code, {
errno: err.errno,
info: err.info,
retryAfter: err.retryAfter,
retryAfterLocalized: err.retryAfterLocalized,
});
} else if (err.code && err.errno && err.message) {
// Auth Server error (general)
throw new ApolloError(err.message, err.code, {
errno: err.errno,
info: err.info,

View File

@ -4,6 +4,7 @@
import base32Decode from 'base32-decode';
import { gql, ApolloClient, Reference, ApolloError } from '@apollo/client';
import { ThrottledError } from 'fxa-graphql-api/src/gql/lib/error';
import config from '../lib/config';
import AuthClient, {
generateRecoveryKey,
@ -550,11 +551,27 @@ export class Account implements AccountData {
`,
variables: { input: { email } },
});
return result.data.passwordForgotSendCode;
} catch (err) {
const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno;
if (errno && AuthUiErrorNos[errno]) {
const graphQlError = ((err as ApolloError) || (err as ThrottledError))
.graphQLErrors[0];
const errno = graphQlError.extensions?.errno;
if (
errno &&
AuthUiErrorNos[errno] &&
errno === AuthUiErrors.THROTTLED.errno
) {
const throttledErrorWithRetryAfter = {
...AuthUiErrorNos[errno],
retryAfter: graphQlError.extensions?.retryAfter,
retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized,
};
throw throttledErrorWithRetryAfter;
} else if (
errno &&
AuthUiErrorNos[errno] &&
errno !== AuthUiErrors.THROTTLED.errno
) {
throw AuthUiErrorNos[errno];
}
throw AuthUiErrors.UNEXPECTED_ERROR;
@ -613,11 +630,28 @@ export class Account implements AccountData {
`,
variables: { input: { email } },
});
return result.data.passwordForgotSendCode;
} catch (err) {
const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno;
if (errno && AuthUiErrorNos[errno]) {
const graphQlError = ((err as ApolloError) || (err as ThrottledError))
.graphQLErrors[0];
const errno = graphQlError.extensions?.errno;
if (
(err as ThrottledError) &&
errno &&
AuthUiErrorNos[errno] &&
errno === AuthUiErrors.THROTTLED.errno
) {
const throttledErrorWithRetryAfter = {
...AuthUiErrorNos[errno],
retryAfter: graphQlError.extensions?.retryAfter,
retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized,
};
throw throttledErrorWithRetryAfter;
} else if (
errno &&
AuthUiErrorNos[errno] &&
errno !== AuthUiErrors.THROTTLED.errno
) {
throw AuthUiErrorNos[errno];
}
throw AuthUiErrors.UNEXPECTED_ERROR;

View File

@ -9,6 +9,12 @@ import { Meta } from '@storybook/react';
import { MOCK_ACCOUNT } from '../../models/mocks';
import { MozServices } from '../../lib/types';
import { withLocalization } from '../../../.storybook/decorators';
import { Account, AppContext } from '../../models';
import {
mockAccountWithThrottledError,
mockAccountWithUnexpectedError,
mockDefaultAccount,
} from './mocks';
export default {
title: 'Pages/ResetPassword',
@ -16,22 +22,35 @@ export default {
decorators: [withLocalization],
} as Meta;
const storyWithProps = (props?: Partial<ResetPasswordProps>) => {
const storyWithProps = (
account: Account,
props?: Partial<ResetPasswordProps>
) => {
const story = () => (
<LocationProvider>
<ResetPassword {...props} />
</LocationProvider>
<AppContext.Provider value={{ account }}>
<LocationProvider>
<ResetPassword {...props} />
</LocationProvider>
</AppContext.Provider>
);
return story;
};
export const Default = storyWithProps();
export const Default = storyWithProps(mockDefaultAccount);
export const WithServiceName = storyWithProps({
export const WithServiceName = storyWithProps(mockDefaultAccount, {
serviceName: MozServices.MozillaVPN,
});
export const WithForceAuth = storyWithProps({
export const WithForceAuth = storyWithProps(mockDefaultAccount, {
prefillEmail: MOCK_ACCOUNT.primaryEmail.email,
forceAuth: true,
});
export const WithThrottledErrorOnSubmit = storyWithProps(
mockAccountWithThrottledError
);
export const WithUnexpectedErrorOnSubmit = storyWithProps(
mockAccountWithUnexpectedError
);

View File

@ -125,7 +125,7 @@ describe('PageResetPassword', () => {
);
});
it('displays errors', async () => {
it('displays unknown account error', async () => {
const gqlError: any = AuthUiErrorNos[102]; // Unknown account error
const account = {
resetPassword: jest.fn().mockRejectedValue(gqlError),
@ -139,4 +139,47 @@ describe('PageResetPassword', () => {
await screen.findByText('Unknown account');
});
it('displays an error when rate limiting kicks in', async () => {
// mocks an error that contains the required values to localize the message
// does not test if the Account model passes in the correct information
// does not test if the message is localized
// does not test if the lang of localizedRetryAfter matches the lang used for the rest of the string
const gqlThrottledErrorWithRetryAfter: any = {
errno: 114,
message: AuthUiErrorNos[114].message,
retryAfter: 500,
retryAfterLocalized: 'in 15 minutes',
}; // Throttled error
const account = {
resetPassword: jest
.fn()
.mockRejectedValue(gqlThrottledErrorWithRetryAfter),
} as unknown as Account;
renderWithAccount(account);
await act(async () => {
fireEvent.click(screen.getByText('Begin Reset'));
});
await screen.findByText(
'Youve tried too many times. Please try again in 15 minutes.'
);
});
it('handles unexpected errors on submit', async () => {
const unknownError = 'some error'; // for example if the error is not a GQLError
const account = {
resetPassword: jest.fn().mockRejectedValue(unknownError),
} as unknown as Account;
renderWithAccount(account);
await act(async () => {
fireEvent.click(screen.getByText('Begin Reset'));
});
await screen.findByText('Unexpected error');
});
});

View File

@ -97,16 +97,27 @@ const ResetPassword = ({
});
} catch (err) {
let localizedError;
if (err.errno === AuthUiErrors.THROTTLED.errno) {
localizedError = ftlMsgResolver.getMsg(
composeAuthUiErrorTranslationId(err),
AuthUiErrorNos[err.errno].message,
{ retryAfter: err.retryAfterLocalized }
);
if (err.errno && AuthUiErrorNos[err.errno]) {
if (
err.errno === AuthUiErrors.THROTTLED.errno &&
err.retryAfterLocalized
) {
localizedError = ftlMsgResolver.getMsg(
composeAuthUiErrorTranslationId(err),
AuthUiErrorNos[err.errno].message,
{ retryAfter: err.retryAfterLocalized }
);
} else {
localizedError = ftlMsgResolver.getMsg(
composeAuthUiErrorTranslationId(err),
AuthUiErrorNos[err.errno].message
);
}
} else {
const unexpectedError = AuthUiErrors.UNEXPECTED_ERROR;
localizedError = ftlMsgResolver.getMsg(
composeAuthUiErrorTranslationId(err),
err.message
composeAuthUiErrorTranslationId(unexpectedError),
unexpectedError.message
);
}
setErrorMessage(localizedError);

View File

@ -0,0 +1,33 @@
/* 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 { MOCK_ACCOUNT } from '../../models/mocks';
import { Account } from '../../models';
// No error message on submit
export const mockDefaultAccount = { MOCK_ACCOUNT } as any as Account;
mockDefaultAccount.resetPassword = () =>
Promise.resolve({ passwordForgotToken: 'mockPasswordForgotToken' });
// Mocked localized throttled error (not localized in storybook)
export const mockAccountWithThrottledError = {
MOCK_ACCOUNT,
} as unknown as Account;
const throttledErrorObj = {
errno: 114,
retryAfter: 500,
retryAfterLocalized: 'in 15 minutes',
};
mockAccountWithThrottledError.resetPassword = () =>
Promise.reject(throttledErrorObj);
export const mockAccountWithUnexpectedError = {
MOCK_ACCOUNT,
} as unknown as Account;
mockAccountWithThrottledError.resetPassword = () =>
Promise.reject('some error');