mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(auth): explain why a phone number was rejected (#2480)
This commit is contained in:
1 parent
519b3a6127
commit
8734956d86
122 files changed
+2356
-75
No files matched your search
+35
@@ -522,6 +522,41 @@ export const PHONE_NOT_ELIGIBLE_DESCRIPTOR = msg({
|
||||
message: 'Phone verification is not available for this account. Use another method or contact support.',
|
||||
comment: 'Required-action error when the account is not eligible to add phone verification.',
|
||||
});
|
||||
export const PHONE_COUNTRY_NOT_SUPPORTED_DESCRIPTOR = msg({
|
||||
message:
|
||||
"We don't send verification texts to this country. Try a mobile number from another country, or contact support.",
|
||||
comment:
|
||||
"Required-action form error when the phone number's country is on the SMS send ban list. Must not imply the number itself is invalid.",
|
||||
});
|
||||
export const PHONE_INBOUND_REQUIRED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'This number is verified by texting us instead. Go back and start phone verification again to get the code and the number to text.',
|
||||
comment:
|
||||
'Required-action form error when a number must use the inbound text-in challenge and the current step cannot start one. Do not say why the number was routed there.',
|
||||
});
|
||||
export const PHONE_LOOKUP_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Our phone number check is down right now. This is on us, not your number. Wait a few minutes and try the same number again.',
|
||||
comment:
|
||||
'Required-action form error when our own phone lookup provider is unavailable and we fail closed. Must blame us, not the number, and must invite retrying the same number.',
|
||||
});
|
||||
export const PHONE_NOT_IN_SERVICE_DESCRIPTOR = msg({
|
||||
message:
|
||||
"Your carrier says this number isn't in service. Check it and try again, or contact support if it's correct.",
|
||||
comment:
|
||||
'Required-action form error when the carrier lookup says the number does not exist. Leaves room for the lookup to be wrong.',
|
||||
});
|
||||
export const PHONE_NOT_MOBILE_DESCRIPTOR = msg({
|
||||
message:
|
||||
"This isn't a mobile number, so it can't receive our text. Try a mobile number, or contact support if you think that's wrong.",
|
||||
comment:
|
||||
'Required-action form error for landline, toll-free, premium, shared-cost, UAN, voicemail and pager lines. Do not name which line type the provider returned.',
|
||||
});
|
||||
export const PHONE_NEEDS_REVIEW_DESCRIPTOR = msg({
|
||||
message: "We couldn't verify this number automatically. Contact support and a person will review your account.",
|
||||
comment:
|
||||
'Required-action form error for phone attempts blocked by a fraud signal. Deliberately non-specific. Never name the signal, the score or the threshold.',
|
||||
});
|
||||
export const CAPTCHA_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'A browser check is required before phone verification. Try again from the sign-in page or contact support.',
|
||||
comment: 'Required-action error when the API requires a captcha for phone verification.',
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {setupI18n} from '@lingui/core';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
|
||||
vi.mock('@app/features/app/config/Config', () => ({
|
||||
default: {
|
||||
PUBLIC_BUILD_VERSION: 'test',
|
||||
PUBLIC_RELEASE_CHANNEL: 'canary',
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
|
||||
},
|
||||
}));
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: () => null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
|
||||
}));
|
||||
vi.mock('@app/features/app/components/alerts/GenericErrorModalCommands', () => ({showGenericErrorModal: vi.fn()}));
|
||||
vi.mock('@app/features/app/components/dialogs/Modal', () => ({}));
|
||||
vi.mock('@app/features/app/components/shared/ExternalLink', () => ({ExternalLink: () => null}));
|
||||
vi.mock('@app/features/ui/button/Button', () => ({Button: () => null}));
|
||||
vi.mock('@app/features/ui/commands/ToastCommands', () => ({showToast: vi.fn()}));
|
||||
vi.mock('@app/features/ui/components/form/Form', () => ({Form: () => null}));
|
||||
vi.mock('@app/features/ui/components/form/FormInput', () => ({Input: () => null}));
|
||||
vi.mock('@app/features/auth/state/SudoPrompt', () => ({isAbortError: () => false}));
|
||||
|
||||
const {APIErrorCodes} = await import('@fluxer/constants/src/ApiErrorCodes');
|
||||
const {HttpError} = await import('@app/features/platform/types/EndpointError');
|
||||
const {resolveRequiredActionErrorMessage} = await import('./RequiredActionShared');
|
||||
const {
|
||||
ENTER_VALID_PHONE_DESCRIPTOR,
|
||||
PHONE_CANNOT_BE_USED_DESCRIPTOR,
|
||||
PHONE_COUNTRY_NOT_SUPPORTED_DESCRIPTOR,
|
||||
PHONE_INBOUND_REQUIRED_DESCRIPTOR,
|
||||
PHONE_LOOKUP_UNAVAILABLE_DESCRIPTOR,
|
||||
PHONE_NEEDS_REVIEW_DESCRIPTOR,
|
||||
PHONE_NOT_IN_SERVICE_DESCRIPTOR,
|
||||
PHONE_NOT_MOBILE_DESCRIPTOR,
|
||||
SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR,
|
||||
TOO_MANY_ATTEMPTS_DESCRIPTOR,
|
||||
} = await import('./RequiredActionDescriptors');
|
||||
|
||||
const i18n = setupI18n({locale: 'en', messages: {en: {}}});
|
||||
|
||||
function apiError(code: string, body: Record<string, unknown> = {}) {
|
||||
return new HttpError({
|
||||
method: 'POST',
|
||||
path: '/auth/verify-phone',
|
||||
status: 400,
|
||||
body: {code, message: 'server message', ...body},
|
||||
responseHeaders: {},
|
||||
});
|
||||
}
|
||||
|
||||
const PHONE_REJECTION_CASES = [
|
||||
[APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED, PHONE_COUNTRY_NOT_SUPPORTED_DESCRIPTOR],
|
||||
[APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE, PHONE_NOT_IN_SERVICE_DESCRIPTOR],
|
||||
[APIErrorCodes.PHONE_NUMBER_NOT_MOBILE, PHONE_NOT_MOBILE_DESCRIPTOR],
|
||||
[APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE, PHONE_LOOKUP_UNAVAILABLE_DESCRIPTOR],
|
||||
[APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED, PHONE_INBOUND_REQUIRED_DESCRIPTOR],
|
||||
[APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW, PHONE_NEEDS_REVIEW_DESCRIPTOR],
|
||||
] as const;
|
||||
|
||||
describe('resolveRequiredActionErrorMessage', () => {
|
||||
for (const [code, descriptor] of PHONE_REJECTION_CASES) {
|
||||
for (const context of ['phone-number', 'phone-code'] as const) {
|
||||
it(`explains ${code} on the ${context} step`, () => {
|
||||
const message = resolveRequiredActionErrorMessage(i18n, apiError(code), context);
|
||||
expect(message).toBe(i18n._(descriptor));
|
||||
expect(message).not.toBe(i18n._(SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('never blames the number when our own lookup is down', () => {
|
||||
const message = resolveRequiredActionErrorMessage(
|
||||
i18n,
|
||||
apiError(APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE),
|
||||
'phone-number',
|
||||
);
|
||||
expect(message).not.toContain('invalid');
|
||||
expect(message).toContain('try the same number again');
|
||||
});
|
||||
|
||||
it('keeps the fraud message free of any distinguishing detail', () => {
|
||||
const message = resolveRequiredActionErrorMessage(
|
||||
i18n,
|
||||
apiError(APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW),
|
||||
'phone-number',
|
||||
);
|
||||
expect(message).toBe(i18n._(PHONE_NEEDS_REVIEW_DESCRIPTOR));
|
||||
expect(message).toContain('Contact support');
|
||||
});
|
||||
|
||||
it('keeps the existing invalid-number copy per step', () => {
|
||||
expect(resolveRequiredActionErrorMessage(i18n, apiError(APIErrorCodes.INVALID_PHONE_NUMBER), 'phone-number')).toBe(
|
||||
i18n._(ENTER_VALID_PHONE_DESCRIPTOR),
|
||||
);
|
||||
expect(resolveRequiredActionErrorMessage(i18n, apiError(APIErrorCodes.INVALID_PHONE_NUMBER), 'phone-code')).toBe(
|
||||
i18n._(PHONE_CANNOT_BE_USED_DESCRIPTOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('still falls back to the generic message for a code nothing maps', () => {
|
||||
expect(resolveRequiredActionErrorMessage(i18n, apiError('SOME_UNMAPPED_CODE'), 'phone-number')).toBe(
|
||||
i18n._(SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a rate limit win over a new phone rejection code', () => {
|
||||
const message = resolveRequiredActionErrorMessage(
|
||||
i18n,
|
||||
apiError(APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW, {retry_after: 86400}),
|
||||
'phone-number',
|
||||
);
|
||||
expect(message).toBe(i18n._(TOO_MANY_ATTEMPTS_DESCRIPTOR));
|
||||
});
|
||||
});
|
||||
|
||||
const PHONE_GATE_CODES = [
|
||||
APIErrorCodes.INVALID_PHONE_NUMBER,
|
||||
APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
|
||||
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
|
||||
APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE,
|
||||
APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE,
|
||||
APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
|
||||
APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
|
||||
] as const;
|
||||
|
||||
describe('phone gate code coverage', () => {
|
||||
for (const context of ['phone-number', 'phone-code'] as const) {
|
||||
it(`no phone gate code falls through to the generic message on the ${context} step`, () => {
|
||||
const generic = i18n._(SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR);
|
||||
for (const code of PHONE_GATE_CODES) {
|
||||
expect(resolveRequiredActionErrorMessage(i18n, apiError(code), context)).not.toBe(generic);
|
||||
}
|
||||
});
|
||||
}
|
||||
it('gives each of the six new codes its own message', () => {
|
||||
const messages = PHONE_REJECTION_CASES.map(([code]) =>
|
||||
resolveRequiredActionErrorMessage(i18n, apiError(code), 'phone-number'),
|
||||
);
|
||||
expect(new Set(messages).size).toBe(PHONE_REJECTION_CASES.length);
|
||||
});
|
||||
it('never tells a user their working number is invalid for a benign rejection', () => {
|
||||
const benign = [
|
||||
APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
|
||||
APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE,
|
||||
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
|
||||
];
|
||||
for (const code of benign) {
|
||||
const message = resolveRequiredActionErrorMessage(i18n, apiError(code), 'phone-number');
|
||||
expect(message).not.toBe(i18n._(ENTER_VALID_PHONE_DESCRIPTOR));
|
||||
expect(message).not.toBe(i18n._(PHONE_CANNOT_BE_USED_DESCRIPTOR));
|
||||
}
|
||||
});
|
||||
it('routes every dead-end rejection somewhere a person can act', () => {
|
||||
const routed = [
|
||||
APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
|
||||
APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE,
|
||||
APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
|
||||
APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
|
||||
];
|
||||
for (const code of routed) {
|
||||
expect(resolveRequiredActionErrorMessage(i18n, apiError(code), 'phone-number')).toContain('support');
|
||||
}
|
||||
});
|
||||
});
|
||||
+18
@@ -23,7 +23,13 @@ import {
|
||||
NEW_EMAIL_DESCRIPTOR,
|
||||
PHONE_ALREADY_USED_DESCRIPTOR,
|
||||
PHONE_CANNOT_BE_USED_DESCRIPTOR,
|
||||
PHONE_COUNTRY_NOT_SUPPORTED_DESCRIPTOR,
|
||||
PHONE_INBOUND_REQUIRED_DESCRIPTOR,
|
||||
PHONE_LOOKUP_UNAVAILABLE_DESCRIPTOR,
|
||||
PHONE_NEEDS_REVIEW_DESCRIPTOR,
|
||||
PHONE_NOT_ELIGIBLE_DESCRIPTOR,
|
||||
PHONE_NOT_IN_SERVICE_DESCRIPTOR,
|
||||
PHONE_NOT_MOBILE_DESCRIPTOR,
|
||||
SMS_UNAVAILABLE_DESCRIPTOR,
|
||||
SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR,
|
||||
SUPPORT_LINK_LABEL_DESCRIPTOR,
|
||||
@@ -160,6 +166,18 @@ export function resolveRequiredActionErrorMessage(
|
||||
return context === 'phone-number'
|
||||
? i18n._(ENTER_VALID_PHONE_DESCRIPTOR)
|
||||
: i18n._(PHONE_CANNOT_BE_USED_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED:
|
||||
return i18n._(PHONE_COUNTRY_NOT_SUPPORTED_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE:
|
||||
return i18n._(PHONE_NOT_IN_SERVICE_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_NUMBER_NOT_MOBILE:
|
||||
return i18n._(PHONE_NOT_MOBILE_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE:
|
||||
return i18n._(PHONE_LOOKUP_UNAVAILABLE_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED:
|
||||
return i18n._(PHONE_INBOUND_REQUIRED_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW:
|
||||
return i18n._(PHONE_NEEDS_REVIEW_DESCRIPTOR);
|
||||
case APIErrorCodes.INVALID_PHONE_VERIFICATION_CODE:
|
||||
return i18n._(CODE_DID_NOT_WORK_DESCRIPTOR);
|
||||
case APIErrorCodes.PHONE_ALREADY_USED:
|
||||
|
||||
Reference in new issue
Block a user