mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-28 07:03:55 +00:00
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:
parent
0319b1732a
commit
60e2e9c214
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"contentServer": {
|
||||
"url": "http://localhost:3030"
|
||||
},
|
||||
"customsUrl": "none",
|
||||
"customsUrl": "http://localhost:7000",
|
||||
"lockoutEnabled": true,
|
||||
"log": {
|
||||
"fmt": "pretty",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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(
|
||||
'You’ve 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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
33
packages/fxa-settings/src/pages/ResetPassword/mocks.tsx
Normal file
33
packages/fxa-settings/src/pages/ResetPassword/mocks.tsx
Normal 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');
|
||||
Loading…
Reference in New Issue
Block a user