// SPDX-License-Identifier: AGPL-3.0-or-later import {createHmac, randomBytes, randomUUID} from 'node:crypto'; import {decode as base32Decode, encode as base32Encode} from 'hi-base32'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {TEST_CREDENTIALS, TEST_USER_DATA} from '../../test/TestConstants'; import {createBuilder} from '../../test/TestRequestBuilder'; interface RegisterResponse { user_id: string; token: string; } export interface LoginSuccessResponse { user_id: string; token: string; } export interface LoginMfaResponse { mfa: true; ticket: string; allowed_methods: Array; totp: boolean; webauthn: boolean; } type LoginResponse = | { user_id: string; token: string; } | { mfa: true; ticket: string; allowed_methods: Array; totp: boolean; webauthn: boolean; }; export interface UserMeResponse { id: string; email: string | null; username: string; global_name: string | null; } export interface TestEmailRecord { to: string; subject: string; type: string; timestamp: string; metadata: Record; } export interface TestAccount { email: string; password: string; userId: string; token: string; ipAddress?: string; username?: string; } interface CreateTestAccountParams { email?: string; password?: string; username?: string; globalName?: string; dateOfBirth?: string; ipAddress?: string; skipSessionStart?: boolean; skipEmailVerification?: boolean; } export function createUniqueEmail(prefix = 'integration'): string { return `${prefix}-${randomUUID()}@example.com`; } function createRandomHexId(): string { return randomUUID().replace(/-/g, ''); } export function createUniqueUsername(prefix = 'itest'): string { return `${prefix}_${createRandomHexId().slice(0, 12)}`; } export function createUniqueTestId(prefix: string): string { return `${prefix}-${randomUUID()}`; } export async function createAuthHarness(): Promise { return await createApiTestHarness(); } export async function registerUser(harness: ApiTestHarness, body: Record): Promise { return createBuilder(harness, '').post('/auth/register').body(body).execute(); } export async function createTestAccount( harness: ApiTestHarness, params?: CreateTestAccountParams, ): Promise { const email = params?.email ?? createUniqueEmail('account'); const password = params?.password ?? TEST_CREDENTIALS.STRONG_PASSWORD; const username = params?.username ?? createUniqueUsername('account'); const ipAddress = params?.ipAddress; const registrationBuilder = createBuilder(harness, '') .post('/auth/register') .body({ email, username, global_name: params?.globalName ?? TEST_USER_DATA.DEFAULT_GLOBAL_NAME, password, date_of_birth: params?.dateOfBirth ?? TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH, consent: true, }); if (ipAddress) { registrationBuilder.header('x-forwarded-for', ipAddress); } const reg = await registrationBuilder.execute(); if (!params?.skipSessionStart) { const HAS_SESSION_STARTED = BigInt(1) << BigInt(39); const sessionStartBuilder = createBuilder(harness, reg.token) .patch(`/test/users/${reg.user_id}/flags`) .body({ flags: HAS_SESSION_STARTED.toString(), }); if (ipAddress) { sessionStartBuilder.header('x-forwarded-for', ipAddress); } await sessionStartBuilder.execute(); } if (!params?.skipEmailVerification) { const securityFlagsBuilder = createBuilder(harness, '').post(`/test/users/${reg.user_id}/security-flags`).body({ email_verified: true, suspicious_activity_flags: 0, }); if (ipAddress) { securityFlagsBuilder.header('x-forwarded-for', ipAddress); } await securityFlagsBuilder.execute(); } return {email, password, userId: reg.user_id, token: reg.token, username, ipAddress}; } export async function loginUser( harness: ApiTestHarness, body: { email: string; password: string; invite_code?: string | null; }, options?: { ipAddress?: string; }, ): Promise { const builder = createBuilder(harness, '').post('/auth/login').body(body); if (options?.ipAddress) { builder.header('x-forwarded-for', options.ipAddress); } return builder.execute(); } export async function loginAccount(harness: ApiTestHarness, account: TestAccount): Promise { const login = await loginUser( harness, {email: account.email, password: account.password}, {ipAddress: account.ipAddress}, ); if ('mfa' in login) { throw new Error('Expected non-MFA login for test account'); } const {token, user_id} = login as { user_id: string; token: string; }; return {...account, token, userId: user_id}; } export async function fetchMe( harness: ApiTestHarness, token: string, expectedStatus: 200 | 401 = 200, ): Promise<{ response: Response; json: unknown; }> { const {response, json} = await createBuilder(harness, token).get('/users/@me').expect(expectedStatus).executeRaw(); return {response, json}; } export async function fetchSettings( harness: ApiTestHarness, token: string, expectedStatus: 200 | 401 = 200, ): Promise<{ response: Response; json: unknown; }> { const {response, json} = await createBuilder(harness, token) .get('/users/@me/settings') .expect(expectedStatus) .executeRaw(); return {response, json}; } export async function listTestEmails( harness: ApiTestHarness, params?: { recipient?: string; }, ): Promise> { const query = params?.recipient ? `?recipient=${encodeURIComponent(params.recipient)}` : ''; const response = await createBuilder<{ emails: Array; }>(harness, '') .get(`/test/emails${query}`) .execute(); return response.emails; } export async function clearTestEmails(harness: ApiTestHarness): Promise { await createBuilder(harness, '').delete('/test/emails').expect(204).execute(); } export function findLastTestEmail(emails: Array, type: string): TestEmailRecord | null { for (let i = emails.length - 1; i >= 0; i--) { const email = emails[i]; if (email?.type === type) return email; } return null; } export function titleCaseEmail(email: string): string { return email .toLowerCase() .replace(/(^|[.@])([a-z])/g, (_match, prefix: string, char: string) => `${prefix}${char.toUpperCase()}`); } export interface BackupCodesResponse { backup_codes: Array<{ code: string; }>; } export function createTotpSecret(): string { const buf = randomBytes(20); return base32Encode(buf).replace(/=/g, ''); } export function generateTotpCode(secret: string, time = Date.now()): string { const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase())); const epoch = Math.floor(time / 1000); const counter = Math.floor(epoch / 30); const counterBuf = Buffer.alloc(8); counterBuf.writeBigUInt64BE(BigInt(counter)); const hmac = createHmac('sha1', key); hmac.update(counterBuf); const hash = hmac.digest(); const offset = hash[hash.length - 1] & 0x0f; const binary = ((hash[offset]! & 0x7f) << 24) | ((hash[offset + 1]! & 0xff) << 16) | ((hash[offset + 2]! & 0xff) << 8) | (hash[offset + 3]! & 0xff); const otp = binary % 1000000; return otp.toString().padStart(6, '0'); } export function totpCodeNow(secret: string): string { return generateTotpCode(secret, Date.now()); } export async function seedMfaTicket( harness: ApiTestHarness, ticket: string, userId: string, ttlSeconds: number, ): Promise { await createBuilder(harness, '') .post('/test/auth/mfa-ticket') .body({ ticket, user_id: userId, ttl_seconds: ttlSeconds, }) .execute(); } export async function setUserACLs( harness: ApiTestHarness, account: TestAccount, acls: Array, ): Promise { const builder = createBuilder(harness, `${account.token}`).post(`/test/users/${account.userId}/acls`).body({acls}); if (account.ipAddress) { builder.header('x-forwarded-for', account.ipAddress); } await builder.execute(); return await loginAccount(harness, account); } export async function unclaimAccount(harness: ApiTestHarness, userId: string): Promise { await createBuilder(harness, '').post(`/test/users/${userId}/unclaim`).body(null).execute(); } interface SsoConfig { enabled: boolean; enforced: boolean; authorization_url: string; token_url: string; client_id: string; client_secret: string; scope: string; allowed_domains: Array; auto_provision: boolean; redirect_uri: string; display_name?: string; } export async function enableSso( harness: ApiTestHarness, token: string, overrides: Partial = {}, ): Promise { const ssoConfig: SsoConfig = { enabled: true, enforced: true, authorization_url: 'test', token_url: 'test', client_id: 'itest-client', client_secret: '', scope: 'openid email profile', allowed_domains: ['example.com'], auto_provision: true, redirect_uri: '', ...overrides, }; await createBuilder(harness, token).post('/admin/instance-config/update').body({sso: ssoConfig}).execute(); } export async function disableSso(harness: ApiTestHarness, token: string): Promise { await createBuilder(harness, token) .post('/admin/instance-config/update') .body({ sso: { enabled: false, }, }) .execute(); } export async function createSessionFromLogin(harness: ApiTestHarness, account: TestAccount): Promise { const login = await loginUser(harness, {email: account.email, password: account.password}); if ('mfa' in login && login.mfa) { throw new Error('Expected non-MFA login for test account'); } const nonMfaLogin = login as { user_id: string; token: string; }; return nonMfaLogin.token; } export function createFakeAuthToken(): string { return `flx_${createRandomHexId()}${createRandomHexId().slice(0, 4)}`; } export async function logoutSpecificSessions( harness: ApiTestHarness, token: string, sessionIdHashes: Array, password: string, ): Promise { await createBuilder(harness, token) .post('/auth/sessions/logout') .body({ session_id_hashes: sessionIdHashes, password, }) .expect(204) .execute(); }