refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
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);
});
});