fix(auth): explain why a phone number was rejected (#2480)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 03:09:26 +02:00
1 parent 519b3a6127
commit 8734956d86
122 files changed
+2356 -75

No files matched your search

@@ -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.',
@@ -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');
}
});
});
@@ -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: