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
@@ -6,10 +6,17 @@ import {BotUserAuthEndpointAccessDeniedError} from '@fluxer/errors/src/domains/a
|
||||
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
|
||||
import {InvalidPhoneVerificationCodeError} from '@fluxer/errors/src/domains/auth/InvalidPhoneVerificationCodeError';
|
||||
import {PhoneAlreadyUsedError} from '@fluxer/errors/src/domains/auth/PhoneAlreadyUsedError';
|
||||
import {PhoneCountryNotSupportedError} from '@fluxer/errors/src/domains/auth/PhoneCountryNotSupportedError';
|
||||
import {PhoneInboundVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneInboundVerificationRequiredError';
|
||||
import {PhoneLookupUnavailableError} from '@fluxer/errors/src/domains/auth/PhoneLookupUnavailableError';
|
||||
import {PhoneNumberNotInServiceError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotInServiceError';
|
||||
import {PhoneNumberNotMobileError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotMobileError';
|
||||
import {PhoneVerificationNeedsReviewError} from '@fluxer/errors/src/domains/auth/PhoneVerificationNeedsReviewError';
|
||||
import {PhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneVerificationRequiredError';
|
||||
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
|
||||
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {PHONE_E164_REGEX} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
import type {RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import type {PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
|
||||
@@ -126,7 +133,7 @@ async function validatePhoneOrThrow(ctx: ApiContext, phone: string, options: Val
|
||||
'Phone verification rejected at gate',
|
||||
);
|
||||
}
|
||||
throw new InvalidPhoneNumberError();
|
||||
throw errorForPhoneRejectReason(verdict.rejectReason);
|
||||
}
|
||||
if (verdict.verdict === 'require_inbound') {
|
||||
Logger.info(
|
||||
@@ -141,7 +148,7 @@ async function validatePhoneOrThrow(ctx: ApiContext, phone: string, options: Val
|
||||
if (options.inboundCapable) {
|
||||
throw new PhoneInboundChallengeRequiredError(verdict.inboundReason);
|
||||
}
|
||||
throw new InvalidPhoneNumberError();
|
||||
throw new PhoneInboundVerificationRequiredError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +157,7 @@ function assertPhoneFormatOrThrow(phone: string): void {
|
||||
throw new InvalidPhoneNumberError();
|
||||
}
|
||||
if (phonePrefixBanCache.isBlocked(phone)) {
|
||||
throw new InvalidPhoneNumberError();
|
||||
throw new PhoneCountryNotSupportedError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +242,7 @@ export async function sendPhoneVerificationCode(
|
||||
await ctx.services.phoneAttemptRisk.record({...riskInput, rejected: false});
|
||||
return await issueInboundChallengeOrThrow(ctx, userId, phone, error.reason);
|
||||
}
|
||||
rejectedAttempt = true;
|
||||
rejectedAttempt = !(error instanceof PhoneLookupUnavailableError);
|
||||
throw error;
|
||||
}
|
||||
const cached = await ctx.services.phoneLookup?.getCachedLookup(phone);
|
||||
@@ -516,6 +523,25 @@ type PhoneVerdictResult =
|
||||
| {verdict: 'reject'; rejectReason: PhoneAttemptRejectReason}
|
||||
| {verdict: 'require_inbound'; inboundReason: PhoneAttemptInboundReason};
|
||||
|
||||
export function errorForPhoneRejectReason(reason: PhoneAttemptRejectReason): FluxerError {
|
||||
switch (reason) {
|
||||
case 'invalid_format':
|
||||
return new InvalidPhoneNumberError();
|
||||
case 'banned_prefix':
|
||||
return new PhoneCountryNotSupportedError();
|
||||
case 'lookup_unavailable':
|
||||
return new PhoneLookupUnavailableError();
|
||||
case 'invalid_number':
|
||||
return new PhoneNumberNotInServiceError();
|
||||
case 'line_type_not_mobile':
|
||||
case 'line_type_hard_rejected':
|
||||
return new PhoneNumberNotMobileError();
|
||||
case 'sms_pumping_risk_high':
|
||||
case 'behavioural_risk_blocked':
|
||||
return new PhoneVerificationNeedsReviewError();
|
||||
}
|
||||
}
|
||||
|
||||
function computePhoneVerdict(lookup: PhoneLookupResult | null, phone: string): PhoneVerdictResult {
|
||||
if (lookup == null) {
|
||||
return {verdict: 'reject', rejectReason: 'lookup_unavailable'};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
|
||||
import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {errorForPhoneRejectReason} from '../AuthPhone';
|
||||
import type {PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
|
||||
|
||||
const ALL_REJECT_REASONS: ReadonlyArray<PhoneAttemptRejectReason> = [
|
||||
'invalid_format',
|
||||
'banned_prefix',
|
||||
'lookup_unavailable',
|
||||
'invalid_number',
|
||||
'line_type_not_mobile',
|
||||
'line_type_hard_rejected',
|
||||
'sms_pumping_risk_high',
|
||||
'behavioural_risk_blocked',
|
||||
];
|
||||
|
||||
const PHONE_GATE_CODES: ReadonlyArray<string> = Array.from(
|
||||
new Set([
|
||||
...ALL_REJECT_REASONS.map((reason) => errorForPhoneRejectReason(reason).code),
|
||||
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
|
||||
]),
|
||||
);
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
|
||||
const CLIENT_SWITCH_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
'fluxer_app/src/features/auth/components/modals/required_action/RequiredActionShared.tsx',
|
||||
);
|
||||
|
||||
describe('phone gate error codes', () => {
|
||||
it('produces every code the gate is meant to produce', () => {
|
||||
expect(PHONE_GATE_CODES.slice().sort()).toEqual(
|
||||
[
|
||||
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,
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
it.each(PHONE_GATE_CODES)('%s is a declared APIErrorCode', (code) => {
|
||||
expect(Object.values(APIErrorCodes)).toContain(code);
|
||||
});
|
||||
it.each(PHONE_GATE_CODES)('%s has an ErrorCodeMappings entry', (code) => {
|
||||
expect(ErrorCodeToI18nKey[code as keyof typeof ErrorCodeToI18nKey]).toBeTruthy();
|
||||
});
|
||||
it.each(PHONE_GATE_CODES)('%s resolves to a real English message', (code) => {
|
||||
const message = getErrorMessageUnsafe(code, 'en-US');
|
||||
expect(message).not.toBe(code);
|
||||
expect(message).not.toBe(ErrorCodeToI18nKey[code as keyof typeof ErrorCodeToI18nKey]);
|
||||
expect(message.length).toBeGreaterThan(0);
|
||||
});
|
||||
it.each(PHONE_GATE_CODES)('%s has a case in the required-action client switch', (code) => {
|
||||
const source = readFileSync(CLIENT_SWITCH_PATH, 'utf8');
|
||||
expect(source).toContain(`case APIErrorCodes.${code}:`);
|
||||
});
|
||||
it('keeps the two fraud reasons on one shared code', () => {
|
||||
expect(errorForPhoneRejectReason('sms_pumping_risk_high').code).toBe(
|
||||
errorForPhoneRejectReason('behavioural_risk_blocked').code,
|
||||
);
|
||||
});
|
||||
it('gives every benign reason a code the fraud reasons do not use', () => {
|
||||
const review = errorForPhoneRejectReason('sms_pumping_risk_high').code;
|
||||
const benign = ALL_REJECT_REASONS.filter(
|
||||
(reason) => reason !== 'sms_pumping_risk_high' && reason !== 'behavioural_risk_blocked',
|
||||
).map((reason) => errorForPhoneRejectReason(reason).code);
|
||||
expect(benign).not.toContain(review);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import type {PhoneLineType, PhoneLookupResult} from '@pkgs/sms/src/PhoneLookupTypes';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {errorForPhoneRejectReason, sendPhoneVerificationCode, verifyPhoneCode} from '../AuthPhone';
|
||||
import {phonePrefixBanCache} from '../PhonePrefixBanCache';
|
||||
import type {PhoneAttemptInboundReason, PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
|
||||
|
||||
const USER_ID = 1n as UserID;
|
||||
const MOBILE_US = '+15125550123';
|
||||
const CANADIAN = '+16045550123';
|
||||
|
||||
function lookup(overrides: Partial<PhoneLookupResult> = {}): PhoneLookupResult {
|
||||
return {
|
||||
valid: true,
|
||||
lineType: 'mobile',
|
||||
countryCode: 'US',
|
||||
carrierName: 'Test',
|
||||
smsPumpingRiskScore: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface HarnessOptions {
|
||||
lookupResult?: PhoneLookupResult | null;
|
||||
userFlags?: bigint;
|
||||
riskDecision?: 'allow' | 'require_inbound';
|
||||
challengeFails?: boolean;
|
||||
}
|
||||
|
||||
function buildContext(options: HarnessOptions = {}) {
|
||||
const issueChallenge = vi.fn(async () => {
|
||||
if (options.challengeFails) {
|
||||
throw new Error('inbound challenge service down');
|
||||
}
|
||||
return {challengeCode: 'ABCD', ourNumber: '+15005550006', expiresAt: new Date(0)};
|
||||
});
|
||||
const record = vi.fn(async (_input: {rejected: boolean}) => undefined);
|
||||
const services = {
|
||||
sms: {
|
||||
lookupPhone: vi.fn(async () => (options.lookupResult === undefined ? lookup() : options.lookupResult)),
|
||||
checkVerification: vi.fn(async () => true),
|
||||
startVerificationWithResult: vi.fn(async () => undefined),
|
||||
},
|
||||
phoneLookup: null,
|
||||
users: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: USER_ID,
|
||||
isBot: false,
|
||||
flags: options.userFlags ?? 0n,
|
||||
hasVerifiedPhone: false,
|
||||
})),
|
||||
},
|
||||
cache: {get: vi.fn(async () => null), ttl: vi.fn(async () => 0), set: vi.fn(async () => undefined)},
|
||||
phoneAttemptRisk: {
|
||||
evaluate: vi.fn(async () => ({decision: options.riskDecision ?? 'allow', reason: null, counters: {}})),
|
||||
record,
|
||||
},
|
||||
inboundSmsChallenge: {issueChallenge},
|
||||
config: {sms: {inboundChallengeNumber: '+15005550006'}},
|
||||
};
|
||||
return {ctx: {services} as unknown as ApiContext, issueChallenge, record};
|
||||
}
|
||||
|
||||
async function codeFromVerify(phone: string, options: HarnessOptions = {}): Promise<string> {
|
||||
const {ctx} = buildContext(options);
|
||||
try {
|
||||
await verifyPhoneCode(ctx, phone, '123456', USER_ID);
|
||||
} catch (error) {
|
||||
return (error as FluxerError).code;
|
||||
}
|
||||
throw new Error(`verifyPhoneCode did not reject ${phone}`);
|
||||
}
|
||||
|
||||
async function inboundReasonFromSend(phone: string, options: HarnessOptions = {}, channel?: 'inbound_challenge') {
|
||||
const {ctx} = buildContext(options);
|
||||
const result = await sendPhoneVerificationCode(ctx, phone, USER_ID, channel ? {channel} : {});
|
||||
if (result.channel !== 'inbound_challenge') {
|
||||
throw new Error(`sendPhoneVerificationCode returned ${result.channel} for ${phone}`);
|
||||
}
|
||||
return result.reason;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
phonePrefixBanCache.unban('+31970');
|
||||
});
|
||||
|
||||
const REJECT_REASON_CODES: Record<PhoneAttemptRejectReason, string> = {
|
||||
invalid_format: APIErrorCodes.INVALID_PHONE_NUMBER,
|
||||
banned_prefix: APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED,
|
||||
lookup_unavailable: APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE,
|
||||
invalid_number: APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE,
|
||||
line_type_not_mobile: APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
|
||||
line_type_hard_rejected: APIErrorCodes.PHONE_NUMBER_NOT_MOBILE,
|
||||
sms_pumping_risk_high: APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
|
||||
behavioural_risk_blocked: APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
|
||||
};
|
||||
|
||||
const INBOUND_REASONS: Record<PhoneAttemptInboundReason, true> = {
|
||||
voip: true,
|
||||
canadian: true,
|
||||
unknown_line_type: true,
|
||||
expensive_destination: true,
|
||||
account_forced: true,
|
||||
behavioural_risk: true,
|
||||
};
|
||||
|
||||
describe('every reject reason maps to its own error', () => {
|
||||
it.each(Object.entries(REJECT_REASON_CODES))('%s produces %s', (reason, code) => {
|
||||
expect(errorForPhoneRejectReason(reason as PhoneAttemptRejectReason).code).toBe(code);
|
||||
});
|
||||
it('covers all eight declared reject reasons', () => {
|
||||
expect(Object.keys(REJECT_REASON_CODES)).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject reasons reaching the caller through the real gate', () => {
|
||||
it('invalid_format keeps the plain invalid-number error', async () => {
|
||||
expect(await codeFromVerify('5551234')).toBe(REJECT_REASON_CODES.invalid_format);
|
||||
});
|
||||
it('banned_prefix says the country is unsupported', async () => {
|
||||
phonePrefixBanCache.ban('+31970');
|
||||
expect(await codeFromVerify('+31970123456')).toBe(REJECT_REASON_CODES.banned_prefix);
|
||||
});
|
||||
it('lookup_unavailable blames our own checker', async () => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: null})).toBe(REJECT_REASON_CODES.lookup_unavailable);
|
||||
});
|
||||
it('invalid_number says the carrier rejected it', async () => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({valid: false})})).toBe(
|
||||
REJECT_REASON_CODES.invalid_number,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'landline',
|
||||
'tollFree',
|
||||
'premium',
|
||||
'sharedCost',
|
||||
'uan',
|
||||
'voicemail',
|
||||
'pager',
|
||||
] as const)('line_type_hard_rejected for %s says it is not a mobile', async (lineType) => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
|
||||
REJECT_REASON_CODES.line_type_hard_rejected,
|
||||
);
|
||||
});
|
||||
it('sms_pumping_risk_high routes to human review', async () => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({smsPumpingRiskScore: 100})})).toBe(
|
||||
REJECT_REASON_CODES.sms_pumping_risk_high,
|
||||
);
|
||||
});
|
||||
it('line_type_not_mobile is unreachable through the verdict and is covered by the mapper alone', async () => {
|
||||
const accepted: Array<PhoneLineType> = ['mobile', 'personal'];
|
||||
for (const lineType of accepted) {
|
||||
const {ctx} = buildContext({lookupResult: lookup({lineType})});
|
||||
const rejection = await verifyPhoneCode(ctx, MOBILE_US, '123456', USER_ID).then(
|
||||
() => null,
|
||||
(error: FluxerError) => error.code,
|
||||
);
|
||||
expect(rejection).not.toBe(APIErrorCodes.PHONE_NUMBER_NOT_MOBILE);
|
||||
expect(rejection).not.toBe(APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED);
|
||||
}
|
||||
expect(errorForPhoneRejectReason('line_type_not_mobile').code).toBe(REJECT_REASON_CODES.line_type_not_mobile);
|
||||
});
|
||||
it('behavioural_risk_blocked has no producer in the gate and is covered by the mapper alone', () => {
|
||||
expect(errorForPhoneRejectReason('behavioural_risk_blocked').code).toBe(
|
||||
REJECT_REASON_CODES.behavioural_risk_blocked,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every inbound reason', () => {
|
||||
it('covers all six declared inbound reasons', () => {
|
||||
expect(Object.keys(INBOUND_REASONS)).toHaveLength(6);
|
||||
});
|
||||
it.each(['fixedVoip', 'nonFixedVoip'] as const)('routes %s to the inbound challenge as voip', async (lineType) => {
|
||||
expect(await inboundReasonFromSend(MOBILE_US, {lookupResult: lookup({lineType})})).toBe('voip');
|
||||
});
|
||||
it('routes a Canadian number to the inbound challenge as canadian', async () => {
|
||||
expect(await inboundReasonFromSend(CANADIAN)).toBe('canadian');
|
||||
});
|
||||
it.each([null, 'unknown'] as const)('routes line type %s to the inbound challenge', async (lineType) => {
|
||||
expect(await inboundReasonFromSend(MOBILE_US, {lookupResult: lookup({lineType})})).toBe('unknown_line_type');
|
||||
});
|
||||
it('routes an explicit inbound channel request as expensive_destination', async () => {
|
||||
expect(await inboundReasonFromSend(MOBILE_US, {}, 'inbound_challenge')).toBe('expensive_destination');
|
||||
});
|
||||
it('routes a force-flagged account as account_forced', async () => {
|
||||
expect(await inboundReasonFromSend(MOBILE_US, {userFlags: UserFlags.FORCE_INBOUND_PHONE_VERIFICATION})).toBe(
|
||||
'account_forced',
|
||||
);
|
||||
});
|
||||
it('routes a risk-flagged account as behavioural_risk', async () => {
|
||||
expect(await inboundReasonFromSend(MOBILE_US, {riskDecision: 'require_inbound'})).toBe('behavioural_risk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inbound reasons a caller cannot answer', () => {
|
||||
it.each([
|
||||
[lookup({lineType: 'fixedVoip'}), MOBILE_US],
|
||||
[lookup({lineType: 'nonFixedVoip'}), MOBILE_US],
|
||||
[lookup({lineType: null}), MOBILE_US],
|
||||
[lookup({lineType: 'unknown'}), MOBILE_US],
|
||||
[lookup(), CANADIAN],
|
||||
])('tells the user to restart phone verification instead of calling the number invalid', async (result, phone) => {
|
||||
expect(await codeFromVerify(phone, {lookupResult: result})).toBe(APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED);
|
||||
});
|
||||
it('never reports the inbound reason to the caller', async () => {
|
||||
const voip = await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType: 'fixedVoip'})});
|
||||
const canadian = await codeFromVerify(CANADIAN);
|
||||
const unknown = await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType: 'unknown'})});
|
||||
expect(new Set([voip, canadian, unknown]).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inbound challenge issuance failures', () => {
|
||||
it.each([
|
||||
['voip', {lookupResult: lookup({lineType: 'fixedVoip'})}, undefined],
|
||||
['account_forced', {userFlags: UserFlags.FORCE_INBOUND_PHONE_VERIFICATION}, undefined],
|
||||
['behavioural_risk', {riskDecision: 'require_inbound' as const}, undefined],
|
||||
['expensive_destination', {}, 'inbound_challenge' as const],
|
||||
])('surfaces the send-path outage for %s', async (_reason, options, channel) => {
|
||||
const {ctx} = buildContext({...(options as HarnessOptions), challengeFails: true});
|
||||
try {
|
||||
await sendPhoneVerificationCode(ctx, MOBILE_US, USER_ID, channel ? {channel} : {});
|
||||
} catch (error) {
|
||||
expect((error as FluxerError).code).toBe(APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
throw new Error('sendPhoneVerificationCode did not reject');
|
||||
});
|
||||
});
|
||||
|
||||
async function recordedRejectionFor(options: HarnessOptions): Promise<boolean> {
|
||||
const {ctx, record} = buildContext(options);
|
||||
await sendPhoneVerificationCode(ctx, MOBILE_US, USER_ID, {}).catch(() => undefined);
|
||||
const call = record.mock.calls.at(-1)?.[0];
|
||||
if (!call) {
|
||||
throw new Error('phoneAttemptRisk.record was never called');
|
||||
}
|
||||
return call.rejected;
|
||||
}
|
||||
|
||||
describe('what each rejection costs the user', () => {
|
||||
it('does not count our own lookup outage against them', async () => {
|
||||
expect(await recordedRejectionFor({lookupResult: null})).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
['invalid_number', lookup({valid: false})],
|
||||
['line_type_hard_rejected', lookup({lineType: 'landline'})],
|
||||
['sms_pumping_risk_high', lookup({smsPumpingRiskScore: 100})],
|
||||
])('still counts %s against them', async (_reason, lookupResult) => {
|
||||
expect(await recordedRejectionFor({lookupResult})).toBe(true);
|
||||
});
|
||||
it('does not count an inbound-challenge routing against them', async () => {
|
||||
expect(await recordedRejectionFor({lookupResult: lookup({lineType: 'fixedVoip'})})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {errorForPhoneRejectReason} from '../AuthPhone';
|
||||
import type {PhoneAttemptRejectReason} from '../services/PhoneLookupRepository';
|
||||
|
||||
const EXPECTED_CODES: ReadonlyArray<[PhoneAttemptRejectReason, string]> = [
|
||||
['invalid_format', APIErrorCodes.INVALID_PHONE_NUMBER],
|
||||
['banned_prefix', APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED],
|
||||
['lookup_unavailable', APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE],
|
||||
['invalid_number', APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE],
|
||||
['line_type_not_mobile', APIErrorCodes.PHONE_NUMBER_NOT_MOBILE],
|
||||
['line_type_hard_rejected', APIErrorCodes.PHONE_NUMBER_NOT_MOBILE],
|
||||
['sms_pumping_risk_high', APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW],
|
||||
['behavioural_risk_blocked', APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW],
|
||||
];
|
||||
|
||||
describe('errorForPhoneRejectReason', () => {
|
||||
it.each(EXPECTED_CODES)('maps %s to %s', (reason, code) => {
|
||||
expect(errorForPhoneRejectReason(reason).code).toBe(code);
|
||||
});
|
||||
it.each(EXPECTED_CODES)('returns a 400 for %s', (reason) => {
|
||||
expect(errorForPhoneRejectReason(reason).status).toBe(400);
|
||||
});
|
||||
it('gives both line type rejections the same code', () => {
|
||||
expect(errorForPhoneRejectReason('line_type_not_mobile').code).toBe(
|
||||
errorForPhoneRejectReason('line_type_hard_rejected').code,
|
||||
);
|
||||
});
|
||||
it('gives both fraud signals the same response body', async () => {
|
||||
const pumping = await errorForPhoneRejectReason('sms_pumping_risk_high').getResponse().text();
|
||||
const behavioural = await errorForPhoneRejectReason('behavioural_risk_blocked').getResponse().text();
|
||||
expect(pumping).toBe(behavioural);
|
||||
});
|
||||
it('keeps the fraud signals distinct from every benign reason', () => {
|
||||
const review = errorForPhoneRejectReason('sms_pumping_risk_high').code;
|
||||
const benign = EXPECTED_CODES.filter(([, code]) => code !== review).map(([, code]) => code);
|
||||
expect(benign).not.toContain(review);
|
||||
});
|
||||
});
|
||||
@@ -23692,8 +23692,14 @@
|
||||
"PASSKEYS_DISABLED",
|
||||
"PHONE_ADD_NOT_ELIGIBLE",
|
||||
"PHONE_ALREADY_USED",
|
||||
"PHONE_COUNTRY_NOT_SUPPORTED",
|
||||
"PHONE_GATE_ESCAPE_UNAVAILABLE",
|
||||
"PHONE_INBOUND_VERIFICATION_REQUIRED",
|
||||
"PHONE_LOOKUP_UNAVAILABLE",
|
||||
"PHONE_NUMBER_NOT_IN_SERVICE",
|
||||
"PHONE_NUMBER_NOT_MOBILE",
|
||||
"PHONE_RATE_LIMIT_EXCEEDED",
|
||||
"PHONE_VERIFICATION_NEEDS_REVIEW",
|
||||
"PHONE_VERIFICATION_REQUIRED",
|
||||
"PREMIUM_PURCHASE_BLOCKED",
|
||||
"PREVIEW_MUST_BE_JPEG",
|
||||
@@ -24001,8 +24007,14 @@
|
||||
"Passkeys are disabled.",
|
||||
"You are not eligible to add a phone number to your account.",
|
||||
"Phone number is already in use.",
|
||||
"Setting the phone verification check aside is not available for this account.",
|
||||
"We don't send verification texts to this country. Use a mobile number from another country, or email [email protected] and a person will review your account.",
|
||||
"This account cannot postpone the phone verification check.",
|
||||
"This number is verified by texting us instead of us texting you. Start phone verification again to get the code and the number to text.",
|
||||
"Our phone number check is down right now, so we stopped before sending your code. This is on us, not your number. Wait a few minutes and try the same number again.",
|
||||
"Your carrier says this number isn't in service. Check the number and try again, or email [email protected] if it's correct.",
|
||||
"This isn't a mobile number, so it can't receive our text. Use a mobile number, or email [email protected] if you think that's wrong.",
|
||||
"Phone rate limit exceeded.",
|
||||
"We couldn't verify this number automatically. Email [email protected] and a person will review your account.",
|
||||
"Phone verification is required.",
|
||||
"No active subscription.",
|
||||
"Preview must be JPEG.",
|
||||
|
||||
Reference in new issue
Block a user