mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(api): emit moderation events and apply account actions (#3031)
This commit is contained in:
1 parent
c488906131
commit
364084c819
385 files changed
+8994
-22221
No files matched your search
@@ -1,747 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {
|
||||
authorizeOAuth2,
|
||||
createOAuth2Application,
|
||||
exchangeOAuth2AuthorizationCode,
|
||||
} from '@app/api/oauth/tests/OAuthTestUtils';
|
||||
import {PHONE_GATE_ESCAPE_MAX_GUILDS} from '@app/api/risk/DeferredPhoneGate';
|
||||
import {
|
||||
RecommendedAction,
|
||||
RiskConfidence,
|
||||
RiskDecisionMethod,
|
||||
RiskLevel,
|
||||
type RiskLevel as RiskLevelType,
|
||||
} from '@app/api/risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
|
||||
SuspiciousActivityFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
async evaluate() {
|
||||
return {
|
||||
level,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore,
|
||||
reasoning: 'deferred phone gate test',
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({acls: ['*']})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({name: `PhoneGate-${Date.now()}`})
|
||||
.execute();
|
||||
const invite = await createBuilder<{code: string}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}`.concat('/invites'))
|
||||
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
|
||||
.execute();
|
||||
return {guildId: guild.id, inviteCode: invite.code};
|
||||
}
|
||||
|
||||
async function readFlags(userId: string): Promise<number> {
|
||||
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
|
||||
const {createUserID} = await import('@app/api/BrandedTypes');
|
||||
const user = await new UserRepository().findUnique(createUserID(BigInt(userId)));
|
||||
return user?.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
async function readGuildIds(userId: string): Promise<Array<string>> {
|
||||
const {GuildRepository} = await import('@app/api/guild/repositories/GuildRepository');
|
||||
const {createUserID} = await import('@app/api/BrandedTypes');
|
||||
const guilds = await new GuildRepository().listUserGuilds(createUserID(BigInt(userId)));
|
||||
return guilds.map((guild) => guild.id.toString());
|
||||
}
|
||||
|
||||
const ESCAPE_PATH = '/users/@me/required-actions/phone-gate-escape';
|
||||
const RATE_LIMIT_HEADER = 'x-fluxer-test-enable-rate-limits';
|
||||
|
||||
interface EscapeSubject {
|
||||
userId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface EscapePreviewResponse {
|
||||
available: boolean;
|
||||
guilds: Array<{id: string; name: string}>;
|
||||
owned_guilds: Array<{id: string; name: string}>;
|
||||
}
|
||||
|
||||
describe('Deferred phone verification gate', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('applies the phone requirement immediately while the gate is off', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-off'),
|
||||
username: createUniqueUsername('gate_off'),
|
||||
global_name: 'Gate Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('defers the phone requirement at registration while the gate is on', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-on'),
|
||||
username: createUniqueUsername('gate_on'),
|
||||
global_name: 'Gate On',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
const me = await createBuilder<{required_actions: Array<string>}>(harness, registration.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a deferred account join a small guild without being challenged', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
const {guildId, inviteCode} = await createGuildWithInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-small'),
|
||||
username: createUniqueUsername('gate_small'),
|
||||
global_name: 'Gate Small',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(guildId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator({
|
||||
async evaluate() {
|
||||
return {
|
||||
level: RiskLevel.VeryHigh,
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level: RiskLevel.VeryHigh,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: 90,
|
||||
reasoning: 'inbound tier',
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-inbound'),
|
||||
username: createUniqueUsername('gate_inbound'),
|
||||
global_name: 'Gate Inbound',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0);
|
||||
});
|
||||
|
||||
it('promotes the requirement and refuses the join on a qualifying guild inside the window', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-qualifying'),
|
||||
username: createUniqueUsername('gate_qualifying'),
|
||||
global_name: 'Gate Qualifying',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
|
||||
describe('with phone flagging disabled', () => {
|
||||
const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging};
|
||||
afterEach(() => {
|
||||
Config.abusePolicy.phoneFlagging = originalPhoneFlagging;
|
||||
});
|
||||
|
||||
it('sets no phone requirement and no deferral at registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('flagging-off'),
|
||||
username: createUniqueUsername('flagging_off'),
|
||||
global_name: 'Flagging Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an existing deferral dormant on a qualifying join', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('flagging-off-join'),
|
||||
username: createUniqueUsername('flagging_off_join'),
|
||||
global_name: 'Flagging Off Join',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone gate escape', () => {
|
||||
async function configurePhoneGate(
|
||||
overrides: {
|
||||
deferred_phone_gate_enabled?: boolean;
|
||||
deferred_phone_gate_member_threshold?: number;
|
||||
deferred_phone_gate_window_hours?: number;
|
||||
single_community_enabled?: boolean;
|
||||
single_community_guild_id?: string | null;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function registerDeferredUser(prefix: string): Promise<EscapeSubject> {
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail(prefix),
|
||||
username: createUniqueUsername(prefix),
|
||||
global_name: 'Gate Escape',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
return {userId: registration.user_id, token: registration.token};
|
||||
}
|
||||
|
||||
async function addFillerMember(inviteCode: string): Promise<void> {
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
}
|
||||
|
||||
async function makeDiscoverable(guildId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: ['DISCOVERABLE']})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setSuspiciousFlags(userId: string, flags: number): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${userId}/security-flags`)
|
||||
.body({suspicious_activity_flags: flags})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function promoteThroughRefusedJoin(subject: EscapeSubject, inviteCode: string): Promise<void> {
|
||||
await createBuilder(harness, subject.token).post(`/invites/${inviteCode}`).expect(403).execute();
|
||||
}
|
||||
|
||||
async function createPromotedSubject(prefix: string): Promise<EscapeSubject> {
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
const subject = await registerDeferredUser(prefix);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
return subject;
|
||||
}
|
||||
|
||||
function previewEscape(subject: EscapeSubject) {
|
||||
return createBuilder<EscapePreviewResponse>(harness, subject.token).get(ESCAPE_PATH).expect(200).execute();
|
||||
}
|
||||
|
||||
function executeEscape(subject: EscapeSubject) {
|
||||
return createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
|
||||
.post(ESCAPE_PATH)
|
||||
.body({})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function expectEscapeRefused(subject: EscapeSubject) {
|
||||
return createBuilder(harness, subject.token)
|
||||
.post(ESCAPE_PATH)
|
||||
.body({})
|
||||
.expect(400, 'PHONE_GATE_ESCAPE_UNAVAILABLE')
|
||||
.execute();
|
||||
}
|
||||
|
||||
it('answers both escape routes for an account every ordinary route refuses', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_reach');
|
||||
|
||||
await createBuilder(harness, subject.token)
|
||||
.get('/users/@me/guilds')
|
||||
.expect(403, 'ACCOUNT_SUSPICIOUS_ACTIVITY')
|
||||
.execute();
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.available).toBe(true);
|
||||
await executeEscape(subject);
|
||||
});
|
||||
|
||||
it('records the promotion without leaking the bit into the client projection', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_bit');
|
||||
|
||||
const flags = await readFlags(subject.userId);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
|
||||
|
||||
const me = await createBuilder<{required_actions: Array<string> | null; suspicious_activity_flags?: number}>(
|
||||
harness,
|
||||
subject.token,
|
||||
)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
expect(me.suspicious_activity_flags).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unlocks an account that is in no community at all', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_empty');
|
||||
expect(await readGuildIds(subject.userId)).toEqual([]);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview).toEqual({available: true, guilds: [], owned_guilds: []});
|
||||
|
||||
const updated = await executeEscape(subject);
|
||||
expect(updated.required_actions ?? []).toEqual([]);
|
||||
|
||||
const me = await createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual([]);
|
||||
await createBuilder(harness, subject.token).get('/users/@me/guilds').expect(200).execute();
|
||||
|
||||
const flags = await readFlags(subject.userId);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the qualifying community, unlocks the account and dispatches a single user update', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_leave');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.guilds.map((guild) => guild.id)).toEqual([member.guildId]);
|
||||
expect(preview.owned_guilds).toEqual([]);
|
||||
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
try {
|
||||
const updated = await executeEscape(subject);
|
||||
expect(updated.required_actions ?? []).toEqual([]);
|
||||
const userUpdates = dispatchSpy.mock.calls.filter(
|
||||
([params]) => params.event === 'USER_UPDATE' && params.userId.toString() === subject.userId,
|
||||
);
|
||||
expect(userUpdates).toHaveLength(1);
|
||||
} finally {
|
||||
dispatchSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(await readGuildIds(subject.userId)).toEqual([]);
|
||||
const flags = await readFlags(subject.userId);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
});
|
||||
|
||||
it('applies the member threshold strictly, so a community sitting on it is kept', async () => {
|
||||
await configurePhoneGate({deferred_phone_gate_member_threshold: 2});
|
||||
const onThreshold = await createGuildWithInvite(harness);
|
||||
const aboveThreshold = await createGuildWithInvite(harness);
|
||||
await addFillerMember(aboveThreshold.inviteCode);
|
||||
const subject = await registerDeferredUser('escape_bound');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${onThreshold.inviteCode}`).expect(200).execute();
|
||||
await createBuilder(harness, subject.token).post(`/invites/${aboveThreshold.inviteCode}`).expect(200).execute();
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.guilds.map((guild) => guild.id)).toEqual([aboveThreshold.guildId]);
|
||||
|
||||
await executeEscape(subject);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([onThreshold.guildId]);
|
||||
});
|
||||
|
||||
it('leaves a discoverable community that sits far below the member threshold', async () => {
|
||||
await configurePhoneGate({deferred_phone_gate_member_threshold: 50});
|
||||
const discoverable = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_disc');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${discoverable.inviteCode}`).expect(200).execute();
|
||||
await makeDiscoverable(discoverable.guildId);
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await makeDiscoverable(gate.guildId);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.guilds.map((guild) => guild.id)).toEqual([discoverable.guildId]);
|
||||
|
||||
await executeEscape(subject);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a qualifying community the user owns and reports it separately', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_owned');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${subject.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
const owned = await createBuilder<GuildResponse>(harness, subject.token)
|
||||
.post('/guilds')
|
||||
.body({name: `PhoneGateOwned-${Date.now()}`})
|
||||
.execute();
|
||||
const ownedInvite = await createBuilder<{code: string}>(harness, subject.token)
|
||||
.post(`/channels/${owned.system_channel_id}`.concat('/invites'))
|
||||
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
|
||||
.execute();
|
||||
await addFillerMember(ownedInvite.code);
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.guilds.map((guild) => guild.id)).toEqual([member.guildId]);
|
||||
expect(preview.owned_guilds.map((guild) => guild.id)).toEqual([owned.id]);
|
||||
|
||||
const updated = await executeEscape(subject);
|
||||
expect(updated.required_actions ?? []).toEqual([]);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([owned.id]);
|
||||
expect((await readFlags(subject.userId)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
});
|
||||
|
||||
it('leaves what it can when the qualifying set exceeds the per-call limit, and finishes on a second call', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await registerDeferredUser('escape_batch');
|
||||
for (let index = 0; index < PHONE_GATE_ESCAPE_MAX_GUILDS + 1; index++) {
|
||||
const guild = await createGuildWithInvite(harness);
|
||||
await createBuilder(harness, subject.token).post(`/invites/${guild.inviteCode}`).expect(200).execute();
|
||||
}
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.guilds).toHaveLength(PHONE_GATE_ESCAPE_MAX_GUILDS + 1);
|
||||
|
||||
const afterFirst = await executeEscape(subject);
|
||||
expect(afterFirst.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
expect(await readGuildIds(subject.userId)).toHaveLength(1);
|
||||
const flagsAfterFirst = await readFlags(subject.userId);
|
||||
expect(flagsAfterFirst & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flagsAfterFirst & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
|
||||
|
||||
const afterSecond = await executeEscape(subject);
|
||||
expect(afterSecond.required_actions ?? []).toEqual([]);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([]);
|
||||
const flags = await readFlags(subject.userId);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses an account whose phone requirement was never deferred', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
await setSuspiciousFlags(account.userId, SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
|
||||
const subject: EscapeSubject = {userId: account.userId, token: account.token};
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview).toEqual({available: false, guilds: [], owned_guilds: []});
|
||||
await expectEscapeRefused(subject);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
|
||||
expect(await readFlags(subject.userId)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
|
||||
});
|
||||
|
||||
it('refuses while the gate is switched off, leaving flags and memberships untouched', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_off');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
const flagsBefore = await readFlags(subject.userId);
|
||||
|
||||
await configurePhoneGate({deferred_phone_gate_enabled: false});
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.available).toBe(false);
|
||||
await expectEscapeRefused(subject);
|
||||
|
||||
expect(await readFlags(subject.userId)).toBe(flagsBefore);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
|
||||
});
|
||||
|
||||
it('refuses in single-community mode and leaves the designated community alone', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_single');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
const gate = await createGuildWithInvite(harness);
|
||||
await addFillerMember(gate.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, gate.inviteCode);
|
||||
|
||||
await configurePhoneGate({single_community_enabled: true, single_community_guild_id: member.guildId});
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.available).toBe(false);
|
||||
await expectEscapeRefused(subject);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
|
||||
});
|
||||
|
||||
it('refuses an account that is still deferred and has never been locked out', async () => {
|
||||
await configurePhoneGate();
|
||||
const member = await createGuildWithInvite(harness);
|
||||
const subject = await registerDeferredUser('escape_defer');
|
||||
await createBuilder(harness, subject.token).post(`/invites/${member.inviteCode}`).expect(200).execute();
|
||||
const flagsBefore = await readFlags(subject.userId);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview).toEqual({available: false, guilds: [], owned_guilds: []});
|
||||
await expectEscapeRefused(subject);
|
||||
expect(await readFlags(subject.userId)).toBe(flagsBefore);
|
||||
expect(await readGuildIds(subject.userId)).toEqual([member.guildId]);
|
||||
});
|
||||
|
||||
it('refuses a promoted account that also carries the inbound-SMS tier', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_inbound');
|
||||
const promotedFlags = await readFlags(subject.userId);
|
||||
await setSuspiciousFlags(
|
||||
subject.userId,
|
||||
promotedFlags | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
|
||||
);
|
||||
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.available).toBe(false);
|
||||
await expectEscapeRefused(subject);
|
||||
});
|
||||
|
||||
it('closes itself after a successful escape', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_twice');
|
||||
await executeEscape(subject);
|
||||
const flagsAfterFirst = await readFlags(subject.userId);
|
||||
|
||||
await expectEscapeRefused(subject);
|
||||
expect(await readFlags(subject.userId)).toBe(flagsAfterFirst);
|
||||
const preview = await previewEscape(subject);
|
||||
expect(preview.available).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a remaining email requirement in place', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_email');
|
||||
const promotedFlags = await readFlags(subject.userId);
|
||||
await setSuspiciousFlags(subject.userId, promotedFlags | SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
|
||||
const updated = await executeEscape(subject);
|
||||
expect(updated.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
const me = await createBuilder<{required_actions: Array<string> | null}>(harness, subject.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
});
|
||||
|
||||
it('restores the deferral, so a qualifying join re-promotes inside the window and not outside it', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_rearm');
|
||||
await executeEscape(subject);
|
||||
|
||||
const insideWindow = await createGuildWithInvite(harness);
|
||||
await addFillerMember(insideWindow.inviteCode);
|
||||
await promoteThroughRefusedJoin(subject, insideWindow.inviteCode);
|
||||
const repromoted = await readFlags(subject.userId);
|
||||
expect(repromoted & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(repromoted & PHONE_GATE_PROMOTED_FROM_DEFERRAL).not.toBe(0);
|
||||
|
||||
await executeEscape(subject);
|
||||
await configurePhoneGate({deferred_phone_gate_window_hours: 0.0001});
|
||||
const outsideWindow = await createGuildWithInvite(harness);
|
||||
await addFillerMember(outsideWindow.inviteCode);
|
||||
const beforeJoin = await readFlags(subject.userId);
|
||||
expect(beforeJoin & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
await createBuilder(harness, subject.token).post(`/invites/${outsideWindow.inviteCode}`).expect(200).execute();
|
||||
|
||||
const flags = await readFlags(subject.userId);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses bot tokens and OAuth2 bearer tokens on both routes', async () => {
|
||||
await configurePhoneGate();
|
||||
const owner = await createTestAccount(harness);
|
||||
const redirectURI = 'https://example.com/callback';
|
||||
const application = await createOAuth2Application(harness, owner, {redirect_uris: [redirectURI]});
|
||||
const {code} = await authorizeOAuth2(harness, owner.token, {
|
||||
client_id: application.id,
|
||||
redirect_uri: redirectURI,
|
||||
scope: 'identify',
|
||||
});
|
||||
const {access_token: accessToken} = await exchangeOAuth2AuthorizationCode(harness, {
|
||||
client_id: application.id,
|
||||
client_secret: application.client_secret,
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
});
|
||||
|
||||
for (const token of [`Bot ${application.bot.token}`, `Bearer ${accessToken}`]) {
|
||||
await createBuilder(harness, token).get(ESCAPE_PATH).expect(403).execute();
|
||||
await createBuilder(harness, token).post(ESCAPE_PATH).body({}).expect(403).execute();
|
||||
}
|
||||
});
|
||||
|
||||
it('spends the execute budget only on execute calls', async () => {
|
||||
await configurePhoneGate();
|
||||
const subject = await createPromotedSubject('escape_limit');
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await createBuilder(harness, subject.token)
|
||||
.get(ESCAPE_PATH)
|
||||
.header(RATE_LIMIT_HEADER, 'true')
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const {response} = await createBuilder(harness, subject.token)
|
||||
.post(ESCAPE_PATH)
|
||||
.body({})
|
||||
.header(RATE_LIMIT_HEADER, 'true')
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(index === 0 ? 200 : 400);
|
||||
}
|
||||
const {response} = await createBuilder(harness, subject.token)
|
||||
.post(ESCAPE_PATH)
|
||||
.body({})
|
||||
.header(RATE_LIMIT_HEADER, 'true')
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(429);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
unclaimAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS, TEST_USER_DATA} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string}>;
|
||||
}
|
||||
|
||||
interface EmailChangeStartResponse {
|
||||
ticket: string;
|
||||
original_proof?: string;
|
||||
}
|
||||
|
||||
describe('Email blocklist at signup and email change', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'ban:email:add']);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function blocklist(email: string): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/email/entries')
|
||||
.body({email})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function expectGenericEmailError(json: ValidationErrorBody, path: string): void {
|
||||
expect(json.errors.map((entry) => ({path: entry.path, code: entry.code}))).toEqual([
|
||||
{path, code: 'INVALID_EMAIL_ADDRESS'},
|
||||
]);
|
||||
}
|
||||
|
||||
it('refuses registration with a blocklisted address in any letter case', async () => {
|
||||
const email = createUniqueEmail('blocked-signup');
|
||||
await blocklist(email);
|
||||
const {json} = await createBuilder<ValidationErrorBody>(harness, '')
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: email.toUpperCase(),
|
||||
username: createUniqueUsername('blocked'),
|
||||
global_name: TEST_USER_DATA.DEFAULT_GLOBAL_NAME,
|
||||
password: TEST_CREDENTIALS.STRONG_PASSWORD,
|
||||
date_of_birth: TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH,
|
||||
consent: true,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectGenericEmailError(json, 'email');
|
||||
});
|
||||
|
||||
it('still registers an address that is not blocklisted', async () => {
|
||||
await blocklist(createUniqueEmail('blocked-other'));
|
||||
await createTestAccount(harness);
|
||||
});
|
||||
|
||||
it('refuses an email change to a blocklisted address before sending a code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const blocked = createUniqueEmail('blocked-change');
|
||||
await blocklist(blocked);
|
||||
const start = await createBuilder<EmailChangeStartResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({password: account.password})
|
||||
.execute();
|
||||
const originalCode = findLastTestEmail(
|
||||
await listTestEmails(harness, {recipient: account.email}),
|
||||
'email_change_original',
|
||||
)!.metadata.code!;
|
||||
const {original_proof: originalProof} = await createBuilder<{original_proof: string}>(harness, account.token)
|
||||
.post('/users/@me/email-change/verify-original')
|
||||
.body({ticket: start.ticket, code: originalCode, password: account.password})
|
||||
.execute();
|
||||
const {json} = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/email-change/request-new')
|
||||
.body({
|
||||
ticket: start.ticket,
|
||||
new_email: blocked,
|
||||
original_proof: originalProof,
|
||||
password: account.password,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectGenericEmailError(json, 'new_email');
|
||||
expect(await listTestEmails(harness, {recipient: blocked})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses claiming an unclaimed account with a blocklisted address', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const blocked = createUniqueEmail('blocked-claim');
|
||||
await blocklist(blocked);
|
||||
const start = await createBuilder<EmailChangeStartResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const {json} = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/email-change/request-new')
|
||||
.body({ticket: start.ticket, new_email: blocked, original_proof: start.original_proof})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectGenericEmailError(json, 'new_email');
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
loginUser,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {RecommendedAction, RiskConfidence, RiskDecisionMethod, RiskLevel} from '@app/api/risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -124,31 +122,6 @@ async function unclaimAccount(harness: ApiTestHarness, userId: string): Promise<
|
||||
await createBuilderWithoutAuth(harness).post(`/test/users/${userId}/unclaim`).body(null).expect(200).execute();
|
||||
}
|
||||
|
||||
function createForcedRiskEvaluator(onEvaluate?: () => void) {
|
||||
return {
|
||||
async evaluate() {
|
||||
onEvaluate?.();
|
||||
return {
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level: RiskLevel.High,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: 70,
|
||||
reasoning: 'forced test risk result',
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
method: RiskDecisionMethod.Deterministic,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
level: RiskLevel.High,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Email change flow', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -485,58 +458,8 @@ describe('Email change flow', () => {
|
||||
expect(updated.required_actions).not.toContain('REQUIRE_REVERIFIED_EMAIL');
|
||||
expect(updated.required_actions).not.toContain('REQUIRE_VERIFIED_PHONE');
|
||||
});
|
||||
it('re-evaluates plus-tagged email changes on claimed accounts', async () => {
|
||||
it('applies ordinary claimed email changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
let evaluateCalls = 0;
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createForcedRiskEvaluator(() => {
|
||||
evaluateCalls += 1;
|
||||
}),
|
||||
);
|
||||
const startResp = await startEmailChange(harness, account, account.password);
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const originalEmail = findLastTestEmail(emails, 'email_change_original');
|
||||
expect(startResp.require_original).toBe(true);
|
||||
expect(originalEmail?.metadata?.code).toBeDefined();
|
||||
const originalProof = await verifyOriginalEmailChange(
|
||||
harness,
|
||||
account,
|
||||
startResp.ticket,
|
||||
originalEmail!.metadata!.code!,
|
||||
account.password,
|
||||
);
|
||||
const newEmail = `integration-plus+${Date.now()}@example.com`;
|
||||
await requestNewEmailChange(harness, account, startResp.ticket, newEmail, originalProof, account.password);
|
||||
const newEmails = await listTestEmails(harness, {recipient: newEmail});
|
||||
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
|
||||
expect(newEmailData?.metadata?.code).toBeDefined();
|
||||
const token = await verifyNewEmailChange(
|
||||
harness,
|
||||
account,
|
||||
startResp.ticket,
|
||||
newEmailData!.metadata!.code!,
|
||||
originalProof,
|
||||
account.password,
|
||||
);
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({
|
||||
email_token: token,
|
||||
password: account.password,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(evaluateCalls).toBe(1);
|
||||
expect(updated.required_actions).toContain('REQUIRE_VERIFIED_PHONE');
|
||||
});
|
||||
it('does not re-evaluate ordinary claimed email changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
let evaluateCalls = 0;
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createForcedRiskEvaluator(() => {
|
||||
evaluateCalls += 1;
|
||||
}),
|
||||
);
|
||||
const startResp = await startEmailChange(harness, account, account.password);
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const originalEmail = findLastTestEmail(emails, 'email_change_original');
|
||||
@@ -570,7 +493,6 @@ describe('Email change flow', () => {
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(evaluateCalls).toBe(0);
|
||||
expect(updated.required_actions ?? []).not.toContain('REQUIRE_VERIFIED_PHONE');
|
||||
});
|
||||
it('requires MFA (not password) for email_token apply when user has TOTP enabled', async () => {
|
||||
@@ -796,15 +718,9 @@ describe('Email change flow', () => {
|
||||
.execute();
|
||||
expect(updated.email).toBe(newEmail);
|
||||
});
|
||||
it('does not re-evaluate clean unclaimed accounts when they are claimed', async () => {
|
||||
it('claims clean unclaimed accounts', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
let evaluateCalls = 0;
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createForcedRiskEvaluator(() => {
|
||||
evaluateCalls += 1;
|
||||
}),
|
||||
);
|
||||
const startResp = await createBuilder<EmailChangeStartResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
@@ -833,7 +749,6 @@ describe('Email change flow', () => {
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(evaluateCalls).toBe(0);
|
||||
expect(updated.email).toBe(newEmail);
|
||||
expect(updated.verified).toBe(true);
|
||||
expect(updated.required_actions).not.toContain('REQUIRE_VERIFIED_PHONE');
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Inbound SMS challenge without a receiving number', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('rejects the challenge with SMS_VERIFICATION_UNAVAILABLE when no number is configured', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const config = getConfig();
|
||||
const previousInboundNumber = config.sms.inboundChallengeNumber;
|
||||
config.sms.inboundChallengeNumber = undefined;
|
||||
try {
|
||||
const {response, json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/inbound-challenge')
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(400);
|
||||
expect(json).toMatchObject({code: 'SMS_VERIFICATION_UNAVAILABLE'});
|
||||
} finally {
|
||||
config.sms.inboundChallengeNumber = previousInboundNumber;
|
||||
}
|
||||
});
|
||||
it('issues the challenge once a receiving number is configured', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const config = getConfig();
|
||||
const previousInboundNumber = config.sms.inboundChallengeNumber;
|
||||
config.sms.inboundChallengeNumber = '+15551234567';
|
||||
try {
|
||||
const response = await createBuilder<{
|
||||
challenge_code: string;
|
||||
our_number: string;
|
||||
expires_at: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/inbound-challenge')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(response.our_number).toBe('+15551234567');
|
||||
expect(response.challenge_code).toMatch(/^\d{6}$/);
|
||||
} finally {
|
||||
config.sms.inboundChallengeNumber = previousInboundNumber;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {
|
||||
errorForPhoneReply,
|
||||
natsPhoneRpcTransport,
|
||||
type PhoneRpcTransport,
|
||||
PhoneRpcUnreachableError,
|
||||
PhoneVerificationClient,
|
||||
phoneIdempotencyKey,
|
||||
renderPhoneRpcMetrics,
|
||||
} from '@app/api/auth/PhoneVerificationClient';
|
||||
import {HEADER, RPC_PHONE_CHECK, RPC_PHONE_START} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {PhoneAlreadyUsedError} from '@fluxer/errors/src/domains/auth/PhoneAlreadyUsedError';
|
||||
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {connect, type NatsConnection} from '@nats-io/transport-node';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
|
||||
interface RecordedRequest {
|
||||
subject: string;
|
||||
payload: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
class ScriptedTransport implements PhoneRpcTransport {
|
||||
readonly requests: Array<RecordedRequest> = [];
|
||||
constructor(private readonly script: Array<string | Error>) {}
|
||||
|
||||
async request(subject: string, payload: string, options: {timeoutMs: number; headers: Record<string, string>}) {
|
||||
this.requests.push({subject, payload: JSON.parse(payload), timeoutMs: options.timeoutMs, headers: options.headers});
|
||||
const next = this.script.shift();
|
||||
if (next === undefined) throw new Error('no scripted reply');
|
||||
if (next instanceof Error) throw next;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const INFO = {userId: '1174109840998400001', phone: '+15555550123', requestId: 'req-1'};
|
||||
const START = {
|
||||
user_id: INFO.userId,
|
||||
user_flags: '0',
|
||||
has_verified_phone: false,
|
||||
phone: INFO.phone,
|
||||
requested_channel: null,
|
||||
client_ip: '203.0.113.9',
|
||||
captcha_passed: false,
|
||||
};
|
||||
|
||||
describe('phone verification client', () => {
|
||||
it('sends an absolute deadline, a stable idempotency key and the request id', async () => {
|
||||
let now = 1_000_000;
|
||||
const transport = new ScriptedTransport([JSON.stringify({result: 'sms_sent'})]);
|
||||
const client = new PhoneVerificationClient(
|
||||
transport,
|
||||
() => now,
|
||||
() => 'nonce-1',
|
||||
);
|
||||
const reply = await client.start(START, INFO);
|
||||
expect(reply).toEqual({result: 'sms_sent'});
|
||||
const [request] = transport.requests;
|
||||
expect(request.subject).toBe(RPC_PHONE_START);
|
||||
expect(request.payload).toEqual({v: 1, ...START});
|
||||
expect(request.headers[HEADER.deadline]).toBe(String(now + 12_000));
|
||||
expect(request.headers[HEADER.requestId]).toBe('req-1');
|
||||
expect(request.headers[HEADER.idempotencyKey]).toBe(
|
||||
createHash('sha256').update(`start\n${INFO.userId}\n${INFO.phone}\nnonce-1`).digest('base64url'),
|
||||
);
|
||||
expect(request.timeoutMs).toBe(12_250);
|
||||
now += 1;
|
||||
expect(phoneIdempotencyKey('check', INFO, 'n')).not.toBe(phoneIdempotencyKey('start', INFO, 'n'));
|
||||
});
|
||||
|
||||
it('never derives the idempotency key from the client request id', async () => {
|
||||
const transport = new ScriptedTransport([
|
||||
JSON.stringify({result: 'sms_sent'}),
|
||||
JSON.stringify({result: 'sms_sent'}),
|
||||
]);
|
||||
const client = new PhoneVerificationClient(transport);
|
||||
await client.start(START, INFO);
|
||||
await client.start(START, INFO);
|
||||
const [first, second] = transport.requests;
|
||||
expect(first.headers[HEADER.requestId]).toBe(second.headers[HEADER.requestId]);
|
||||
expect(first.headers[HEADER.idempotencyKey]).not.toBe(second.headers[HEADER.idempotencyKey]);
|
||||
});
|
||||
|
||||
it('counts error replies by their code so unavailable is alertable', async () => {
|
||||
const transport = new ScriptedTransport([JSON.stringify({result: 'error', code: 'unavailable'})]);
|
||||
await new PhoneVerificationClient(transport).start(START, INFO);
|
||||
expect(renderPhoneRpcMetrics()).toContain('fluxer_api_phone_rpc_total{op="start",result="unavailable"}');
|
||||
});
|
||||
|
||||
it('retries once with the same key when the service did not answer and enough time remains', async () => {
|
||||
let now = 0;
|
||||
const transport = new ScriptedTransport([
|
||||
new PhoneRpcUnreachableError('no_responders'),
|
||||
JSON.stringify({result: 'sms_sent'}),
|
||||
]);
|
||||
const client = new PhoneVerificationClient(transport, () => now);
|
||||
const pending = client.start(START, INFO);
|
||||
now = 1_000;
|
||||
expect(await pending).toEqual({result: 'sms_sent'});
|
||||
expect(transport.requests).toHaveLength(2);
|
||||
expect(transport.requests[1].headers[HEADER.idempotencyKey]).toBe(
|
||||
transport.requests[0].headers[HEADER.idempotencyKey],
|
||||
);
|
||||
expect(transport.requests[1].headers[HEADER.deadline]).toBe(transport.requests[0].headers[HEADER.deadline]);
|
||||
});
|
||||
|
||||
it('gives up without a retry when too little of the deadline is left', async () => {
|
||||
let now = 0;
|
||||
const transport: PhoneRpcTransport = {
|
||||
request: async () => {
|
||||
now = 9_000;
|
||||
throw new PhoneRpcUnreachableError('timeout');
|
||||
},
|
||||
};
|
||||
const client = new PhoneVerificationClient(transport, () => now);
|
||||
await expect(
|
||||
client.check({user_id: INFO.userId, user_flags: '0', phone: INFO.phone, code: '123456', client_ip: ''}, INFO),
|
||||
).rejects.toBeInstanceOf(SmsVerificationUnavailableError);
|
||||
});
|
||||
|
||||
it('answers unavailable when the service is not configured or replies with garbage', async () => {
|
||||
await expect(new PhoneVerificationClient(null).start(START, INFO)).rejects.toBeInstanceOf(
|
||||
SmsVerificationUnavailableError,
|
||||
);
|
||||
const garbled = new PhoneVerificationClient(new ScriptedTransport(['{"nope":1}']));
|
||||
await expect(garbled.start(START, INFO)).rejects.toBeInstanceOf(SmsVerificationUnavailableError);
|
||||
});
|
||||
|
||||
it('maps service errors to the existing client errors', () => {
|
||||
const limited = errorForPhoneReply({
|
||||
code: 'rate_limited',
|
||||
retry_after_s: 600,
|
||||
rate_limit_scope: 'shared',
|
||||
limit: 3,
|
||||
message: 'Too many verification texts were sent recently. Try again later.',
|
||||
});
|
||||
expect(limited).toBeInstanceOf(RateLimitError);
|
||||
expect((limited as RateLimitError).code).toBe(APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED);
|
||||
const blank = {retry_after_s: null, rate_limit_scope: null, limit: null, message: null};
|
||||
expect(errorForPhoneReply({code: 'already_used', ...blank})).toBeInstanceOf(PhoneAlreadyUsedError);
|
||||
for (const code of ['unavailable', 'deadline_exceeded', 'unsupported_contract'] as const) {
|
||||
expect(errorForPhoneReply({code, ...blank})).toBeInstanceOf(SmsVerificationUnavailableError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const NATS_URL = process.env.FLUXER_TEST_ACTIVITY_NATS_URL;
|
||||
|
||||
describe.skipIf(!NATS_URL)('phone verification client over NATS', () => {
|
||||
let nc: NatsConnection;
|
||||
const seen: Array<{subject: string; headers: Record<string, string>}> = [];
|
||||
beforeAll(async () => {
|
||||
nc = await connect({servers: NATS_URL, token: process.env.FLUXER_TEST_ACTIVITY_NATS_TOKEN});
|
||||
nc.subscribe('rpc.phone.v1.*', {
|
||||
queue: 'phone',
|
||||
callback: (_error, msg) => {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const key of msg.headers?.keys() ?? []) headers[key] = msg.headers!.get(key);
|
||||
seen.push({subject: msg.subject, headers});
|
||||
msg.respond(
|
||||
JSON.stringify({
|
||||
result: 'verified',
|
||||
action: {
|
||||
v: 1,
|
||||
id: 'phone:k',
|
||||
key: INFO.userId,
|
||||
issued_at_ms: 1,
|
||||
expires_at_ms: 2,
|
||||
type: 'phone_verified',
|
||||
user_id: INFO.userId,
|
||||
method: 'outbound',
|
||||
clear_suspicious: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
await nc.flush();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await nc?.close();
|
||||
});
|
||||
|
||||
it('carries the headers to the responder and returns its reply', async () => {
|
||||
const client = new PhoneVerificationClient(natsPhoneRpcTransport(() => nc));
|
||||
const reply = await client.check(
|
||||
{user_id: INFO.userId, user_flags: '0', phone: INFO.phone, code: '123456', client_ip: '203.0.113.9'},
|
||||
INFO,
|
||||
);
|
||||
expect(reply.result).toBe('verified');
|
||||
expect(seen.at(-1)?.subject).toBe(RPC_PHONE_CHECK);
|
||||
expect(seen.at(-1)?.headers[HEADER.requestId]).toBe('req-1');
|
||||
expect(Number(seen.at(-1)?.headers[HEADER.deadline])).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('reports a subject nobody serves as unreachable', async () => {
|
||||
const other = await connect({servers: NATS_URL, token: process.env.FLUXER_TEST_ACTIVITY_NATS_TOKEN});
|
||||
try {
|
||||
const transport = natsPhoneRpcTransport(() => other);
|
||||
await expect(transport.request('rpc.phone.v9.start', '{}', {timeoutMs: 1000, headers: {}})).rejects.toMatchObject(
|
||||
{reason: 'no_responders'},
|
||||
);
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, createTotpSecret, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {PublicUserFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Phone verification flow', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function allowPhoneVerification(userId: string): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${userId}/security-flags`)
|
||||
.body({
|
||||
email_verified: true,
|
||||
suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
it('completes full phone verification flow', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, '')
|
||||
.patch(`/test/users/${account.userId}/flags`)
|
||||
.body({flags: (UserFlags.SPAMMER | UserFlags.HAS_SESSION_STARTED).toString()})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const totpSecret = createTotpSecret();
|
||||
const totpCode = totpCodeNow(totpSecret);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret: totpSecret,
|
||||
code: totpCode,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
await allowPhoneVerification(account.userId);
|
||||
const phone = `+1555${String(Date.now()).slice(-7)}`;
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({
|
||||
phone,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const verifyPhoneJson = await createBuilder<{
|
||||
verified: true;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/verify')
|
||||
.body({
|
||||
phone,
|
||||
code: '123456',
|
||||
})
|
||||
.execute();
|
||||
expect(verifyPhoneJson).toEqual({verified: true});
|
||||
const meJson = await createBuilder<{
|
||||
flags: number;
|
||||
phone: string | null;
|
||||
has_verified_phone: boolean;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(meJson.phone).toBeNull();
|
||||
expect(meJson.has_verified_phone).toBe(true);
|
||||
expect(meJson.flags & PublicUserFlags.SPAMMER).toBe(0);
|
||||
});
|
||||
it('allows a second account to verify the same phone during the encrypted marker window', async () => {
|
||||
const phone = `+1555${String(Date.now()).slice(-7)}`;
|
||||
async function verifyPhone(account: {token: string}): Promise<void> {
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const response = await createBuilder<{
|
||||
verified: true;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/verify')
|
||||
.body({phone, code: '123456'})
|
||||
.execute();
|
||||
expect(response).toEqual({verified: true});
|
||||
}
|
||||
async function hasVerifiedPhone(token: string): Promise<boolean> {
|
||||
const me = await createBuilder<{
|
||||
has_verified_phone: boolean;
|
||||
}>(harness, token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
return me.has_verified_phone;
|
||||
}
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
await allowPhoneVerification(first.userId);
|
||||
await allowPhoneVerification(second.userId);
|
||||
await verifyPhone(first);
|
||||
expect(await hasVerifiedPhone(first.token)).toBe(true);
|
||||
await verifyPhone(second);
|
||||
expect(await hasVerifiedPhone(first.token)).toBe(true);
|
||||
expect(await hasVerifiedPhone(second.token)).toBe(true);
|
||||
});
|
||||
it('rejects a third account from reusing the same phone number during the encrypted marker window', async () => {
|
||||
const phone = `+1555${String(Date.now()).slice(-7)}`;
|
||||
async function verifyPhone(account: {token: string}): Promise<void> {
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const response = await createBuilder<{
|
||||
verified: true;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/verify')
|
||||
.body({phone, code: '123456'})
|
||||
.execute();
|
||||
expect(response).toEqual({verified: true});
|
||||
}
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
const third = await createTestAccount(harness);
|
||||
await allowPhoneVerification(first.userId);
|
||||
await allowPhoneVerification(second.userId);
|
||||
await allowPhoneVerification(third.userId);
|
||||
await verifyPhone(first);
|
||||
await verifyPhone(second);
|
||||
await createBuilder(harness, third.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.expect(400, 'PHONE_ALREADY_USED')
|
||||
.execute();
|
||||
});
|
||||
it('limits each account to 3 phone verification SMS sends across any numbers per 6 hours', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await allowPhoneVerification(account.userId);
|
||||
const phones = Array.from({length: 4}, (_, idx) => `+1555${String(1000000 + idx).padStart(7, '0')}`);
|
||||
for (const phone of phones.slice(0, 3)) {
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
const {response, json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone: phones[3]})
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(429);
|
||||
expect(json).toMatchObject({code: 'PHONE_RATE_LIMIT_EXCEEDED'});
|
||||
});
|
||||
it('limits a specific phone number to 3 SMS sends per 5 days across all accounts', async () => {
|
||||
const phone = '+15551234567';
|
||||
const accounts = await Promise.all([
|
||||
createTestAccount(harness),
|
||||
createTestAccount(harness),
|
||||
createTestAccount(harness),
|
||||
createTestAccount(harness),
|
||||
]);
|
||||
for (const account of accounts) {
|
||||
await allowPhoneVerification(account.userId);
|
||||
}
|
||||
for (const account of accounts.slice(0, 3)) {
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
const {response, json} = await createBuilder(harness, accounts[3].token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone})
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(429);
|
||||
expect(json).toMatchObject({code: 'PHONE_RATE_LIMIT_EXCEEDED'});
|
||||
});
|
||||
it('requires an inbound challenge for expensive outbound SMS prefixes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await allowPhoneVerification(account.userId);
|
||||
const config = getConfig();
|
||||
const previousInboundNumber = config.sms.inboundChallengeNumber;
|
||||
const previousInboundPrefixes = config.abusePolicy.phoneVerification.inboundRequiredPrefixes;
|
||||
config.sms.inboundChallengeNumber = '+15551234567';
|
||||
config.abusePolicy.phoneVerification.inboundRequiredPrefixes = ['+998'];
|
||||
try {
|
||||
const response = await createBuilder<{
|
||||
channel: string;
|
||||
challenge_code: string;
|
||||
our_number: string;
|
||||
reason: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone: '+998991234567'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(response).toMatchObject({
|
||||
channel: 'inbound_challenge',
|
||||
our_number: '+15551234567',
|
||||
reason: 'expensive_destination',
|
||||
});
|
||||
expect(response.challenge_code).toMatch(/^\d{6}$/);
|
||||
} finally {
|
||||
config.sms.inboundChallengeNumber = previousInboundNumber;
|
||||
config.abusePolicy.phoneVerification.inboundRequiredPrefixes = previousInboundPrefixes;
|
||||
}
|
||||
});
|
||||
it('does not let clients force SMS for inbound-only prefixes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await allowPhoneVerification(account.userId);
|
||||
const config = getConfig();
|
||||
const previousInboundNumber = config.sms.inboundChallengeNumber;
|
||||
const previousInboundPrefixes = config.abusePolicy.phoneVerification.inboundRequiredPrefixes;
|
||||
config.sms.inboundChallengeNumber = '+15551234567';
|
||||
config.abusePolicy.phoneVerification.inboundRequiredPrefixes = ['+593'];
|
||||
try {
|
||||
const response = await createBuilder<{
|
||||
channel: string;
|
||||
challenge_code: string;
|
||||
our_number: string;
|
||||
reason: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.body({phone: '+593991234567', channel: 'sms'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(response).toMatchObject({
|
||||
channel: 'inbound_challenge',
|
||||
our_number: '+15551234567',
|
||||
reason: 'expensive_destination',
|
||||
});
|
||||
} finally {
|
||||
config.sms.inboundChallengeNumber = previousInboundNumber;
|
||||
config.abusePolicy.phoneVerification.inboundRequiredPrefixes = previousInboundPrefixes;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {applySharedListUpdate, resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import {getInstanceConfigRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
@@ -298,15 +298,15 @@ describe('Auth registration', () => {
|
||||
expect(login.token.length).toBeGreaterThan(0);
|
||||
expect(login.user_id).toBe(reg.user_id);
|
||||
});
|
||||
it('blocks any request from a Tor exit at the edge', async () => {
|
||||
torExitListCache.seedForTesting(['127.0.0.1']);
|
||||
it('blocks any request from an address on the shared blocked list', async () => {
|
||||
applySharedListUpdate('ip_blocked', '127.0.0.1\n');
|
||||
try {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('tor-register'),
|
||||
username: createUniqueUsername('torregister'),
|
||||
global_name: 'Tor Register',
|
||||
email: createUniqueEmail('blocked-register'),
|
||||
username: createUniqueUsername('blockedregister'),
|
||||
global_name: 'Blocked Register',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
@@ -314,7 +314,7 @@ describe('Auth registration', () => {
|
||||
.expect(403, 'GLOBAL_IP_BANNED')
|
||||
.execute();
|
||||
} finally {
|
||||
torExitListCache.clearForTesting();
|
||||
resetSharedListsForTests();
|
||||
}
|
||||
});
|
||||
it('treats email as case-insensitive across auth flows', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
@@ -9,47 +8,11 @@ import {
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {
|
||||
RecommendedAction,
|
||||
RiskConfidence,
|
||||
RiskDecisionMethod,
|
||||
RiskLevel,
|
||||
type RiskLevel as RiskLevelType,
|
||||
} from '@app/api/risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function createStaticRiskEvaluator(params: {
|
||||
level: RiskLevelType;
|
||||
riskScore: number;
|
||||
recommendedAction: RecommendedAction;
|
||||
}): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
async evaluate() {
|
||||
return {
|
||||
level: params.level,
|
||||
recommendedAction: params.recommendedAction,
|
||||
assessment: {
|
||||
suspicious: params.level !== RiskLevel.Low,
|
||||
level: params.level,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: params.riskScore,
|
||||
reasoning: 'test risk verdict',
|
||||
recommendedAction: params.recommendedAction,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildInvite(harness: ApiTestHarness): Promise<{
|
||||
guildId: string;
|
||||
inviteCode: string;
|
||||
@@ -87,32 +50,23 @@ async function createGuildInvite(harness: ApiTestHarness): Promise<{
|
||||
return {guildId: guild.id, inviteCode: invite.code, ownerToken: owner.token};
|
||||
}
|
||||
|
||||
describe('Auth registration invite auto-join risk gating', () => {
|
||||
describe('Auth registration invite auto-join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('auto-joins the invite on medium-risk registrations', async () => {
|
||||
it('joins the invite guild on registration', async () => {
|
||||
const {guildId, inviteCode, ownerToken} = await createGuildInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createStaticRiskEvaluator({
|
||||
level: RiskLevel.Medium,
|
||||
riskScore: 40,
|
||||
recommendedAction: RecommendedAction.RequireVerifiedEmail,
|
||||
}),
|
||||
);
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('invite-medium'),
|
||||
username: createUniqueUsername('invite_medium'),
|
||||
global_name: 'Invite Medium',
|
||||
email: createUniqueEmail('invite-join'),
|
||||
username: createUniqueUsername('invite_join'),
|
||||
global_name: 'Invite Join',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
@@ -124,52 +78,4 @@ describe('Auth registration invite auto-join risk gating', () => {
|
||||
.executeWithResponse();
|
||||
expect(memberLookup.response.status).toBe(200);
|
||||
});
|
||||
it('auto-joins the invite on high-risk registrations', async () => {
|
||||
const {guildId, inviteCode, ownerToken} = await createGuildInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createStaticRiskEvaluator({
|
||||
level: RiskLevel.High,
|
||||
riskScore: 70,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
}),
|
||||
);
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('invite-high'),
|
||||
username: createUniqueUsername('invite_high'),
|
||||
global_name: 'Invite High',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
invite_code: inviteCode,
|
||||
});
|
||||
const memberLookup = await createBuilder(harness, ownerToken)
|
||||
.get(`/guilds/${guildId}/members/${registration.user_id}`)
|
||||
.expect(200)
|
||||
.executeWithResponse();
|
||||
expect(memberLookup.response.status).toBe(200);
|
||||
});
|
||||
it('does not auto-join the invite on very-high-risk registrations', async () => {
|
||||
const {guildId, inviteCode, ownerToken} = await createGuildInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(
|
||||
createStaticRiskEvaluator({
|
||||
level: RiskLevel.VeryHigh,
|
||||
riskScore: 90,
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
}),
|
||||
);
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('invite-veryhigh'),
|
||||
username: createUniqueUsername('invite_veryhigh'),
|
||||
global_name: 'Invite VeryHigh',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
invite_code: inviteCode,
|
||||
});
|
||||
const memberLookup = await createBuilder(harness, ownerToken)
|
||||
.get(`/guilds/${guildId}/members/${registration.user_id}`)
|
||||
.expect(404)
|
||||
.executeWithResponse();
|
||||
expect(memberLookup.response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {applySharedListUpdate, resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -50,18 +51,23 @@ describe('Registration validation', () => {
|
||||
expect(emailError?.message).toBe("Format d'adresse e-mail invalide.");
|
||||
});
|
||||
it('rejects blocked registration email TLDs as invalid email', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: '[email protected]',
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
applySharedListUpdate('email_tld_blocked', 'blocked\n');
|
||||
try {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: '[email protected]',
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
} finally {
|
||||
resetSharedListsForTests();
|
||||
}
|
||||
});
|
||||
it('rejects weak password', async () => {
|
||||
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
type LoginSuccessResponse,
|
||||
listTestEmails,
|
||||
loginAccount,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getKVAccountDeletionQueue, getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {generateUniquePassword, HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
describe('Signing in again reactivates the account on every sign-in path', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
await clearTestEmails(harness);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function readUser(account: TestAccount) {
|
||||
return (await getUserRepository().findUnique(createUserID(BigInt(account.userId))))!;
|
||||
}
|
||||
|
||||
async function resetPassword(account: TestAccount): Promise<LoginSuccessResponse> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: account.email})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const token = findLastTestEmail(emails, 'password_reset')!.metadata!.token!;
|
||||
return createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: generateUniquePassword()})
|
||||
.execute();
|
||||
}
|
||||
|
||||
test('a password reset cancels a self-requested deletion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/delete')
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect((await readUser(account)).pendingDeletionAt).not.toBeNull();
|
||||
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
|
||||
const reset = await resetPassword(account);
|
||||
const user = await readUser(account);
|
||||
expect(user.flags & UserFlags.SELF_DELETED).toBe(0n);
|
||||
expect(user.pendingDeletionAt).toBeNull();
|
||||
expect(user.deletionReasonCode).toBeNull();
|
||||
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
|
||||
await createBuilder(harness, reset.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('a password reset reactivates a disabled account', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect((await readUser(account)).flags & UserFlags.DISABLED).toBe(UserFlags.DISABLED);
|
||||
await resetPassword(account);
|
||||
expect((await readUser(account)).flags & UserFlags.DISABLED).toBe(0n);
|
||||
});
|
||||
|
||||
test('a device handoff from a live session cancels a pending inactivity deletion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const users = getUserRepository();
|
||||
const user = await readUser(account);
|
||||
const pendingDeletionAt = new Date(Date.now() + 86_400_000);
|
||||
await users.updateDeletionSchedule(user, {
|
||||
flags: user.flags | UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: DeletionReasons.INACTIVITY,
|
||||
});
|
||||
await reschedulePendingDeletion({
|
||||
userId: user.id,
|
||||
nextPendingDeletionAt: pendingDeletionAt,
|
||||
deletionReasonCode: DeletionReasons.INACTIVITY,
|
||||
userRepository: users,
|
||||
deletionQueue: getKVAccountDeletionQueue(),
|
||||
});
|
||||
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
|
||||
const initiated = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.body(null)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initiated.code}/info`).execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.body({code: initiated.code, token: login.token, user_id: login.userId})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const after = await readUser(account);
|
||||
expect(after.flags & UserFlags.SELF_DELETED).toBe(0n);
|
||||
expect(after.pendingDeletionAt).toBeNull();
|
||||
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
|
||||
});
|
||||
|
||||
test('a temporary ban still blocks every sign-in path', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const users = getUserRepository();
|
||||
const user = await readUser(account);
|
||||
await users.patchUpsert(
|
||||
user.id,
|
||||
{flags: user.flags | UserFlags.DISABLED, temp_banned_until: new Date(Date.now() + 3_600_000)},
|
||||
user.toRow(),
|
||||
);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: account.email})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const token = findLastTestEmail(emails, 'password_reset')!.metadata!.token!;
|
||||
const {response} = await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: generateUniquePassword()})
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
|
||||
expect((await readUser(account)).flags & UserFlags.DISABLED).toBe(UserFlags.DISABLED);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user