/** * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * E2E-style tests for AuthController signup, login, and token-grant flows. * * Drives the controller's extracted route-handler methods directly with * synthetic req/res shapes — that way we exercise the full controller * logic (DB writes via in-memory sqlite, real password hashing, real * JWT signing/verifying via TokenService, real PermissionService writes) * without needing the HTTP layer's middleware (rate limiting, captcha, * anti-CSRF) to play along. Aligns with AGENTS.md: "Prefer test server * over mocking deps." */ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { EventClient } from '../../clients/event/EventClient.js'; import type { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; import { requireUserActorGate } from '../../core/http/middleware/gates.js'; import { PuterServer } from '../../server.js'; import { FULL_API_ACCESS } from '../../services/permission/consts.js'; import { setupTestServer } from '../../testUtil.js'; // ── Test harness ──────────────────────────────────────────────────── let server: PuterServer; let controller: any; let eventClient: EventClient; beforeAll(async () => { server = await setupTestServer(); controller = server.controllers.auth; eventClient = server.clients.event; installSharedListeners(); }); afterAll(async () => { await server?.shutdown(); }); // EventClient has no `off()`, and its listener registry is a private field // — we can't pop listeners after each test. Instead we register a single // shared listener at module init and have it consult mutable state. Tests // that need to inspect or manipulate validate-events flip the state and // reset it in a `finally` block. type SignupValidateOverride = (data: { allow: boolean; no_temp_user: boolean; requires_email_confirmation: boolean; message: string | null; code: string | null; }) => void; let signupValidateOverride: SignupValidateOverride | null = null; const heardSignupSuccess: Array> = []; const heardUserDelete: Array> = []; // Card verification is pure mechanism in core: a payments extension fills in the // event fields via emitAndWait. These overrides let a test stand in for that // extension, using the same shared-listener pattern as the signup validate // override above (EventClient has no off()). null override => no extension. type CardSetupOverride = (data: { enabled: boolean | null; client_secret: string | null; publishable_key: string | null; }) => void; type CardConfirmOverride = (data: { enabled: boolean | null; verified: boolean; reason: string | null; }) => void; let cardSetupOverride: CardSetupOverride | null = null; let cardConfirmOverride: CardConfirmOverride | null = null; const installSharedListeners = () => { eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => { if (signupValidateOverride) { signupValidateOverride( data as Parameters[0], ); } }); eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => { heardSignupSuccess.push(data as Record); }); eventClient.on('user.delete', (_k: unknown, data: unknown) => { heardUserDelete.push(data as Record); }); eventClient.on( 'puter.card-verification.setup', (_k: unknown, data: unknown) => { if (cardSetupOverride) { cardSetupOverride(data as Parameters[0]); } }, ); eventClient.on( 'puter.card-verification.confirm', (_k: unknown, data: unknown) => { if (cardConfirmOverride) { cardConfirmOverride(data as Parameters[0]); } }, ); }; const withSignupValidateOverride = async ( override: SignupValidateOverride, fn: () => Promise, ): Promise => { signupValidateOverride = override; try { return await fn(); } finally { signupValidateOverride = null; } }; const withCardSetupOverride = async ( override: CardSetupOverride, fn: () => Promise, ): Promise => { cardSetupOverride = override; try { return await fn(); } finally { cardSetupOverride = null; } }; const withCardConfirmOverride = async ( override: CardConfirmOverride, fn: () => Promise, ): Promise => { cardConfirmOverride = override; try { return await fn(); } finally { cardConfirmOverride = null; } }; // ── Synthetic req/res helpers ─────────────────────────────────────── interface MockRes { statusCode: number; body: unknown; headersSent: boolean; cookies: Record }>; clearedCookies: string[]; sent: string | null; ended: boolean; status(code: number): MockRes; json(body: unknown): MockRes; cookie( name: string, value: string, opts?: Record, ): MockRes; clearCookie(name: string): MockRes; send(text: string): MockRes; end(): MockRes; } const makeRes = (): MockRes => { const res: MockRes = { statusCode: 200, body: undefined, headersSent: false, cookies: {}, clearedCookies: [], sent: null, ended: false, status(code: number) { this.statusCode = code; return this; }, json(body: unknown) { this.body = body; this.headersSent = true; return this; }, cookie(name: string, value: string, opts?: Record) { this.cookies[name] = { value, opts }; return this; }, clearCookie(name: string) { this.clearedCookies.push(name); return this; }, send(text: string) { this.sent = text; this.headersSent = true; return this; }, end() { this.ended = true; this.headersSent = true; return this; }, }; return res; }; const makeReq = ( body: Record = {}, extra: Partial<{ actor: Actor; token: string; headers: Record; ip: string; params: Record; }> = {}, ) => ({ body, headers: extra.headers ?? {}, connection: { remoteAddress: extra.ip ?? '127.0.0.1' }, socket: { remoteAddress: extra.ip ?? '127.0.0.1' }, ip: extra.ip ?? '127.0.0.1', params: extra.params ?? {}, actor: extra.actor, token: extra.token, }); // PermissionService-backed handlers (grants, get-user-app-token) call // `Context.set(...)` internally, which throws unless invoked within a // `runWithContext` scope. Wrap controller calls that hit those paths. const inCtx = (actor: Actor | undefined, fn: () => Promise): Promise => Promise.resolve(runWithContext({ actor: actor ?? undefined }, fn)); // Login/signup happy paths return the full {proceed, token, user} envelope const isCompleteLoginResponse = ( body: unknown, ): body is { proceed: boolean; next_step: string; token: string; user: { username: string; uuid: string }; } => !!body && typeof body === 'object' && 'next_step' in (body as Record) && (body as Record).next_step === 'complete'; // ── Existing event-shape sanity check (unchanged) ─────────────────── describe('puter.signup.validate event', () => { it('supports code in the validate event when allow is false', async () => { await withSignupValidateOverride( (event) => { event.allow = false; event.message = 'Region not supported'; event.code = 'region_blocked'; }, async () => { const validateEvent = { req: {}, data: {}, ip: '127.0.0.1', email: 'test@example.com', allow: true, no_temp_user: false, requires_email_confirmation: false, message: null as string | null, code: null as string | null, }; await eventClient.emitAndWait( 'puter.signup.validate', validateEvent, {}, ); expect(validateEvent.allow).toBe(false); expect(validateEvent.message).toBe('Region not supported'); expect(validateEvent.code).toBe('region_blocked'); const err = new HttpError( 403, validateEvent.message ?? 'Signup blocked', { legacyCode: 'forbidden', ...(validateEvent.code ? { code: validateEvent.code } : {}), }, ); expect(err.statusCode).toBe(403); expect(err.message).toBe('Region not supported'); expect(err.code).toBe('region_blocked'); }, ); }); it('omits code from HttpError when extension does not set it', () => { const validateEvent = { req: {}, data: {}, ip: '127.0.0.1', email: 'nocode@example.com', allow: false, no_temp_user: false, requires_email_confirmation: false, message: 'Blocked', code: null as string | null, }; const err = new HttpError( 403, validateEvent.message ?? 'Signup blocked', { legacyCode: 'forbidden', ...(validateEvent.code ? { code: validateEvent.code } : {}), }, ); expect(err.statusCode).toBe(403); expect(err.message).toBe('Blocked'); expect(err.code).toBeUndefined(); }); }); // ── Signup flow ───────────────────────────────────────────────────── describe('AuthController.handleSignup', () => { const uniq = () => Math.random().toString(36).slice(2, 10); it('creates a user, hashes password, and completes login on a fresh signup', async () => { const username = `s_${uniq()}`; const req = makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }); const res = makeRes(); await controller.handleSignup(req, res); // Response shape mirrors completeLogin: GUI token + user envelope. expect(isCompleteLoginResponse(res.body)).toBe(true); const body = res.body as { user: { username: string; email: string; requires_email_confirmation: number; is_temp: boolean; }; token: string; }; expect(body.user.username).toBe(username); expect(body.user.email).toBe(`${username}@test.local`); expect(body.user.is_temp).toBe(false); expect(typeof body.token).toBe('string'); expect(body.token.length).toBeGreaterThan(20); // Session cookie set with the configured cookie name. expect(res.cookies['puter_auth_token']).toBeDefined(); // Persisted with a bcrypt-hashed password (NOT plaintext). const persisted = await server.stores.user.getByUsername(username); expect(persisted).toBeTruthy(); expect(persisted!.password).not.toBe('correct-horse-battery'); expect( await bcrypt.compare('correct-horse-battery', persisted!.password!), ).toBe(true); }); it('forces phone verification on every signup when always_require_phone_verification is set', async () => { const cfg = (controller as { config: Record }).config; const prev = cfg.always_require_phone_verification; cfg.always_require_phone_verification = true; try { const username = `s_${uniq()}`; const req = makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }); const res = makeRes(); await controller.handleSignup(req, res); // The login envelope flags the gate so the GUI shows the dialog … const body = res.body as { user: { requires_phone_verification?: number | boolean }; }; expect(body.user.requires_phone_verification).toBeTruthy(); // … and it's persisted so the gate survives re-login. const persisted = await server.stores.user.getByUsername(username); expect(persisted!.requires_phone_verification).toBe(true); } finally { cfg.always_require_phone_verification = prev; } }); it('rejects a duplicate username with 400', async () => { const username = `s_${uniq()}`; // Seed first. await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); // Second signup with the same username must throw. await expect( controller.handleSignup( makeReq({ username, email: `other-${username}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a confirmed-email duplicate with 400', async () => { const u1 = `s_${uniq()}`; const email = `${u1}@test.local`; await controller.handleSignup( makeReq({ username: u1, email, password: 'correct-horse-battery', }), makeRes(), ); // Promote to email_confirmed so the duplicate-block branch fires. const seeded = await server.stores.user.getByUsername(u1); await server.stores.user.update(seeded!.id, { email_confirmed: 1 }); await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects reserved usernames (e.g. "admin")', async () => { await expect( controller.handleSignup( makeReq({ username: 'admin', email: `a_${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects an invalid email format', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: 'not-an-email', password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a too-short password', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: '12', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('honeypot: returns 200 with empty body when p102xyzname is set', async () => { const req = makeReq({ username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: 'correct-horse-battery', p102xyzname: 'i-am-a-bot', }); const res = makeRes(); await controller.handleSignup(req, res); expect(res.body).toEqual({}); // No cookie was set — honeypot path bails before completeLogin. expect(res.cookies['puter_auth_token']).toBeUndefined(); }); it('temp user signup auto-fills username/email/password and is_temp=true on response', async () => { const req = makeReq({ is_temp: true }); const res = makeRes(); await controller.handleSignup(req, res); expect(isCompleteLoginResponse(res.body)).toBe(true); const body = res.body as { user: { username: string; email: string | null; is_temp: boolean; }; }; // Auto-generated username; auto-filled email; persisted email is null // (temp users have no email on file). expect(body.user.username).toBeTruthy(); expect(body.user.is_temp).toBe(true); expect(body.user.email).toBeNull(); }); it('extension hook can block signup with 403 + custom legacy code', async () => { await withSignupValidateOverride( (event) => { event.allow = false; event.message = 'Region not supported'; event.code = 'region_blocked'; }, async () => { // The controller forwards `validateEvent.code` as `legacyCode` // on the resulting HttpError (see /signup handler), which is // what the rest of the system treats as the public error code. await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'region_blocked', }); }, ); }); it('extension hook can block temp signups with no_temp_user', async () => { await withSignupValidateOverride( (event) => { event.no_temp_user = true; }, async () => { await expect( controller.handleSignup( makeReq({ is_temp: true }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'must_login_or_signup', }); }, ); }); it('rejects brand-new temp signups when registration is disabled', async () => { const authConfig = server.controllers.auth.config as { disable_user_signup?: boolean; }; const prev = authConfig.disable_user_signup; authConfig.disable_user_signup = true; try { await expect( controller.handleSignup(makeReq({ is_temp: true }), makeRes()), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'signup_disabled', }); } finally { authConfig.disable_user_signup = prev; } }); it('emits puter.signup.success on successful signup', async () => { const baseline = heardSignupSuccess.length; const username = `s_${uniq()}`; await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const fresh = heardSignupSuccess.slice(baseline); expect(fresh.length).toBeGreaterThan(0); // At least one of the new emissions corresponds to this username. expect( fresh.some( (evt) => (evt as { username?: string }).username === username, ), ).toBe(true); }); it('still allows claiming a pseudo-user row when registration is disabled', async () => { const authConfig = server.controllers.auth.config as { disable_user_signup?: boolean; }; const prev = authConfig.disable_user_signup; authConfig.disable_user_signup = true; try { const targetEmail = `disabled_claim_${uniq()}@test.local`; const placeholder = await server.stores.user.create({ username: `placeholder_${uniq()}`, uuid: uuidv4(), password: null, email: targetEmail, clean_email: targetEmail, email_confirmed: 0, } as never); const res = makeRes(); await controller.handleSignup( makeReq({ username: `claim_${uniq()}`, email: targetEmail, password: 'correct-horse-battery', }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); const claimed = await server.stores.user.getById(placeholder.id, { force: true, }); expect(claimed!.username).not.toBe(placeholder.username); } finally { authConfig.disable_user_signup = prev; } }); it('does not reveal existing usernames or emails when registration is disabled', async () => { const username = `taken_${uniq()}`; const email = `${username}@test.local`; await controller.handleSignup( makeReq({ username, email, password: 'correct-horse-battery' }), makeRes(), ); const authConfig = server.controllers.auth.config as { disable_user_signup?: boolean; }; const prev = authConfig.disable_user_signup; authConfig.disable_user_signup = true; try { // Taken username → the generic 403, not the duplicate error. await expect( controller.handleSignup( makeReq({ username, email: `fresh_${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'signup_disabled', }); // Taken (non-claimable) email → same generic 403. await expect( controller.handleSignup( makeReq({ username: `fresh_${uniq()}`, email, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'signup_disabled', }); } finally { authConfig.disable_user_signup = prev; } }); }); // -- Signup device signal (fingerprint) -- describe('AuthController.handleSignup device signals', () => { const uniq = () => Math.random().toString(36).slice(2, 10); const captureValidateEvents = async ( fn: () => Promise, ): Promise>> => { const seen: Array> = []; await withSignupValidateOverride((event) => { seen.push(event as unknown as Record); }, fn); return seen; }; const successEventsFor = (baseline: number, username: string) => heardSignupSuccess .slice(baseline) .filter( (evt) => (evt as { username?: string }).username === username, ); it('forwards fingerprint verbatim to validate and success events', async () => { const username = `fp_${uniq()}`; const baseline = heardSignupSuccess.length; const fingerprint = 'Fp_abc.123-XYZ'; const seen = await captureValidateEvents(async () => { const res = makeRes(); await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', fingerprint, }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); }); expect(seen).toHaveLength(1); expect(seen[0].fingerprint).toBe(fingerprint); const successes = successEventsFor(baseline, username); expect(successes).toHaveLength(1); expect(successes[0].fingerprint).toBe(fingerprint); expect(successes[0].is_temp).toBe(false); }); it('accepts a boundary-length 128-char fingerprint', async () => { const username = `fp_${uniq()}`; const res = makeRes(); await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', fingerprint: 'f'.repeat(128), }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); }); it('defaults the fingerprint to null on the validate event when absent', async () => { const username = `fp_${uniq()}`; const baseline = heardSignupSuccess.length; const seen = await captureValidateEvents(async () => { const res = makeRes(); await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }), res, ); // Signup completes exactly as before when the fields are absent. expect(isCompleteLoginResponse(res.body)).toBe(true); expect(res.cookies['puter_auth_token']).toBeDefined(); }); expect(seen).toHaveLength(1); expect(seen[0].fingerprint).toBeNull(); const successes = successEventsFor(baseline, username); expect(successes).toHaveLength(1); expect(successes[0].fingerprint).toBeNull(); expect(successes[0].is_temp).toBe(false); }); it('treats an empty-string fingerprint as absent', async () => { const username = `fp_${uniq()}`; const seen = await captureValidateEvents(async () => { const res = makeRes(); await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', fingerprint: '', }), res, ); // An empty signal is "not collected", never a 400. expect(isCompleteLoginResponse(res.body)).toBe(true); }); expect(seen).toHaveLength(1); expect(seen[0].fingerprint).toBeNull(); }); it('rejects a non-string fingerprint with 400 and fires no success event', async () => { const username = `fp_${uniq()}`; const baseline = heardSignupSuccess.length; await expect( controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', fingerprint: 12345, }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request', }); expect(heardSignupSuccess.length).toBe(baseline); // No user row was created either. expect(await server.stores.user.getByUsername(username)).toBeFalsy(); }); it('rejects a fingerprint longer than 128 characters with 400 and fires no success event', async () => { const username = `fp_${uniq()}`; const baseline = heardSignupSuccess.length; await expect( controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', fingerprint: 'f'.repeat(129), }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); expect(heardSignupSuccess.length).toBe(baseline); }); it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => { const baseline = heardSignupSuccess.length; const res = makeRes(); await controller.handleSignup( makeReq({ is_temp: true, fingerprint: 'temp-device-fp' }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); const username = (res.body as { user: { username: string } }).user .username; const successes = successEventsFor(baseline, username); expect(successes).toHaveLength(1); expect(successes[0].is_temp).toBe(true); expect(successes[0].fingerprint).toBe('temp-device-fp'); }); it('pseudo-user claim reports is_temp false on the success event', async () => { // Seed an unconfirmed placeholder row (email set, password null) — // signing up with the same email claims it instead of inserting. const placeholder = `ph_${uniq()}`; const email = `${placeholder}@test.local`; await server.stores.user.create({ username: placeholder, uuid: uuidv4(), password: null, email, }); const username = `fp_${uniq()}`; const baseline = heardSignupSuccess.length; const res = makeRes(); await controller.handleSignup( makeReq({ username, email, password: 'correct-horse-battery', fingerprint: 'claim-device-fp', }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); const successes = successEventsFor(baseline, username); expect(successes).toHaveLength(1); expect(successes[0].is_temp).toBe(false); expect(successes[0].fingerprint).toBe('claim-device-fp'); }); }); // ── Login flow ────────────────────────────────────────────────────── describe('AuthController.handleLogin', () => { const password = 'correct-horse-battery'; let username: string; let email: string; beforeAll(async () => { username = `l_${Math.random().toString(36).slice(2, 10)}`; email = `${username}@test.local`; await controller.handleSignup( makeReq({ username, email, password }), makeRes(), ); }); it('returns the GUI token + user envelope on a correct username login', async () => { const res = makeRes(); await controller.handleLogin(makeReq({ username, password }), res); expect(isCompleteLoginResponse(res.body)).toBe(true); // GUI token is verifiable as an `auth` JWT. const token = (res.body as { token: string }).token; const decoded = server.services.token.verify('auth', token) as { type: string; user_uid: string; }; expect(decoded.type).toBe('gui'); // Session cookie carries the (different) session token. expect(res.cookies['puter_auth_token'].value).toBeTruthy(); expect(res.cookies['puter_auth_token'].value).not.toBe(token); }); it('also accepts email instead of username', async () => { const res = makeRes(); await controller.handleLogin(makeReq({ email, password }), res); expect(isCompleteLoginResponse(res.body)).toBe(true); }); it('returns 400 when neither username nor email is supplied', async () => { await expect( controller.handleLogin(makeReq({ password }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 400 when password is missing', async () => { await expect( controller.handleLogin(makeReq({ username }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 for an unknown username', async () => { await expect( controller.handleLogin( makeReq({ username: `does_not_exist_${uuidv4()}`, password }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('returns 401 for the wrong password', async () => { await expect( controller.handleLogin( makeReq({ username, password: 'wrong-password' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); it('returns 401 when the account is suspended', async () => { const u = `lsus_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { suspended: 1 }); await expect( controller.handleLogin( makeReq({ username: u, password: 'correct-horse-battery' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); it('hides the system user when allow_system_login is false', async () => { // Default config has no `allow_system_login`. The system user does // exist (seeded), so the lookup succeeds — but the controller masks // it as 404 to avoid leaking presence. await expect( controller.handleLogin( makeReq({ username: 'system', password: 'whatever' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('OTP-enabled accounts get a 202 + otp_jwt_token instead of completing login', async () => { const u = `lotp_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { otp_enabled: 1, otp_secret: 'TESTSECRETBASE32', }); const res = makeRes(); await controller.handleLogin( makeReq({ username: u, password: 'correct-horse-battery' }), res, ); expect(res.statusCode).toBe(202); const body = res.body as { proceed: boolean; next_step: string; otp_jwt_token: string; }; expect(body.next_step).toBe('otp'); expect(typeof body.otp_jwt_token).toBe('string'); const decoded = server.services.token.verify( 'otp', body.otp_jwt_token, ) as { user_uid: string; purpose: string }; expect(decoded.purpose).toBe('otp-login'); expect(decoded.user_uid).toBe(seeded!.uuid); // No session cookie set yet — login isn't complete. expect(res.cookies['puter_auth_token']).toBeUndefined(); }); }); // ── Login: OTP / recovery-code branches ───────────────────────────── describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => { it('handleLoginOtp rejects an invalid token with 400', async () => { await expect( controller.handleLoginOtp( makeReq({ token: 'not-a-jwt', code: '123456' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('handleLoginOtp rejects a valid JWT with the wrong purpose', async () => { const wrongPurposeJwt = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'something-else' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginOtp( makeReq({ token: wrongPurposeJwt, code: '123456' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('handleLoginOtp returns proceed:false when the code does not verify', async () => { const u = `otp_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { otp_enabled: 1, otp_secret: 'TESTSECRETBASE32', }); const otpJwt = server.services.token.sign( 'otp', { user_uid: seeded!.uuid, purpose: 'otp-login' }, { expiresIn: '5m' }, ); const res = makeRes(); await controller.handleLoginOtp( makeReq({ token: otpJwt, code: '000000' }), res, ); expect(res.body).toEqual({ proceed: false }); }); it('handleLoginRecoveryCode consumes a valid code and completes login', async () => { const u = `rec_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); // Hashed-recovery-code list — pre-hash a known plaintext. const { hashRecoveryCode } = await import('../../services/auth/OTPUtil.js'); const PLAIN = 'recover-me-please'; const hashed = hashRecoveryCode(PLAIN); await server.stores.user.update(seeded!.id, { otp_recovery_codes: hashed, }); const otpJwt = server.services.token.sign( 'otp', { user_uid: seeded!.uuid, purpose: 'otp-login' }, { expiresIn: '5m' }, ); const res = makeRes(); await controller.handleLoginRecoveryCode( makeReq({ token: otpJwt, code: PLAIN }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); // Recovery code consumed (single-use): rerunning with the same code // should now return proceed:false. const res2 = makeRes(); await controller.handleLoginRecoveryCode( makeReq({ token: otpJwt, code: PLAIN }), res2, ); expect(res2.body).toEqual({ proceed: false }); }); }); // ── Step-up (elevation) ───────────────────────────────────────────── describe('AuthController.handleElevate', () => { it('password account: correct password mints the elevation cookie', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleElevate( makeReq({ password: 'correct-horse-battery' }, { actor }), res, ); expect(res.body).toMatchObject({ elevated: true }); expect(res.cookies.puter_elevated).toBeDefined(); expect(res.cookies.puter_elevated.opts).toMatchObject({ httpOnly: true }); // The minted cookie satisfies verifyStepUpSession for this same user. const { verifyStepUpSession } = await import( '../../core/http/middleware/stepUpSession.js' ); const ok = verifyStepUpSession( { cookies: { puter_elevated: res.cookies.puter_elevated.value }, actor: { user: { uuid: actor.user.uuid } }, } as never, { tokenService: server.services.token }, ); expect(ok).toBe(true); }); it('password account: wrong password → 401 password_mismatch', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleElevate( makeReq({ password: 'nope' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401, legacyCode: 'password_mismatch', }); }); it('2FA account: a live TOTP code elevates; a wrong code is rejected', async () => { const { TOTP } = await import('otpauth'); const { createSecret } = await import( '../../services/auth/OTPUtil.js' ); const { user, actor } = await makeUserAndActor(); const { secret } = createSecret(user.username); await server.stores.user.update(user.id, { otp_enabled: 1, otp_secret: secret, }); // Reflect the enabled state on the actor the way the auth probe would. const otpActor = { user: { ...actor.user, otp_enabled: true }, } as never; const totp = new TOTP({ issuer: 'puter.com', label: user.username, algorithm: 'SHA1', digits: 6, secret, }); const res = makeRes(); await controller.handleElevate( makeReq({ code: totp.generate() }, { actor: otpActor }), res, ); expect(res.body).toMatchObject({ elevated: true }); expect(res.cookies.puter_elevated).toBeDefined(); await expect( controller.handleElevate( makeReq({ code: '000000' }, { actor: otpActor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); it('account with no password and 2FA off cannot elevate → 403', async () => { const { user, actor } = await makeUserAndActor(); await server.stores.user.update(user.id, { password: null }); await expect( controller.handleElevate( makeReq({ password: 'anything' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'elevation_unavailable', }); }); it('API clients (no cookie) get the token back to send as a header', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleElevate( makeReq({ password: 'correct-horse-battery' }, { actor }), res, ); expect(typeof (res.body as { token?: string }).token).toBe('string'); }); it('browser sessions (cookie-authed) do NOT get the raw token in the body', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); // Mimic the browser: the resolved token IS the session cookie value. // Cookie name must match `config.cookie_name` (puter_auth_token). const req = { ...makeReq({ password: 'correct-horse-battery' }, { actor }), token: 'session-cookie-value', cookies: { puter_auth_token: 'session-cookie-value' }, }; await controller.handleElevate(req, res); expect(res.body).toEqual({ elevated: true }); expect(res.cookies.puter_elevated).toBeDefined(); }); it('the elevation token is never honored as a main auth token', async () => { const { user } = await makeUserAndActor(); const { signStepUpToken } = await import( '../../core/http/middleware/stepUpSession.js' ); const token = signStepUpToken(server.services.token, { uuid: user.uuid, }); const result = await server.services.auth.authenticate(token); expect(result.actor).toBeUndefined(); }); }); // ── Logout ────────────────────────────────────────────────────────── describe('AuthController.handleLogout', () => { it('clears the session cookie and responds with "logged out"', async () => { const u = `lo_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); const res = makeRes(); await controller.handleLogout( makeReq( {}, { actor: { user: { id: seeded!.id, uuid: seeded!.uuid, username: seeded!.username, email: seeded!.email ?? null, }, } as Actor, }, ), res, ); expect(res.clearedCookies).toContain('puter_auth_token'); expect(res.sent).toBe('logged out'); }); }); describe('AuthController account-lifecycle route gating', () => { const routeOptions = (method: string, path: string) => { const proto = Object.getPrototypeOf(controller) as { __puterRoutes?: Array<{ method: string; path: string; options?: Record; }>; }; const route = (proto.__puterRoutes ?? []).find( (r) => r.method.toLowerCase() === method.toLowerCase() && r.path === path, ); expect(route, `route ${method} ${path} not found`).toBeDefined(); return route!.options ?? {}; }; it('POST /logout requires a human user actor', () => { const opts = routeOptions('post', '/logout'); expect(opts.requireUserActor).toBe(true); expect(opts.antiCsrf).toBe(true); }); it('GET /get-anticsrf-token requires a human user actor', () => { const opts = routeOptions('get', '/get-anticsrf-token'); expect(opts.requireUserActor).toBe(true); }); it('requireUserActorGate rejects app-under-user and access-token actors', () => { const gate = requireUserActorGate(); const run = (actor: Partial) => new Promise((resolve) => { gate( { actor } as never, {} as never, (err?: unknown) => resolve(err), ); }); return (async () => { const appActor = await run({ user: { uuid: 'u1' }, app: { uid: 'app-1' }, } as Partial); expect(appActor).toMatchObject({ statusCode: 403 }); const tokenActor = await run({ user: { uuid: 'u1' }, accessToken: { uid: 'tok-1' }, } as Partial); expect(tokenActor).toMatchObject({ statusCode: 403 }); const human = await run({ user: { uuid: 'u1' } } as Partial); expect(human).toBeUndefined(); })(); }); }); // ── Token grants: user → user / app / group ───────────────────────── describe('AuthController grant flows', () => { let issuer: { id: number; uuid: string; username: string; email: string }; let target: { id: number; uuid: string; username: string; email: string }; let issuerActor: Actor; beforeAll(async () => { const issuerName = `gi_${Math.random().toString(36).slice(2, 10)}`; const targetName = `gt_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: issuerName, email: `${issuerName}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); await controller.handleSignup( makeReq({ username: targetName, email: `${targetName}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const i = await server.stores.user.getByUsername(issuerName); const t = await server.stores.user.getByUsername(targetName); // Auto-confirm so they can act in permission flows that gate on it. await server.stores.user.update(i!.id, { email_confirmed: 1 }); await server.stores.user.update(t!.id, { email_confirmed: 1 }); issuer = { id: i!.id, uuid: i!.uuid, username: i!.username, email: i!.email!, }; target = { id: t!.id, uuid: t!.uuid, username: t!.username, email: t!.email!, }; issuerActor = { user: { id: issuer.id, uuid: issuer.uuid, username: issuer.username, email: issuer.email, email_confirmed: true, }, } as Actor; }); it('grant-user-user: rejects missing target_username/permission with 400', async () => { await expect( controller.handleGrantUserUser( makeReq({ permission: 'fs:read' }, { actor: issuerActor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('grant-user-user: persists the permission and PermissionService.check sees it', async () => { const permission = `service:test-grant-${uuidv4()}:ii:read`; // The controller calls PermissionService.grantUserUserPermission, // which gates on `manage:` for non-system actors. Pre- // bootstrap the manage flag directly through the permission store // (the system actor would skip this gate, but its in-memory shape // has no user.id, so it can't issue grants). Then the controller // call exercises persist + check end-to-end. await server.stores.permission.setFlatUserPerm( issuer.id, `manage:${permission}`, { permission: `manage:${permission}`, deleted: false, issuer_user_id: issuer.id, } as never, ); const res = makeRes(); await inCtx(issuerActor, () => controller.handleGrantUserUser( makeReq( { target_username: target.username, permission, extra: { reason: 'unit-test' }, }, { actor: issuerActor }, ), res, ), ); expect(res.body).toEqual({}); // The target now sees the permission via the user-to-user grant. const targetActor = { user: { ...target, email_confirmed: true }, } as Actor; const granted = await server.services.permission .check(targetActor, permission) .catch(() => false); expect(granted).toBeTruthy(); }); it('grant-user-app: persists a user→app permission grant', async () => { // Create an app row owned by the issuer so the grant has somewhere // to land. const app = await server.stores.app.create( { name: `tg-${uuidv4()}`, title: 'TestGrantApp', index_url: 'https://example.test/index.html', }, { ownerUserId: issuer.id }, ); const permission = `service:tg-app:ii:read`; // The controller's grant call delegates through PermissionService // (which uses ALS Context.set), so wrap in runWithContext. const res = makeRes(); await inCtx(issuerActor, () => controller.handleGrantUserApp( makeReq( { app_uid: app.uid, permission, extra: {} }, { actor: issuerActor }, ), res, ), ); expect(res.body).toEqual({}); // The permission row exists in the user_to_app_permissions table. // Schema uses `app_id` (numeric FK), not `app_uid`. const rows = await server.clients.db.read( 'SELECT p.`permission` FROM `user_to_app_permissions` p ' + 'JOIN `apps` a ON a.`id` = p.`app_id` ' + 'WHERE p.`user_id` = ? AND a.`uid` = ?', [issuer.id, app.uid], ); expect( (rows as Array<{ permission: string }>).map((r) => r.permission), ).toContain(permission); }); it('grant-user-group: 404 when the group does not exist', async () => { await expect( controller.handleGrantUserGroup( makeReq( { group_uid: `does-not-exist-${uuidv4()}`, permission: 'service:foo:ii:read', }, { actor: issuerActor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); }); // ── Token grants: get-user-app-token / check-app ─────────────────── describe('AuthController.handleGetUserAppToken + handleCheckApp', () => { let user: { id: number; uuid: string; username: string; email: string }; let actor: Actor; let app: { uid: string }; beforeAll(async () => { const u = `at_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { email_confirmed: 1 }); user = { id: seeded!.id, uuid: seeded!.uuid, username: seeded!.username, email: seeded!.email!, }; actor = { user: { ...user, email_confirmed: true }, } as Actor; app = await ( server.stores.app.create as unknown as ( fields: Record, opts: { ownerUserId: number; appOwner?: unknown }, ) => Promise<{ uid: string; id: number }> )( { name: `at-${uuidv4()}`, title: 'AppToken target', index_url: 'https://example.test/at.html', }, { ownerUserId: user.id }, ); }); it('rejects missing app_uid AND origin with 400', async () => { await expect( controller.handleGetUserAppToken(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns a verifiable JWT token + app_uid for an existing app', async () => { const res = makeRes(); await inCtx(actor, () => controller.handleGetUserAppToken( makeReq({ app_uid: app.uid }, { actor }), res, ), ); const body = res.body as { token: string; app_uid: string }; expect(body.app_uid).toBe(app.uid); const decoded = server.services.token.verify('auth', body.token) as { type: string; user_uid: string; app_uid: string; }; expect(decoded.user_uid).toBe(user.uuid); expect(decoded.app_uid).toBe(app.uid); }); it('after get-user-app-token, check-app reports authenticated:true and returns a token', async () => { // Ensure the flag is granted (re-run is idempotent). await inCtx(actor, () => controller.handleGetUserAppToken( makeReq({ app_uid: app.uid }, { actor }), makeRes(), ), ); const res = makeRes(); await inCtx(actor, () => controller.handleCheckApp( makeReq({ app_uid: app.uid }, { actor }), res, ), ); const body = res.body as { app_uid: string; authenticated: boolean; token?: string; }; expect(body.app_uid).toBe(app.uid); expect(body.authenticated).toBe(true); expect(typeof body.token).toBe('string'); }); it('check-app returns the {app_uid, authenticated} envelope shape', async () => { // Create a brand-new actor with no app-related history so the // permission scan can't cache-hit anything from prior tests, AND // create an app owned by a *different* user so the fresh actor // doesn't pick up owner-level implicit perms on `service::*`. const freshUser = `cf_${uuidv4().slice(0, 6)}`; await controller.handleSignup( makeReq({ username: freshUser, email: `${freshUser}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const fresh = await server.stores.user.getByUsername(freshUser); await server.stores.user.update(fresh!.id, { email_confirmed: 1 }); const freshActor = { user: { id: fresh!.id, uuid: fresh!.uuid, username: fresh!.username, email: fresh!.email!, email_confirmed: true, }, } as Actor; const ownerUser = `co_${uuidv4().slice(0, 6)}`; await controller.handleSignup( makeReq({ username: ownerUser, email: `${ownerUser}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const owner = await server.stores.user.getByUsername(ownerUser); const otherApp = await ( server.stores.app.create as unknown as ( fields: Record, opts: { ownerUserId: number; appOwner?: unknown }, ) => Promise<{ uid: string; id: number }> )( { name: `at-${uuidv4()}`, title: 'Untouched', index_url: 'https://example.test/untouched.html', }, { ownerUserId: owner!.id }, ); const res = makeRes(); await inCtx(freshActor, () => controller.handleCheckApp( makeReq({ app_uid: otherApp.uid }, { actor: freshActor }), res, ), ); const body = res.body as { app_uid: string; authenticated: boolean; token?: string; }; expect(body.app_uid).toBe(otherApp.uid); expect(typeof body.authenticated).toBe('boolean'); // Whether `authenticated` is true depends on the user's full // permission set (default group, owned-app implicits, etc.) — this // test only pins the response *shape*, since the substantive case // (`authenticated: true` after a paired get-user-app-token) is // covered by the test above. if (!body.authenticated) { expect(body.token).toBeUndefined(); } }); it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => { const origin = `https://test-origin-${uuidv4()}.example`; const res = makeRes(); await inCtx(actor, () => controller.handleGetUserAppToken( makeReq({ origin }, { actor }), res, ), ); const body = res.body as { token: string; app_uid: string }; expect(body.app_uid).toMatch(/^app-/); // A bootstrap app row was created for that origin. const bootstrapped = await server.stores.app.getByUid(body.app_uid); expect(bootstrapped).toBeTruthy(); }); }); // ── Access tokens: create + revoke ───────────────────────────────── describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', () => { let actor: Actor; beforeAll(async () => { const u = `acc_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq({ username: u, email: `${u}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { email_confirmed: 1 }); actor = { user: { id: seeded!.id, uuid: seeded!.uuid, username: seeded!.username, email: seeded!.email!, email_confirmed: true, }, } as Actor; }); it('rejects an empty permissions array with 400', async () => { await expect( controller.handleCreateAccessToken( makeReq({ permissions: [] }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a non-array permissions field with 400', async () => { await expect( controller.handleCreateAccessToken( makeReq( { permissions: 'not-an-array' as unknown as never[] }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a permission spec that is neither a string nor a tuple with 400', async () => { await expect( controller.handleCreateAccessToken( makeReq( { permissions: [{ not: 'a-spec' } as unknown as string] }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('mints a verifiable access-token JWT for valid permissions', async () => { const res = makeRes(); await controller.handleCreateAccessToken( makeReq( { permissions: ['service:foo:ii:read'], expiresIn: '1h', }, { actor }, ), res, ); const body = res.body as { token: string }; expect(typeof body.token).toBe('string'); // Token should be verifiable + carry the issuer's user_uid. const decoded = server.services.token.verify('auth', body.token) as { user_uid: string; }; expect(decoded.user_uid).toBe(actor.user.uuid); }); // -- Full-API-access + labels -- it('mints a full-access token and stores a trimmed label on the session row', async () => { const res = makeRes(); await controller.handleCreateAccessToken( makeReq( { permissions: [FULL_API_ACCESS], label: ' My CLI ' }, { actor }, ), res, ); const decoded = server.services.token.verify( 'auth', (res.body as { token: string }).token, ) as { type: string; token_uid: string; session_uid: string; full_access?: boolean; }; expect(decoded.type).toBe('access-token'); // Full access is a signed claim, not a stored permission row. expect(decoded.full_access).toBe(true); const permRows = (await server.clients.db.read( 'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?', [decoded.token_uid], )) as Array<{ permission: string }>; expect(permRows).toHaveLength(0); const sessRows = (await server.clients.db.read( 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', [decoded.session_uid], )) as Array<{ label: string }>; expect(sessRows[0]?.label).toBe('My CLI'); }); it('caps an over-long label at 64 characters', async () => { const res = makeRes(); await controller.handleCreateAccessToken( makeReq( { permissions: [FULL_API_ACCESS], label: 'x'.repeat(120) }, { actor }, ), res, ); const decoded = server.services.token.verify( 'auth', (res.body as { token: string }).token, ) as { session_uid: string }; const sessRows = (await server.clients.db.read( 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', [decoded.session_uid], )) as Array<{ label: string }>; expect(sessRows[0]?.label.length).toBe(64); }); it('rejects a non-string label with 400', async () => { await expect( controller.handleCreateAccessToken( makeReq( { permissions: [FULL_API_ACCESS], label: 123 as unknown as string, }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-access-token: requires tokenOrUuid and returns ok:true on success', async () => { // Mint, then revoke. const created = makeRes(); await controller.handleCreateAccessToken( makeReq( { permissions: ['service:foo:ii:read'], expiresIn: '1h' }, { actor }, ), created, ); const tokenJwt = (created.body as { token: string }).token; // Missing tokenOrUuid → 400. await expect( controller.handleRevokeAccessToken( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); // Successful revoke. const revoked = makeRes(); await controller.handleRevokeAccessToken( makeReq({ tokenOrUuid: tokenJwt }, { actor }), revoked, ); expect(revoked.body).toEqual({ ok: true }); }); it('revoke-access-token: extracts JWT from /token-read URLs', async () => { const created = makeRes(); await controller.handleCreateAccessToken( makeReq( { permissions: ['service:foo:ii:read'], expiresIn: '1h' }, { actor }, ), created, ); const tokenJwt = (created.body as { token: string }).token; const url = `https://example.com/token-read/${tokenJwt}?other=1`; const res = makeRes(); await controller.handleRevokeAccessToken( makeReq({ tokenOrUuid: url }, { actor }), res, ); expect(res.body).toEqual({ ok: true }); }); }); // ── Helpers shared by the rest of the test groups ─────────────────── const uniq = () => Math.random().toString(36).slice(2, 10); const makeUserAndActor = async (overrides: Record = {}) => { const username = `u_${uniq()}`; await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }), makeRes(), ); const u = await server.stores.user.getByUsername(username); if (overrides && Object.keys(overrides).length > 0) { await server.stores.user.update(u!.id, overrides); } const refreshed = await server.stores.user.getById(u!.id, { force: true }); const actor = { user: { id: refreshed!.id, uuid: refreshed!.uuid, username: refreshed!.username, email: refreshed!.email ?? null, email_confirmed: !!refreshed!.email_confirmed, }, } as Actor; return { user: refreshed!, actor }; }; // ── Email confirmation flows ──────────────────────────────────────── describe('AuthController.handleSendConfirmEmail', () => { it('throws 400 when the user has no email on file', async () => { const { actor } = await makeUserAndActor(); // Wipe the email to exercise the "no email on file" branch. await server.stores.user.update(actor.user.id!, { email: null }); await expect( controller.handleSendConfirmEmail( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws 403 when the account is suspended', async () => { const { actor } = await makeUserAndActor({ suspended: 1 }); await expect( controller.handleSendConfirmEmail( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); }); it('rotates the email_confirm_code and returns {} on success', async () => { const { user, actor } = await makeUserAndActor(); const before = await server.stores.user.getById(user.id, { force: true, }); const res = makeRes(); await controller.handleSendConfirmEmail(makeReq({}, { actor }), res); expect(res.body).toEqual({}); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.email_confirm_code).not.toBe(before!.email_confirm_code); expect(String(after!.email_confirm_code).length).toBe(6); }); }); // The per-account / per-number SMS send caps used to live here as a hardcoded // `MAX_PHONE_VERIFY_SENDS` in the backend. They now live entirely in the abuse // extension (no abuse thresholds in the OSS repo): the backend only asks via // `puter.phone-verification.check` and reports sends via // `puter.phone-verification.sent`. The cap behavior is covered by the // extension's phoneVerification / phoneSendLog tests; the backend side (it // forwards a veto, and emits `sent` only on success) is covered below. describe('AuthController.handleSendConfirmPhone validation', () => { // Stub the Prelude client (a real external boundary) so we exercise the // controller's validation branches, not the network. Override per case. const stubPrelude = (over: Record = {}) => ({ isConfigured: () => true, isCountrySupported: () => true, defaultCountry: 'US', createVerification: vi.fn(async () => ({ status: 'success' })), ...over, }); const withPrelude = async ( prelude: unknown, fn: () => Promise, ): Promise => { const ctrl = controller as { clients: { prelude: unknown } }; const real = ctrl.clients.prelude; ctrl.clients.prelude = prelude; try { await fn(); } finally { ctrl.clients.prelude = real; } }; it('throws 400 for an unparseable phone number', async () => { const { actor } = await makeUserAndActor(); await withPrelude(stubPrelude(), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: 'not a phone' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); }); it('throws 400 (and sends nothing) when the country is over the cost cap', async () => { const { actor } = await makeUserAndActor(); const createVerification = vi.fn(async () => ({ status: 'success' })); await withPrelude( stubPrelude({ isCountrySupported: () => false, createVerification }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); expect(createVerification).not.toHaveBeenCalled(); }, ); }); it('throws 503 when Prelude is not configured', async () => { const { actor } = await makeUserAndActor(); await withPrelude( stubPrelude({ isConfigured: () => false }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 503 }); }, ); }); it('surfaces a Prelude block as 429', async () => { const { actor } = await makeUserAndActor(); await withPrelude( stubPrelude({ createVerification: vi.fn(async () => ({ status: 'blocked' })), }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 429 }); }, ); }); it('attaches a support error_id to a Prelude block', async () => { const { actor } = await makeUserAndActor(); await withPrelude( stubPrelude({ createVerification: vi.fn(async () => ({ status: 'blocked' })), }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 429, fields: { error_id: expect.any(String) }, }); }, ); }); it('attaches a support error_id when the Prelude request throws', async () => { const { actor } = await makeUserAndActor(); await withPrelude( stubPrelude({ createVerification: vi.fn(async () => { throw new Error('network down'); }), }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 502, fields: { error_id: expect.any(String) }, }); }, ); }); it('persists the send failure to KV under the error_id with a 7-day expiry', async () => { const { user, actor } = await makeUserAndActor(); const kvSet = vi.spyOn(server.stores.kv, 'set'); try { await withPrelude( stubPrelude({ createVerification: vi.fn(async () => { throw new Error('network down'); }), }), async () => { let thrown: HttpError | undefined; try { await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ); } catch (e) { thrown = e as HttpError; } const errorId = thrown!.fields!.error_id as string; const { res: record } = await server.stores.kv.get({ key: `sms-send-error:${errorId}`, }); expect(record).toMatchObject({ reason: 'prelude_request_failed', status: 502, user_id: user.id, user_uid: user.uuid, detail: 'network down', t: expect.any(Number), }); const errorSet = kvSet.mock.calls.find(([arg]) => (arg as { key?: string }).key?.startsWith( 'sms-send-error:', ), ); const { expireAt } = errorSet![0] as { expireAt: number; }; const nowSec = Math.floor(Date.now() / 1000); expect(expireAt).toBeGreaterThan( nowSec + 7 * 24 * 60 * 60 - 60, ); expect(expireAt).toBeLessThanOrEqual( nowSec + 7 * 24 * 60 * 60, ); }, ); } finally { kvSet.mockRestore(); } }); it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => { const { actor } = await makeUserAndActor(); const createVerification = vi.fn(async () => ({ status: 'success' })); await withPrelude(stubPrelude({ createVerification }), async () => { const req = makeReq( { phone: '+14155550123' }, { actor, ip: '203.0.113.7', headers: { 'user-agent': 'Mozilla/5.0' }, }, ); // Stamped by the global fingerprint middleware in production. (req as { deviceFingerprint?: string }).deviceFingerprint = 'thumb_abc123'; await controller.handleSendConfirmPhone(req, makeRes()); }); expect(createVerification).toHaveBeenCalledWith('+14155550123', { ip: '203.0.113.7', device_id: 'thumb_abc123', user_agent: 'Mozilla/5.0', }); }); it('omits absent device fingerprint and user-agent from the Prelude signals', async () => { const { actor } = await makeUserAndActor(); const createVerification = vi.fn(async () => ({ status: 'success' })); await withPrelude(stubPrelude({ createVerification }), async () => { await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor, ip: '203.0.113.7' }), makeRes(), ); }); expect(createVerification).toHaveBeenCalledWith('+14155550123', { ip: '203.0.113.7', device_id: undefined, user_agent: undefined, }); }); it('returns the delivery channel Prelude picked so the client can point at the right app', async () => { const { actor } = await makeUserAndActor(); await withPrelude( stubPrelude({ createVerification: vi.fn(async () => ({ status: 'success', channels: ['whatsapp', 'sms'], })), }), async () => { const res = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), res, ); expect(res.body).toMatchObject({ channel: 'whatsapp' }); }, ); }); it('omits `channel` when Prelude reports no delivery sequence', async () => { const { actor } = await makeUserAndActor(); await withPrelude(stubPrelude(), async () => { const res = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), res, ); expect(res.body).not.toHaveProperty('channel'); }); }); }); describe('AuthController phone verification — staging & reuse', () => { const stubPrelude = (over: Record = {}) => ({ isConfigured: () => true, isCountrySupported: () => true, defaultCountry: 'US', createVerification: vi.fn(async () => ({ status: 'success' })), checkVerification: vi.fn(async () => ({ status: 'success' })), ...over, }); const withClients = async ( over: { prelude?: unknown; event?: unknown }, fn: () => Promise, ): Promise => { const ctrl = controller as { clients: { prelude: unknown; event: unknown }; }; const realPrelude = ctrl.clients.prelude; const realEvent = ctrl.clients.event; if ('prelude' in over) ctrl.clients.prelude = over.prelude; if ('event' in over) ctrl.clients.event = over.event; try { await fn(); } finally { ctrl.clients.prelude = realPrelude; ctrl.clients.event = realEvent; } }; it('stages the number in KV and does NOT write it to the user row before verification', async () => { const { user, actor } = await makeUserAndActor(); await withClients({ prelude: stubPrelude() }, async () => { await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ); }); const after = await server.stores.user.getById(user.id, { force: true, }); // The unverified number is not persisted to the indexed column … expect(after!.phone ?? null).toBeNull(); // … it's staged in KV instead, for /confirm-phone to read back. const { res: staged } = await server.stores.kv.get({ key: `phone-verify-pending:${user.id}`, }); expect(staged).toBe('+14155550123'); }); it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => { const { user, actor } = await makeUserAndActor(); const emitAndWait = vi.fn( async ( name: string, ev: { allowed: boolean; reason: string | null }, ) => { if (name === 'puter.phone-verification.check') { ev.allowed = false; ev.reason = 'phone_already_used'; } }, ); const createVerification = vi.fn(async () => ({ status: 'success' })); await withClients( { prelude: stubPrelude({ createVerification }), event: { emitAndWait, emit: vi.fn() }, }, async () => { // 429 (not 403), and the extension's reason is forwarded // verbatim for the client to message on — the backend never // interprets it. await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 429, fields: { reason: 'phone_already_used' }, }); }, ); // Vetoed before the send — no SMS dispatched, nothing staged. expect(createVerification).not.toHaveBeenCalled(); const { res: staged } = await server.stores.kv.get({ key: `phone-verify-pending:${user.id}`, }); expect(staged ?? null).toBeNull(); }); it('emits puter.phone-verification.sent after a successful send', async () => { const { actor } = await makeUserAndActor(); const emit = vi.fn(); const emitAndWait = vi.fn(async () => {}); // no veto await withClients( { prelude: stubPrelude(), event: { emit, emitAndWait } }, async () => { await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ); }, ); expect(emit).toHaveBeenCalledWith( 'puter.phone-verification.sent', expect.objectContaining({ phone: '+14155550123' }), expect.anything(), ); }); it('does NOT emit the sent signal when the send fails upstream', async () => { const { actor } = await makeUserAndActor(); const emit = vi.fn(); const emitAndWait = vi.fn(async () => {}); await withClients( { prelude: stubPrelude({ createVerification: vi.fn(async () => { throw new Error('prelude down'); }), }), event: { emit, emitAndWait }, }, async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 502 }); }, ); const sentCalls = emit.mock.calls.filter( (c) => c[0] === 'puter.phone-verification.sent', ); expect(sentCalls).toHaveLength(0); }); it('confirms against the staged number and persists it to the row only on success', async () => { const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, }); // No phone on the row; the number lives only in the KV staging slot. await server.stores.kv.set({ key: `phone-verify-pending:${user.id}`, value: '+14155550123', }); const checkVerification = vi.fn(async () => ({ status: 'success' })); await withClients( { prelude: stubPrelude({ checkVerification }) }, async () => { const res = makeRes(); await controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), res, ); expect(res.body).toMatchObject({ phone_verified: true }); }, ); expect(checkVerification).toHaveBeenCalledWith('+14155550123', '123456'); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_phone_verification).toBe(false); // Persisted to the row only now, on success. expect(after!.phone).toBe('+14155550123'); }); }); describe('AuthController.handleConfirmPhone', () => { const stubPrelude = (over: Record = {}) => ({ isConfigured: () => true, checkVerification: vi.fn(async () => ({ status: 'success' })), ...over, }); const withPrelude = async ( prelude: unknown, fn: () => Promise, ): Promise => { const ctrl = controller as { clients: { prelude: unknown } }; const real = ctrl.clients.prelude; ctrl.clients.prelude = prelude; try { await fn(); } finally { ctrl.clients.prelude = real; } }; it('throws 400 when code is missing', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await expect( controller.handleConfirmPhone(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('short-circuits to verified when the gate is not set (no Prelude call)', async () => { const { actor } = await makeUserAndActor(); const checkVerification = vi.fn(); await withPrelude(stubPrelude({ checkVerification }), async () => { const res = makeRes(); await controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), res, ); expect(res.body).toMatchObject({ phone_verified: true }); expect(checkVerification).not.toHaveBeenCalled(); }); }); it('throws 400 when the gate is set but no phone is on file', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, }); await expect( controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws 503 when the gate is set but Prelude is not configured', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await withPrelude( stubPrelude({ isConfigured: () => false }), async () => { await expect( controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 503 }); }, ); }); it('returns phone_verified:false on a wrong code without clearing the gate', async () => { const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await withPrelude( stubPrelude({ checkVerification: vi.fn(async () => ({ status: 'failure' })), }), async () => { const res = makeRes(); await controller.handleConfirmPhone( makeReq({ code: '000000' }, { actor }), res, ); expect(res.body).toMatchObject({ phone_verified: false }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_phone_verification).toBe(true); }, ); }); it('clears the gate on a correct code and echoes the socket id', async () => { const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await withPrelude( stubPrelude({ checkVerification: vi.fn(async () => ({ status: 'success' })), }), async () => { const res = makeRes(); await controller.handleConfirmPhone( makeReq( { code: '123456', original_client_socket_id: 'sock_1' }, { actor }, ), res, ); expect(res.body).toMatchObject({ phone_verified: true, original_client_socket_id: 'sock_1', }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_phone_verification).toBe(false); }, ); }); it('awaits the user.phone-verified listeners (emitAndWait, not fire-and-forget)', async () => { // The carrier-based card-verification waiver (abuse extension) listens // on user.phone-verified and must clear the card gate BEFORE confirm // responds — so the event has to be awaited, not fire-and-forget. const { actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); const emit = vi.fn(); const emitAndWait = vi.fn(async () => {}); const ctrl = controller as { clients: { event: unknown } }; const realEvent = ctrl.clients.event; ctrl.clients.event = { emit, emitAndWait }; try { await withPrelude( stubPrelude({ checkVerification: vi.fn(async () => ({ status: 'success', })), }), async () => { await controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), makeRes(), ); }, ); } finally { ctrl.clients.event = realEvent; } expect(emitAndWait).toHaveBeenCalledWith( 'user.phone-verified', expect.objectContaining({ phone: '+14155550123' }), expect.anything(), ); const fireAndForget = emit.mock.calls.filter( (c) => c[0] === 'user.phone-verified', ); expect(fireAndForget).toHaveLength(0); }); it('throws 502 when the upstream check fails', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await withPrelude( stubPrelude({ checkVerification: vi.fn(async () => { throw new Error('prelude down'); }), }), async () => { await expect( controller.handleConfirmPhone( makeReq({ code: '123456' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 502 }); }, ); }); }); describe('AuthController.handleCardVerificationSetup', () => { it('short-circuits to verified when the gate is not set', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleCardVerificationSetup( makeReq({}, { actor }), res, ); expect(res.body).toMatchObject({ card_verified: true }); }); it('throws 403 when the account is suspended', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, suspended: 1, }); await expect( controller.handleCardVerificationSetup( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); }); it('throws 409 when phone verification must be completed first', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, phone: '+14155550123', }); await expect( controller.handleCardVerificationSetup( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); }); it('throws 503 when no payments extension is listening', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, }); await expect( controller.handleCardVerificationSetup( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 503 }); }); it('clears the gate when the extension reports the feature disabled', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, }); const res = makeRes(); await withCardSetupOverride( (data) => { data.enabled = false; }, () => controller.handleCardVerificationSetup( makeReq({}, { actor }), res, ), ); expect(res.body).toMatchObject({ card_verified: true, disabled: true }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_card_verification).toBe(false); }); it('returns the provider credentials on success', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, }); const res = makeRes(); await withCardSetupOverride( (data) => { data.enabled = true; data.client_secret = 'seti_secret'; data.publishable_key = 'pk_test'; }, () => controller.handleCardVerificationSetup( makeReq({}, { actor }), res, ), ); expect(res.body).toEqual({ client_secret: 'seti_secret', publishable_key: 'pk_test', }); }); }); describe('AuthController.handleCardVerificationConfirm', () => { it('throws 400 for a missing or invalid setup_intent_id', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, }); for (const bad of [undefined, '', 123, 'x'.repeat(256)]) { await expect( controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: bad }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); } }); it('short-circuits to verified when the gate is not set', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), res, ); expect(res.body).toMatchObject({ card_verified: true }); }); it('throws 409 when phone verification must be completed first', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, phone: '+14155550123', }); await expect( controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); }); it('throws 503 when no payments extension is listening', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, }); await expect( controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 503 }); }); it('clears the gate when the extension reports the feature disabled', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, }); const res = makeRes(); await withCardConfirmOverride( (data) => { data.enabled = false; }, () => controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), res, ), ); expect(res.body).toMatchObject({ card_verified: true, disabled: true }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_card_verification).toBe(false); }); it('returns card_verified:false with a reason when not verified', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, }); const res = makeRes(); await withCardConfirmOverride( (data) => { data.enabled = true; data.verified = false; data.reason = 'prepaid_not_allowed'; }, () => controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), res, ), ); expect(res.body).toMatchObject({ card_verified: false, reason: 'prepaid_not_allowed', }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_card_verification).toBe(true); }); it('clears the gate when the extension verifies the card', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, }); const res = makeRes(); await withCardConfirmOverride( (data) => { data.enabled = true; data.verified = true; }, () => controller.handleCardVerificationConfirm( makeReq( { setup_intent_id: 'seti_1', original_client_socket_id: 'sock_1', }, { actor }, ), res, ), ); expect(res.body).toMatchObject({ card_verified: true }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_card_verification).toBe(false); }); }); describe('AuthController SMS → card fallback', () => { const stubPrelude = (over: Record = {}) => ({ isConfigured: () => true, isCountrySupported: () => true, defaultCountry: 'US', createVerification: vi.fn(async () => ({ status: 'success' })), ...over, }); const withPrelude = async ( prelude: unknown, fn: () => Promise, ): Promise => { const ctrl = controller as { clients: { prelude: unknown } }; const real = ctrl.clients.prelude; ctrl.clients.prelude = prelude; try { await fn(); } finally { ctrl.clients.prelude = real; } }; const withFallbackConfig = async ( value: unknown, fn: () => Promise, ): Promise => { const cfg = (controller as { config: Record }).config; const prev = cfg.phone_verification_card_fallback; cfg.phone_verification_card_fallback = value; try { await fn(); } finally { cfg.phone_verification_card_fallback = prev; } }; // Drive the attempt counter directly so the threshold is deterministic // (the handler keys it the same way: `phone-verify-attempts:`). const seedAttempts = (userId: number, attempts: number) => server.stores.kv.incr({ key: `phone-verify-attempts:${userId}`, pathAndAmountMap: { attempts }, }); // Stamp the eligibility flag the card endpoints check, the same way a // threshold-crossing send does (`card-fallback-open:`). const openFallback = (userId: number) => server.stores.kv.set({ key: `card-fallback-open:${userId}`, value: true, }); it('offers the fallback on send once the attempt threshold is reached', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, }); // No after_attempts → exercises the default threshold of 2. await withFallbackConfig({ enabled: true }, async () => { await withPrelude(stubPrelude(), async () => { const first = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), first, ); // First attempt is below the threshold — no offer yet. expect(first.body).toEqual({}); const second = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), second, ); expect(second.body).toEqual({ card_fallback_available: true, }); }); }); }); it('never offers the fallback on send when disabled', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, }); await withFallbackConfig( { enabled: false, after_attempts: 1 }, async () => { await withPrelude(stubPrelude(), async () => { const res = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), res, ); expect(res.body).toEqual({}); }); }, ); }); it('flags the fallback on a Prelude block once eligible', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, }); await withFallbackConfig( { enabled: true, after_attempts: 1 }, async () => { await withPrelude( stubPrelude({ createVerification: vi.fn(async () => ({ status: 'blocked', })), }), async () => { await expect( controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 429, fields: { card_fallback_available: true }, }); }, ); }, ); }); it('lets card setup proceed past the phone gate once eligible', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, phone: '+14155550123', }); await openFallback(user.id); await withFallbackConfig( { enabled: true }, async () => { const res = makeRes(); await withCardSetupOverride( (data) => { data.enabled = true; data.client_secret = 'seti_secret'; data.publishable_key = 'pk_test'; }, () => controller.handleCardVerificationSetup( makeReq({}, { actor }), res, ), ); expect(res.body).toEqual({ client_secret: 'seti_secret', publishable_key: 'pk_test', }); }, ); }); it('still 409s card setup when no send has opened the fallback', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, phone: '+14155550123', }); // Counter above the threshold but no flag: eligibility is the flag a // threshold-crossing send stamps, never the raw counter. await seedAttempts(user.id, 5); await withFallbackConfig( { enabled: true, after_attempts: 3 }, async () => { await expect( controller.handleCardVerificationSetup( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); }, ); }); it('send crossing the threshold opens card setup end-to-end', async () => { const { actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, }); await withFallbackConfig( { enabled: true, after_attempts: 1 }, async () => { await withPrelude(stubPrelude(), async () => { const sendRes = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), sendRes, ); expect(sendRes.body).toEqual({ card_fallback_available: true, }); }); const res = makeRes(); await withCardSetupOverride( (data) => { data.enabled = true; data.client_secret = 'seti_secret'; data.publishable_key = 'pk_test'; }, () => controller.handleCardVerificationSetup( makeReq({}, { actor }), res, ), ); expect(res.body).toEqual({ client_secret: 'seti_secret', publishable_key: 'pk_test', }); }, ); }); it('clamps after_attempts to the send route rate limit', async () => { const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, }); // 9 prior attempts + this send = 10, the route limit. A threshold of // 50 could never be crossed, so it clamps down and the offer opens. await seedAttempts(user.id, 9); await withFallbackConfig( { enabled: true, after_attempts: 50 }, async () => { await withPrelude(stubPrelude(), async () => { const res = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), res, ); expect(res.body).toEqual({ card_fallback_available: true, }); }); }, ); }); it('clears BOTH gates when the fallback card verifies', async () => { const { user, actor } = await makeUserAndActor({ requires_card_verification: 1, requires_phone_verification: 1, phone: '+14155550123', }); await openFallback(user.id); await withFallbackConfig( { enabled: true }, async () => { const res = makeRes(); await withCardConfirmOverride( (data) => { data.enabled = true; data.verified = true; }, () => controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), res, ), ); expect(res.body).toMatchObject({ card_verified: true, phone_verified: true, }); }, ); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_card_verification).toBe(false); expect(after!.requires_phone_verification).toBe(false); }); it('clears the phone gate via card even when card was not required', async () => { const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, phone: '+14155550123', }); await openFallback(user.id); await withFallbackConfig( { enabled: true }, async () => { const res = makeRes(); await withCardConfirmOverride( (data) => { data.enabled = true; data.verified = true; }, () => controller.handleCardVerificationConfirm( makeReq({ setup_intent_id: 'seti_1' }, { actor }), res, ), ); expect(res.body).toMatchObject({ phone_verified: true }); }, ); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.requires_phone_verification).toBe(false); }); }); describe('AuthController.handleConfirmEmail', () => { it('throws 400 when code is missing', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleConfirmEmail(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns email_confirmed:false on a wrong code', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleConfirmEmail( makeReq( { code: '000000', original_client_socket_id: 'sock1' }, { actor }, ), res, ); expect(res.body).toEqual({ email_confirmed: false, original_client_socket_id: 'sock1', }); }); it('confirms the email when the code matches', async () => { const { user, actor } = await makeUserAndActor(); const refreshed = await server.stores.user.getById(user.id, { force: true, }); const res = makeRes(); await controller.handleConfirmEmail( makeReq({ code: refreshed!.email_confirm_code! }, { actor }), res, ); expect((res.body as { email_confirmed: boolean }).email_confirmed).toBe( true, ); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.email_confirmed).toBeTruthy(); }); it('short-circuits to email_confirmed:true when the email is already confirmed', async () => { const { actor } = await makeUserAndActor({ email_confirmed: 1 }); const res = makeRes(); await controller.handleConfirmEmail( makeReq( { code: 'ignored', original_client_socket_id: 's' }, { actor }, ), res, ); expect(res.body).toEqual({ email_confirmed: true, original_client_socket_id: 's', }); }); }); // ── Password recovery flow ────────────────────────────────────────── describe('AuthController password recovery', () => { it('send-pass-recovery-email: 400 when neither username nor email supplied', async () => { await expect( controller.handleSendPassRecoveryEmail(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('send-pass-recovery-email: returns the generic message even for an unknown username (no leak)', async () => { const res = makeRes(); await controller.handleSendPassRecoveryEmail( makeReq({ username: `nonexistent_${uuidv4()}` }), res, ); expect((res.body as { message: string }).message).toMatch( /If that account exists/i, ); }); it('send-pass-recovery-email: stores a recovery token on a real user and returns the generic message', async () => { const { user } = await makeUserAndActor(); const res = makeRes(); await controller.handleSendPassRecoveryEmail( makeReq({ email: user.email! }), res, ); expect((res.body as { message: string }).message).toMatch(/account/); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.pass_recovery_token).toBeTruthy(); }); it('verify-pass-recovery-token: 400 on missing token', async () => { await expect( controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('verify-pass-recovery-token: 400 on a token with the wrong purpose', async () => { const wrong = server.services.token.sign( 'otp', { purpose: 'something-else', user_uid: uuidv4(), email: 'x' }, { expiresIn: '1h' }, ); await expect( controller.handleVerifyPassRecoveryToken( makeReq({ token: wrong }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('verify-pass-recovery-token: returns time_remaining for a valid token', async () => { const { user } = await makeUserAndActor(); const recoveryToken = uuidv4(); await server.stores.user.update(user.id, { pass_recovery_token: recoveryToken, }); const jwt = server.services.token.sign( 'otp', { token: recoveryToken, user_uid: user.uuid, email: user.email, purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); const res = makeRes(); await controller.handleVerifyPassRecoveryToken( makeReq({ token: jwt }), res, ); const body = res.body as { time_remaining: number }; expect(body.time_remaining).toBeGreaterThan(0); }); it('set-pass-using-token: 400 on missing token or password', async () => { await expect( controller.handleSetPassUsingToken( makeReq({ token: 'abc' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleSetPassUsingToken( makeReq({ password: 'abcdefgh' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('set-pass-using-token: rejects too-short passwords', async () => { await expect( controller.handleSetPassUsingToken( makeReq({ token: 'abc', password: '12' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('set-pass-using-token: rotates the password atomically and consumes the recovery token', async () => { const { user } = await makeUserAndActor(); const recoveryToken = uuidv4(); await server.stores.user.update(user.id, { pass_recovery_token: recoveryToken, }); const jwt = server.services.token.sign( 'otp', { token: recoveryToken, user_uid: user.uuid, email: user.email, purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); const res = makeRes(); await controller.handleSetPassUsingToken( makeReq({ token: jwt, password: 'a-brand-new-password' }), res, ); expect(res.sent).toBe('Password successfully updated.'); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.pass_recovery_token).toBeNull(); expect( await bcrypt.compare('a-brand-new-password', after!.password!), ).toBe(true); // Replay must fail (token was consumed atomically). await expect( controller.handleSetPassUsingToken( makeReq({ token: jwt, password: 'another-different-pass' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('set-pass-using-token: revokes all of the user’s interactive sessions', async () => { // A password reset is the "someone may have my account" flow, so // existing sessions must not survive it. const { user } = await makeUserAndActor(); const recoveryToken = uuidv4(); await server.stores.user.update(user.id, { pass_recovery_token: recoveryToken, }); const jwt = server.services.token.sign( 'otp', { token: recoveryToken, user_uid: user.uuid, email: user.email, purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); const s1 = await server.services.auth.createSessionToken(user, {}); const s2 = await server.services.auth.createSessionToken(user, {}); const uuid1 = (s1.session as { uuid: string }).uuid; const uuid2 = (s2.session as { uuid: string }).uuid; // Both are live before the reset. expect(await server.stores.session.getByUuid(uuid1)).not.toBeNull(); expect(await server.stores.session.getByUuid(uuid2)).not.toBeNull(); await controller.handleSetPassUsingToken( makeReq({ token: jwt, password: 'a-brand-new-password' }), makeRes(), ); // Every interactive session is revoked (getByUuid gates on revoked_at). expect(await server.stores.session.getByUuid(uuid1)).toBeNull(); expect(await server.stores.session.getByUuid(uuid2)).toBeNull(); }); }); // ── User-protected change-* (skipping middleware-driven setup) ───── describe('AuthController user-protected mutations (validation paths)', () => { it('change-password: 400 on missing new_pass', async () => { const { actor } = await makeUserAndActor(); const req = makeReq({}, { actor }); // The route's middleware would normally populate req.userProtected.user // — provide a stub so the validation path before it can run. (req as unknown as { userProtected: unknown }).userProtected = { user: actor.user, }; await expect( controller.handleChangePassword(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change-password: 400 on too-short new_pass', async () => { const { actor } = await makeUserAndActor(); const req = makeReq({ new_pass: '12' }, { actor }); (req as unknown as { userProtected: unknown }).userProtected = { user: actor.user, }; await expect( controller.handleChangePassword(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change-password: rotates the password hash on success', async () => { const { user, actor } = await makeUserAndActor(); const req = makeReq({ new_pass: 'correct-horse-battery-2' }, { actor }); (req as unknown as { userProtected: unknown }).userProtected = { user, }; const res = makeRes(); await controller.handleChangePassword(req, res); expect(res.sent).toBe('Password successfully updated.'); const after = await server.stores.user.getById(user.id, { force: true, }); expect( await bcrypt.compare('correct-horse-battery-2', after!.password!), ).toBe(true); }); it('change-password: revokes the user’s other web sessions but keeps the current one', async () => { const { user, actor } = await makeUserAndActor(); // Two web sessions for this user; one is the session performing the // change (the "current" one, identified via actor.session.uid). const current = await server.services.auth.createSessionToken(user, {}); const other = await server.services.auth.createSessionToken(user, {}); const currentUuid = (current.session as { uuid: string }).uuid; const otherUuid = (other.session as { uuid: string }).uuid; const req = makeReq( { new_pass: 'a-fresh-password-123' }, { actor: { ...actor, session: { uid: currentUuid }, } as typeof actor, }, ); (req as unknown as { userProtected: unknown }).userProtected = { user }; await controller.handleChangePassword(req, makeRes()); // The other session is revoked; the one that made the change survives. expect(await server.stores.session.getByUuid(otherUuid)).toBeNull(); expect( await server.stores.session.getByUuid(currentUuid), ).not.toBeNull(); }); it('change-username: 400 on missing/invalid/reserved/already-taken usernames', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleChangeUsername(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleChangeUsername( makeReq({ new_username: 'has space' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleChangeUsername( makeReq({ new_username: 'admin' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); // Already-taken const { user: other } = await makeUserAndActor(); await expect( controller.handleChangeUsername( makeReq({ new_username: other.username }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change-username: persists the rename and emits user.username-changed', async () => { const { user, actor } = await makeUserAndActor(); const newUsername = `r_${uniq()}`; const heard: Array> = []; const off = (() => { const fn = (_k: unknown, data: unknown) => { heard.push(data as Record); }; eventClient.on('user.username-changed', fn); return fn; })(); try { const res = makeRes(); await controller.handleChangeUsername( makeReq({ new_username: newUsername }, { actor }), res, ); expect(res.body).toEqual({ username: newUsername }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.username).toBe(newUsername); expect( heard.some( (e) => (e as { new_username?: string }).new_username === newUsername, ), ).toBe(true); } finally { void off; // listener stays attached; harmless for the rest of the suite. } }); it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleChangeEmail(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleChangeEmail( makeReq({ new_email: 'not-an-email' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); // Pre-existing confirmed account on another email. const { user: other } = await makeUserAndActor({ email_confirmed: 1 }); await expect( controller.handleChangeEmail( makeReq({ new_email: other.email! }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change-email: stages the new email + token on success', async () => { const { user, actor } = await makeUserAndActor(); const newEmail = `ch_${uniq()}@test.local`; const res = makeRes(); await controller.handleChangeEmail( makeReq({ new_email: newEmail }, { actor }), res, ); expect(res.body).toEqual({}); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.unconfirmed_change_email).toBe(newEmail); expect(after!.change_email_confirm_token).toBeTruthy(); // Original email is unchanged until the user confirms. expect(after!.email).toBe(user.email); }); it('change_email/confirm: rejects an invalid/non-change-email-purpose JWT', async () => { const wrong = server.services.token.sign( 'otp', { purpose: 'pass-recovery', token: uuidv4() }, { expiresIn: '1h' }, ); const req = makeReq({}); (req as unknown as { query: Record }).query = { token: wrong, }; await expect( controller.handleChangeEmailConfirm(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change_email/confirm: completes the swap when the token matches the staged row', async () => { const { user, actor } = await makeUserAndActor(); const newEmail = `chc_${uniq()}@test.local`; await controller.handleChangeEmail( makeReq({ new_email: newEmail }, { actor }), makeRes(), ); const staged = await server.stores.user.getById(user.id, { force: true, }); const linkJwt = server.services.token.sign( 'otp', { token: staged!.change_email_confirm_token, user_id: user.id, purpose: 'change-email', }, { expiresIn: '1h' }, ); const req = makeReq({}); (req as unknown as { query: Record }).query = { token: linkJwt, }; const res = makeRes(); await controller.handleChangeEmailConfirm(req, res); expect(res.sent).toMatch(/Email changed successfully/); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.email).toBe(newEmail); expect(after!.unconfirmed_change_email).toBeNull(); expect(after!.email_confirmed).toBeTruthy(); }); }); // ── Save account (temp → permanent) ──────────────────────────────── describe('AuthController.handleSaveAccount', () => { const makeTempActor = async () => { const tempRes = makeRes(); await controller.handleSignup(makeReq({ is_temp: true }), tempRes); const body = tempRes.body as { user: { username: string; uuid: string }; }; const u = await server.stores.user.getByUsername(body.user.username); return { user: u!, actor: { user: { id: u!.id, uuid: u!.uuid, username: u!.username, email: u!.email ?? null, }, } as Actor, }; }; it('rejects non-temp accounts with 400', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleSaveAccount( makeReq( { username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: 'correct-horse-battery', }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('promotes a temp user to a permanent account', async () => { const { user, actor } = await makeTempActor(); const newUsername = `s_${uniq()}`; const newEmail = `${newUsername}@test.local`; const res = makeRes(); await controller.handleSaveAccount( makeReq( { username: newUsername, email: newEmail, password: 'correct-horse-battery', }, { actor }, ), res, ); const body = res.body as { user: { username: string; email: string; is_temp: boolean }; }; expect(body.user.username).toBe(newUsername); expect(body.user.email).toBe(newEmail); expect(body.user.is_temp).toBe(false); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.username).toBe(newUsername); expect(after!.email).toBe(newEmail); expect( await bcrypt.compare('correct-horse-battery', after!.password!), ).toBe(true); }); it('rejects invalid username/email/password validations', async () => { const { actor } = await makeTempActor(); await expect( controller.handleSaveAccount( makeReq( { username: 'has space', email: 'a@b.c', password: 'xxxxxx', }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleSaveAccount( makeReq( { username: 'admin', email: 'a@b.com', password: 'xxxxxx' }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleSaveAccount( makeReq( { username: 'okname', email: 'not-an-email', password: 'xxxxxx', }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleSaveAccount( makeReq( { username: 'okname', email: 'a@b.com', password: '12' }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); }); // ── Captcha + anti-CSRF ──────────────────────────────────────────── describe('AuthController.handleCaptchaGenerate + handleGetAntiCsrfToken', () => { it('captcha-generate returns {token, image}', async () => { const res = makeRes(); await controller.handleCaptchaGenerate(makeReq({}), res); const body = res.body as { token: string; image: string }; expect(typeof body.token).toBe('string'); expect(typeof body.image).toBe('string'); expect(body.token.length).toBeGreaterThan(0); }); it('get-anticsrf-token: 401 without an authenticated actor', async () => { await expect( controller.handleGetAntiCsrfToken(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 401 }); }); it('get-anticsrf-token: returns a token bound to the user UUID', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleGetAntiCsrfToken(makeReq({}, { actor }), res); const body = res.body as { token: string }; expect(typeof body.token).toBe('string'); expect(body.token.length).toBeGreaterThan(0); }); }); // ── Permission revoke flows ──────────────────────────────────────── describe('AuthController permission revokes', () => { it('revoke-user-user: 400 on missing target_username/permission', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeUserUser( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-user-app: 400 on missing app_uid/permission', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeUserApp( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-user-group: 400 on missing group_uid/permission', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeUserGroup( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-user-user: round-trips a grant + revoke without throwing', async () => { const { actor: issuerActor, user: issuer } = await makeUserAndActor(); const { user: target } = await makeUserAndActor(); const permission = `service:test-revoke-${uuidv4()}:ii:read`; await server.stores.permission.setFlatUserPerm( issuer.id, `manage:${permission}`, { permission: `manage:${permission}`, deleted: false, issuer_user_id: issuer.id, } as never, ); // Grant first. await inCtx(issuerActor, () => controller.handleGrantUserUser( makeReq( { target_username: target.username, permission }, { actor: issuerActor }, ), makeRes(), ), ); // Now revoke — must complete without throwing and return {}. // We don't re-assert the post-revoke `check()` answer here: the // Redis-mock scan cache is process-wide, and intervening grants // from other tests have repeatedly been observed to leave the // cached `true` answer in place even after a successful revoke. // Verifying the controller path rather than the cache eviction // semantics keeps this test focused. const res = makeRes(); await inCtx(issuerActor, () => controller.handleRevokeUserUser( makeReq( { target_username: target.username, permission }, { actor: issuerActor }, ), res, ), ); expect(res.body).toEqual({}); }); }); // ── Permission checks + listing ──────────────────────────────────── describe('AuthController.handleCheckPermissions + handleListPermissions', () => { it('check-permissions: 400 when permissions is not an array', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleCheckPermissions( makeReq( { permissions: 'not-an-array' as unknown as string[] }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('check-permissions: returns a per-permission boolean map for known + unknown perms', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleCheckPermissions( makeReq( { permissions: [ 'service:foo:ii:read', 'service:foo:ii:read', // dedup-tested 'service:bar:ii:write', ], }, { actor }, ), res, ); const body = res.body as { permissions: Record }; expect(Object.keys(body.permissions).sort()).toEqual([ 'service:bar:ii:write', 'service:foo:ii:read', ]); }); it('list-permissions: handler runs and returns shape (the source SQL references `app_uid` and may throw on real installs — we catch and assert either branch)', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); try { await controller.handleListPermissions(makeReq({}, { actor }), res); const body = res.body as { myself_to_app: unknown[]; myself_to_user: unknown[]; user_to_myself: unknown[]; }; expect(Array.isArray(body.myself_to_app)).toBe(true); expect(Array.isArray(body.myself_to_user)).toBe(true); expect(Array.isArray(body.user_to_myself)).toBe(true); } catch (e) { // The current schema uses `app_id` in user_to_app_permissions. // If the SQL fails because of the schema mismatch, surface the // error message clearly so future fixes flip this branch off. expect((e as Error).message).toMatch(/app_uid|no such column/); } }); }); // ── Sessions ─────────────────────────────────────────────────────── describe('AuthController session endpoints', () => { it('list-sessions: returns an array shape (possibly empty)', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleListSessions(makeReq({}, { actor }), res); // listSessions returns an array — it may be empty for a freshly- // created actor without an active session row. expect(res.body).toBeDefined(); }); it('revoke-session: 400 when uuid is missing or non-string', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeSession(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleRevokeSession( makeReq({ uuid: 123 as unknown as string }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-session: 403 when revoking someone else’s session', async () => { const { user: u1 } = await makeUserAndActor(); const { actor: a2 } = await makeUserAndActor(); // Create a real session for u1 so the lookup succeeds, then attempt // to revoke it as a2 — must 403. const sessionRes = await server.services.auth.createSessionToken( u1, {}, ); await expect( controller.handleRevokeSession( makeReq( { uuid: (sessionRes.session as { uuid: string }).uuid }, { actor: a2 }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); }); it('rename-session: 400 when uuid param is missing', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRenameSession( makeReq({ label: 'x' }, { actor, params: {} }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rename-session: 400 when label is the wrong type', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRenameSession( makeReq( { label: 123 as unknown as string }, { actor, params: { uuid: 'whatever' } }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rename-session: 400 when label field is missing entirely', async () => { // Guards against accidental "PATCH with empty body silently clears // the label". Type guard rejects `undefined` before reaching the // service layer. const { actor } = await makeUserAndActor(); await expect( controller.handleRenameSession( makeReq({}, { actor, params: { uuid: 'whatever' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rename-session: 404 when the uuid belongs to another user', async () => { const { user: u1 } = await makeUserAndActor(); const { actor: a2 } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( u1, {}, ); const uuid = (sessionRes.session as { uuid: string }).uuid; await expect( controller.handleRenameSession( makeReq( { label: 'pwned' }, { actor: a2, params: { uuid } }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('rename-session: success updates the row label', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const uuid = (sessionRes.session as { uuid: string }).uuid; const res = makeRes(); await controller.handleRenameSession( makeReq({ label: 'My Phone' }, { actor, params: { uuid } }), res, ); expect(res.body).toEqual({}); const rows = await server.clients.db.read( 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', [uuid], ); expect((rows[0] as { label: string }).label).toBe('My Phone'); }); it('rename-session: accepts null to clear the label', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const uuid = (sessionRes.session as { uuid: string }).uuid; // Seed a non-null label so the clear-to-null transition is observable. await server.clients.db.write( 'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?', ['something', uuid], ); await controller.handleRenameSession( makeReq({ label: null }, { actor, params: { uuid } }), makeRes(), ); const rows = await server.clients.db.read( 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', [uuid], ); expect((rows[0] as { label: string | null }).label).toBeNull(); }); }); // ── Dev-app grants/revokes ───────────────────────────────────────── describe('AuthController dev-app permission flows', () => { it('grant-dev-app: 400 on missing app_uid/origin/permission', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGrantDevApp( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-dev-app: 400 on missing app_uid/origin/permission', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeDevApp( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); }); // ── App origin resolution ────────────────────────────────────────── describe('AuthController.handleAppUidFromOrigin', () => { it('400 when origin is missing', async () => { await expect( controller.handleAppUidFromOrigin(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns a deterministic app- prefixed uid for an arbitrary origin', async () => { const origin = `https://origin-${uuidv4()}.example`; const res = makeRes(); await controller.handleAppUidFromOrigin(makeReq({ origin }), res); const body = res.body as { uid: string }; expect(body.uid).toMatch(/^app-/); }); }); // ── 2FA configure / disable ──────────────────────────────────────── describe('AuthController 2FA flows', () => { it('configure-2fa: 400 on an unknown :action', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'frobnicate' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('configure-2fa setup: returns {url, secret, codes[10]} and stores the secret', async () => { const { user, actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'setup' } }), res, ); const body = res.body as { url: string; secret: string; codes: string[]; }; expect(body.codes).toHaveLength(10); expect(typeof body.secret).toBe('string'); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.otp_secret).toBe(body.secret); expect( ((after!.otp_recovery_codes as string | null) ?? '').split(','), ).toHaveLength(10); }); it('configure-2fa setup: 409 when 2FA is already enabled', async () => { const { actor } = await makeUserAndActor({ otp_enabled: 1 }); await expect( controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'setup' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); }); it('configure-2fa test: 400 when code is missing', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'test' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('configure-2fa enable: 403 if email is unconfirmed; 409 if already enabled or no secret', async () => { // Email unconfirmed → 403. const { actor: aUnconfirmed } = await makeUserAndActor(); await expect( controller.handleConfigure2fa( makeReq( {}, { actor: aUnconfirmed, params: { action: 'enable' } }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); // Confirmed but no secret → 409. const { actor: aNoSecret } = await makeUserAndActor({ email_confirmed: 1, }); await expect( controller.handleConfigure2fa( makeReq({}, { actor: aNoSecret, params: { action: 'enable' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); // Already enabled → 409. const { actor: aEnabled } = await makeUserAndActor({ email_confirmed: 1, otp_enabled: 1, otp_secret: 'TESTSECRETBASE32', }); await expect( controller.handleConfigure2fa( makeReq({}, { actor: aEnabled, params: { action: 'enable' } }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409 }); }); it('disable-2fa: clears otp_enabled / otp_secret / otp_recovery_codes', async () => { const { user, actor } = await makeUserAndActor({ otp_enabled: 1, otp_secret: 'TESTSECRETBASE32', otp_recovery_codes: 'a,b,c', }); const res = makeRes(); await controller.handleDisable2fa(makeReq({}, { actor }), res); expect(res.body).toEqual({ success: true }); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.otp_enabled).toBeFalsy(); expect(after!.otp_secret).toBeNull(); expect(after!.otp_recovery_codes).toBeNull(); }); }); // ── Dev profile ──────────────────────────────────────────────────── describe('AuthController.handleGetDevProfile', () => { it('returns the public dev-profile shape with sensible defaults', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleGetDevProfile(makeReq({}, { actor }), res); const body = res.body as Record; expect(body).toMatchObject({ first_name: null, last_name: null, approved_for_incentive_program: false, joined_incentive_program: false, paypal: null, }); }); }); // ── Group endpoints ──────────────────────────────────────────────── describe('AuthController group endpoints', () => { it('group/create: rejects non-object extra/metadata with 400', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGroupCreate( makeReq({ extra: ['x'] }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleGroupCreate( makeReq({ metadata: ['x'] }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('group/create + add-users + remove-users: full owner-driven lifecycle', async () => { const { actor: owner } = await makeUserAndActor(); const { user: target } = await makeUserAndActor(); // Create. const createRes = makeRes(); await controller.handleGroupCreate( makeReq({ metadata: { name: 'g' } }, { actor: owner }), createRes, ); const { uid } = createRes.body as { uid: string }; expect(typeof uid).toBe('string'); // Add. const addRes = makeRes(); await controller.handleGroupAddUsers( makeReq({ uid, users: [target.username] }, { actor: owner }), addRes, ); expect(addRes.body).toEqual({}); // Remove. const remRes = makeRes(); await controller.handleGroupRemoveUsers( makeReq({ uid, users: [target.username] }, { actor: owner }), remRes, ); expect(remRes.body).toEqual({}); }); it('group/add-users: 400 on missing uid or non-array users', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGroupAddUsers( makeReq({ users: ['x'] }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); await expect( controller.handleGroupAddUsers( makeReq({ uid: 'g-1' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('group/add-users: 404 on unknown uid; 403 when caller doesn’t own the group', async () => { const { actor: a1 } = await makeUserAndActor(); const { actor: a2 } = await makeUserAndActor(); await expect( controller.handleGroupAddUsers( makeReq( { uid: `does-not-exist-${uuidv4()}`, users: [] }, { actor: a1 }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); // Group owned by a1; a2 tries to add → 403. const createRes = makeRes(); await controller.handleGroupCreate( makeReq({}, { actor: a1 }), createRes, ); const { uid } = createRes.body as { uid: string }; await expect( controller.handleGroupAddUsers( makeReq({ uid, users: [] }, { actor: a2 }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); }); it('group/list: forwards to GroupStore listByOwner/listByMember (or surfaces the source-side method-name mismatch)', async () => { const { actor } = await makeUserAndActor(); const res = makeRes(); try { await controller.handleGroupList(makeReq({}, { actor }), res); const body = res.body as { owned_groups: unknown[]; in_groups: unknown[]; }; expect(Array.isArray(body.owned_groups)).toBe(true); expect(Array.isArray(body.in_groups)).toBe(true); } catch (e) { // The handler calls `stores.group.listByOwner(...)`, but the // GroupStore implementation may expose a differently-named // method. Surface the mismatch so a future GroupStore rename // re-enables the assertion above. expect((e as Error).message).toMatch( /listByOwner|listByMember|is not a function/, ); } }); it('group/public-groups: returns {user, temp} from config', async () => { const res = makeRes(); await controller.handleGroupPublicGroups(makeReq({}), res); const body = res.body as { user: string | null; temp: string | null }; expect(body).toHaveProperty('user'); expect(body).toHaveProperty('temp'); }); }); // ── GUI token + session sync cookie ──────────────────────────────── describe('AuthController.handleGetGuiToken + handleSessionSyncCookie', () => { it('get-gui-token: 400 when actor has no session bound', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGetGuiToken(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('get-gui-token: returns a verifiable GUI token for an actor with a session', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const sessionedActor = { ...actor, session: { uid: sessionUid }, } as Actor; const res = makeRes(); await controller.handleGetGuiToken( makeReq({}, { actor: sessionedActor }), res, ); const body = res.body as { token: string }; const decoded = server.services.token.verify('auth', body.token) as { type: string; user_uid: string; }; expect(decoded.type).toBe('gui'); expect(decoded.user_uid).toBe(user.uuid); }); it('session/sync-cookie: 400 when no session; 204 + cookie when bound', async () => { const { user, actor } = await makeUserAndActor(); // No session → 400. const r1 = makeRes(); await controller.handleSessionSyncCookie(makeReq({}, { actor }), r1); expect(r1.statusCode).toBe(400); // Bound session → 204 with the session cookie set. const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const sessionedActor = { ...actor, session: { uid: sessionUid }, } as Actor; const r2 = makeRes(); await controller.handleSessionSyncCookie( makeReq({}, { actor: sessionedActor }), r2, ); expect(r2.statusCode).toBe(204); expect(r2.cookies['puter_auth_token']).toBeDefined(); }); }); // ── Delete own user ──────────────────────────────────────────────── describe('AuthController.handleDeleteOwnUser', () => { it('cascade-deletes the user row and clears the session cookie', async () => { const { user, actor } = await makeUserAndActor(); const res = makeRes(); await controller.handleDeleteOwnUser(makeReq({}, { actor }), res); expect(res.body).toEqual({ success: true }); expect(res.clearedCookies).toContain('puter_auth_token'); expect(res.clearedCookies).toContain('puter_revalidation'); // Row is gone. const after = await server.stores.user.getById(user.id, { force: true, }); expect(after).toBeFalsy(); }); it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => { // `stripe_customer_id` ships in the MySQL/Postgres migrations but not // the sqlite ones the test harness runs — add it so the delete path // captures it (it's how the marketplace extension cancels the sub). try { await server.clients.db.write( 'ALTER TABLE user ADD COLUMN stripe_customer_id TEXT', [], ); } catch { /* already exists */ } const { user, actor } = await makeUserAndActor(); await server.clients.db.write( 'UPDATE user SET stripe_customer_id = ? WHERE id = ?', ['cus_delete_test', user.id], ); heardUserDelete.length = 0; await controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()); const evt = heardUserDelete.find((e) => e.user_id === user.id); expect(evt).toMatchObject({ user_id: user.id, user_uuid: user.uuid, stripe_customer_id: 'cus_delete_test', }); }); }); // ── Additional branch coverage ───────────────────────────────────── describe('AuthController.handleLogin additional branches', () => { it('rejects non-string password with 400', async () => { await expect( controller.handleLogin( makeReq({ username: 'someone', password: 123 as unknown as string, }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects too-short password with 400', async () => { await expect( controller.handleLogin( makeReq({ username: 'someone', password: '12' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects non-string username with 400', async () => { await expect( controller.handleLogin( makeReq({ username: 42 as unknown as string, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 for an unknown email address (parallel to unknown-username case)', async () => { await expect( controller.handleLogin( makeReq({ email: `unknown-${uuidv4()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('returns 401 when the stored password is null (e.g. OIDC-only account)', async () => { const { user } = await makeUserAndActor(); // Mimic an OIDC account: confirmed email but no password. await server.stores.user.update(user.id, { password: null, email_confirmed: 1, }); await expect( controller.handleLogin( makeReq({ username: user.username, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); }); describe('AuthController.handleLoginOtp additional branches', () => { it('rejects missing token with 400', async () => { await expect( controller.handleLoginOtp(makeReq({ code: '123456' }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects missing code with 400', async () => { const otpJwt = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginOtp(makeReq({ token: otpJwt }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 when the user_uid in the token has no matching user', async () => { const otpJwt = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginOtp( makeReq({ token: otpJwt, code: '123456' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('returns 401 when the user is suspended', async () => { const { user } = await makeUserAndActor({ suspended: 1 }); const otpJwt = server.services.token.sign( 'otp', { user_uid: user.uuid, purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginOtp( makeReq({ token: otpJwt, code: '123456' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); }); describe('AuthController.handleLoginRecoveryCode additional branches', () => { it('rejects missing token with 400', async () => { await expect( controller.handleLoginRecoveryCode( makeReq({ code: 'foo' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects missing code with 400', async () => { const otpJwt = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginRecoveryCode( makeReq({ token: otpJwt }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects an invalid (unverifiable) JWT with 400', async () => { await expect( controller.handleLoginRecoveryCode( makeReq({ token: 'not-a-jwt', code: 'foo' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a valid JWT with the wrong purpose', async () => { const wrong = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'something-else' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginRecoveryCode( makeReq({ token: wrong, code: 'foo' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 when the user_uid does not match any user', async () => { const otpJwt = server.services.token.sign( 'otp', { user_uid: uuidv4(), purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginRecoveryCode( makeReq({ token: otpJwt, code: 'foo' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('returns 401 when the user is suspended', async () => { const { user } = await makeUserAndActor({ suspended: 1 }); const otpJwt = server.services.token.sign( 'otp', { user_uid: user.uuid, purpose: 'otp-login' }, { expiresIn: '5m' }, ); await expect( controller.handleLoginRecoveryCode( makeReq({ token: otpJwt, code: 'foo' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); }); describe('AuthController.handleSignup additional branches', () => { it('rejects missing username with 400', async () => { await expect( controller.handleSignup( makeReq({ email: `${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects non-string username with 400', async () => { await expect( controller.handleSignup( makeReq({ username: 123 as unknown as string, email: `${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects username containing invalid characters with 400', async () => { await expect( controller.handleSignup( makeReq({ username: 'has space', email: `${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects username longer than 45 characters with 400', async () => { const longUsername = 'a'.repeat(46); await expect( controller.handleSignup( makeReq({ username: longUsername, email: `${uniq()}@test.local`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects missing email with 400 for non-temp signups', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects non-string email with 400', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: 12345 as unknown as string, password: 'correct-horse-battery', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects missing password with 400 for non-temp signups', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: `${uniq()}@test.local`, }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects non-string password with 400', async () => { await expect( controller.handleSignup( makeReq({ username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: 12345 as unknown as string, }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('claims a pseudo-user (password=null, email_confirmed=0) on email match', async () => { // Seed a pseudo user (admin-style placeholder): email present, // password null, unconfirmed. const targetEmail = `pseudo_${uniq()}@test.local`; const placeholder = await server.stores.user.create({ username: `placeholder_${uniq()}`, uuid: uuidv4(), password: null, email: targetEmail, clean_email: targetEmail, email_confirmed: 0, } as never); // Now signup with the same email — should claim the pseudo row, // not throw. const newUsername = `claim_${uniq()}`; const res = makeRes(); await controller.handleSignup( makeReq({ username: newUsername, email: targetEmail, password: 'correct-horse-battery', }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); // The placeholder row was repurposed (same id, new username). const claimed = await server.stores.user.getById(placeholder.id, { force: true, }); expect(claimed!.username).toBe(newUsername); expect(claimed!.password).not.toBeNull(); }); it('claim clears stale phone/card gates on the placeholder when the decision no longer requires them', async () => { // Placeholder seeded with both gates already set. A benign claim // (no validate override → no requirements) must reset them rather // than silently inheriting the stale requirement. const targetEmail = `pseudo_${uniq()}@test.local`; const placeholder = await server.stores.user.create({ username: `placeholder_${uniq()}`, uuid: uuidv4(), password: null, email: targetEmail, clean_email: targetEmail, email_confirmed: 0, requires_phone_verification: 1, requires_card_verification: 1, } as never); const res = makeRes(); await controller.handleSignup( makeReq({ username: `claim_${uniq()}`, email: targetEmail, password: 'correct-horse-battery', }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); const claimed = await server.stores.user.getById(placeholder.id, { force: true, }); expect(claimed!.requires_phone_verification).toBe(false); expect(claimed!.requires_card_verification).toBe(false); }); it('claim carries the phone/card gates when the decision requires them', async () => { const targetEmail = `pseudo_${uniq()}@test.local`; const placeholder = await server.stores.user.create({ username: `placeholder_${uniq()}`, uuid: uuidv4(), password: null, email: targetEmail, clean_email: targetEmail, email_confirmed: 0, } as never); await withSignupValidateOverride( (event) => { event.requires_phone_verification = true; event.requires_card_verification = true; }, async () => { const res = makeRes(); await controller.handleSignup( makeReq({ username: `claim_${uniq()}`, email: targetEmail, password: 'correct-horse-battery', }), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); }, ); const claimed = await server.stores.user.getById(placeholder.id, { force: true, }); expect(claimed!.requires_phone_verification).toBe(true); expect(claimed!.requires_card_verification).toBe(true); }); it('extension hook can require email confirmation via requires_email_confirmation=true', async () => { await withSignupValidateOverride( (event) => { event.requires_email_confirmation = true; }, async () => { const username = `efce_${uniq()}`; const res = makeRes(); await controller.handleSignup( makeReq({ username, email: `${username}@test.local`, password: 'correct-horse-battery', }), res, ); // Login still completes; the user row carries the flag. const persisted = await server.stores.user.getByUsername(username); expect(persisted!.requires_email_confirmation).toBeTruthy(); }, ); }); }); describe('AuthController.handleSendPassRecoveryEmail additional branches', () => { it('rejects an invalid email format with 400 (when no username supplied)', async () => { await expect( controller.handleSendPassRecoveryEmail( makeReq({ email: 'not-an-email' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns the generic message for a suspended user (no leak)', async () => { const { user } = await makeUserAndActor({ suspended: 1 }); const res = makeRes(); await controller.handleSendPassRecoveryEmail( makeReq({ username: user.username }), res, ); // Generic message — does not reveal the suspension state. expect((res.body as { message: string }).message).toMatch( /If that account exists/i, ); // No recovery token persisted on a suspended account. const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.pass_recovery_token).toBeFalsy(); }); }); describe('AuthController.handleVerifyPassRecoveryToken additional branches', () => { it('rejects an unverifiable JWT with 400', async () => { await expect( controller.handleVerifyPassRecoveryToken( makeReq({ token: 'not-a-jwt' }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects when the user does not exist (user_uid is bogus)', async () => { const jwt = server.services.token.sign( 'otp', { token: uuidv4(), user_uid: uuidv4(), email: 'someone@test.local', purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); await expect( controller.handleVerifyPassRecoveryToken( makeReq({ token: jwt }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects when the email in the token no longer matches the user', async () => { const { user } = await makeUserAndActor(); const jwt = server.services.token.sign( 'otp', { token: uuidv4(), user_uid: user.uuid, email: 'someone-else@test.local', // mismatch purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); await expect( controller.handleVerifyPassRecoveryToken( makeReq({ token: jwt }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 401 when the user is suspended', async () => { const { user } = await makeUserAndActor({ suspended: 1 }); const jwt = server.services.token.sign( 'otp', { token: uuidv4(), user_uid: user.uuid, email: user.email, purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); await expect( controller.handleVerifyPassRecoveryToken( makeReq({ token: jwt }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); }); describe('AuthController.handleSetPassUsingToken additional branches', () => { it('rejects missing both token and password with 400', async () => { await expect( controller.handleSetPassUsingToken(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects an unverifiable JWT with 400', async () => { await expect( controller.handleSetPassUsingToken( makeReq({ token: 'not-a-jwt', password: 'a-brand-new-password', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects a JWT with the wrong purpose', async () => { const wrong = server.services.token.sign( 'otp', { purpose: 'otp-login', user_uid: uuidv4() }, { expiresIn: '1h' }, ); await expect( controller.handleSetPassUsingToken( makeReq({ token: wrong, password: 'a-brand-new-password', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('rejects when the user no longer exists', async () => { const jwt = server.services.token.sign( 'otp', { token: uuidv4(), user_uid: uuidv4(), email: 'someone@test.local', purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); await expect( controller.handleSetPassUsingToken( makeReq({ token: jwt, password: 'a-brand-new-password', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 401 when the user is suspended', async () => { const { user } = await makeUserAndActor({ suspended: 1 }); const jwt = server.services.token.sign( 'otp', { token: uuidv4(), user_uid: user.uuid, email: user.email, purpose: 'pass-recovery', }, { expiresIn: '1h' }, ); await expect( controller.handleSetPassUsingToken( makeReq({ token: jwt, password: 'a-brand-new-password', }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); }); describe('AuthController user-protected mutations: additional branches', () => { it('change-username: 400 on too-long new_username', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleChangeUsername( makeReq({ new_username: 'a'.repeat(46) }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change-email: 400 when an unconfirmed-but-password-holding account already owns the email', async () => { // Other user: password set, email NOT confirmed → still blocks // (existing.password !== null branch). const { user: other } = await makeUserAndActor(); const { actor } = await makeUserAndActor(); await expect( controller.handleChangeEmail( makeReq({ new_email: other.email! }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change_email/confirm: 400 on missing token', async () => { const req = makeReq({}); (req as unknown as { query: Record }).query = {}; await expect( controller.handleChangeEmailConfirm(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change_email/confirm: 400 on a bogus JWT', async () => { const req = makeReq({}); (req as unknown as { query: Record }).query = { token: 'not-a-jwt', }; await expect( controller.handleChangeEmailConfirm(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('change_email/confirm: 400 when no row matches the staged token', async () => { // Sign a properly-shaped JWT with a nonexistent change_email token. const linkJwt = server.services.token.sign( 'otp', { token: uuidv4(), user_id: 999_999, purpose: 'change-email', }, { expiresIn: '1h' }, ); const req = makeReq({}); (req as unknown as { query: Record }).query = { token: linkJwt, }; await expect( controller.handleChangeEmailConfirm(req, makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); }); describe('AuthController.handleSaveAccount additional branches', () => { it('returns 404 when the actor has no matching user row (deleted)', async () => { const { user, actor } = await makeUserAndActor(); // Delete the row out from under the actor. await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleSaveAccount( makeReq( { username: `s_${uniq()}`, email: `${uniq()}@test.local`, password: 'correct-horse-battery', }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('rejects too-long username with 400', async () => { // Need a temp actor for the username-validation path to be // reachable (non-temp short-circuits at "not a temporary account"). const tempRes = makeRes(); await controller.handleSignup(makeReq({ is_temp: true }), tempRes); const tempBody = tempRes.body as { user: { username: string; uuid: string }; }; const tempUser = await server.stores.user.getByUsername( tempBody.user.username, ); const tempActor = { user: { id: tempUser!.id, uuid: tempUser!.uuid, username: tempUser!.username, email: tempUser!.email ?? null, }, } as Actor; await expect( controller.handleSaveAccount( makeReq( { username: 'a'.repeat(46), email: `${uniq()}@test.local`, password: 'correct-horse-battery', }, { actor: tempActor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); }); describe('AuthController grant/revoke additional branches', () => { it('grant-user-app: 400 on missing app_uid', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGrantUserApp( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('grant-user-group: 400 on missing group_uid', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGrantUserGroup( makeReq({ permission: 'fs:read' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('revoke-user-app: 400 when permission is "*" but app_uid is missing', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleRevokeUserApp( makeReq({ permission: '*' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); }); describe('AuthController.handleAppUidFromOrigin additional branches', () => { it('reads origin from req.query as well as req.body', async () => { const origin = `https://qparam-${uuidv4()}.example`; const req = makeReq({}); (req as unknown as { query: Record }).query = { origin, }; const res = makeRes(); await controller.handleAppUidFromOrigin(req, res); expect((res.body as { uid: string }).uid).toMatch(/^app-/); }); }); describe('AuthController.handleCheckApp additional branches', () => { it('rejects missing app_uid AND origin with 400', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleCheckApp(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 400 }); }); it('resolves origin → app_uid when app_uid is omitted', async () => { const { actor } = await makeUserAndActor(); const origin = `https://co-${uuidv4()}.example`; const res = makeRes(); await inCtx(actor, () => controller.handleCheckApp(makeReq({ origin }, { actor }), res), ); const body = res.body as { app_uid: string; authenticated: boolean; }; expect(body.app_uid).toMatch(/^app-/); expect(typeof body.authenticated).toBe('boolean'); }); }); describe('AuthController 2FA additional branches', () => { it('configure-2fa test: returns ok:false on a mismatched code', async () => { // Setup so otp_secret is populated. const { user, actor } = await makeUserAndActor(); await controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'setup' } }), makeRes(), ); const refreshed = await server.stores.user.getById(user.id, { force: true, }); // Re-build the actor so it sees the freshly stored secret if cached. void refreshed; const res = makeRes(); await controller.handleConfigure2fa( makeReq({ code: '000000' }, { actor, params: { action: 'test' } }), res, ); expect(res.body).toEqual({ ok: false }); }); it('configure-2fa enable: succeeds when email is confirmed and a secret exists', async () => { const { user, actor } = await makeUserAndActor({ email_confirmed: 1 }); // Bootstrap a secret directly so we don't depend on the setup // handler's side effects. await server.clients.db.write( 'UPDATE `user` SET `otp_secret` = ? WHERE `uuid` = ?', ['TESTSECRETBASE32', user.uuid], ); await server.stores.user.invalidateById(user.id); const res = makeRes(); await controller.handleConfigure2fa( makeReq({}, { actor, params: { action: 'enable' } }), res, ); expect(res.body).toEqual({}); const after = await server.stores.user.getById(user.id, { force: true, }); expect(after!.otp_enabled).toBeTruthy(); }); it('disable-2fa: throws 404 when the user no longer exists', async () => { const { user, actor } = await makeUserAndActor(); await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleDisable2fa(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 404 }); }); }); describe('AuthController.handleGetDevProfile additional branches', () => { it('throws 404 when the actor has no matching user row', async () => { const { user, actor } = await makeUserAndActor(); await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleGetDevProfile(makeReq({}, { actor }), makeRes()), ).rejects.toMatchObject({ statusCode: 404 }); }); }); describe('AuthController group endpoints: additional branches', () => { it('group/remove-users: 400 on missing uid', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGroupRemoveUsers( makeReq({ users: ['x'] }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('group/remove-users: 400 on non-array users', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGroupRemoveUsers( makeReq({ uid: 'g-1' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('group/remove-users: 404 on unknown uid', async () => { const { actor } = await makeUserAndActor(); await expect( controller.handleGroupRemoveUsers( makeReq( { uid: `does-not-exist-${uuidv4()}`, users: [] }, { actor }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('group/remove-users: 403 when caller does not own the group', async () => { const { actor: a1 } = await makeUserAndActor(); const { actor: a2 } = await makeUserAndActor(); const createRes = makeRes(); await controller.handleGroupCreate( makeReq({}, { actor: a1 }), createRes, ); const { uid } = createRes.body as { uid: string }; await expect( controller.handleGroupRemoveUsers( makeReq({ uid, users: [] }, { actor: a2 }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 403 }); }); }); describe('AuthController.handleGetGuiToken / handleSessionSyncCookie additional branches', () => { it('get-gui-token: 404 when actor has a session but the user row is gone', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const sessionedActor = { ...actor, session: { uid: sessionUid }, } as Actor; // Pull the user row out from under the session. await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleGetGuiToken( makeReq({}, { actor: sessionedActor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('session/sync-cookie: 404 when actor has a session but the user row is gone', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const sessionedActor = { ...actor, session: { uid: sessionUid }, } as Actor; await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); const res = makeRes(); await controller.handleSessionSyncCookie( makeReq({}, { actor: sessionedActor }), res, ); expect(res.statusCode).toBe(404); }); }); describe('AuthController.handleSendConfirmEmail additional branches', () => { it('throws 404 when the actor user row no longer exists', async () => { const { user, actor } = await makeUserAndActor(); await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleSendConfirmEmail( makeReq({}, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); }); describe('AuthController.handleConfirmEmail additional branches', () => { it('throws 404 when the actor user row no longer exists', async () => { const { user, actor } = await makeUserAndActor(); await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [ user.id, ]); await server.stores.user.invalidateById(user.id); await expect( controller.handleConfirmEmail( makeReq({ code: '000000' }, { actor }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); }); describe('AuthController.handleRevokeSession additional branches', () => { it('successfully revokes the actor’s own session', async () => { const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const res = makeRes(); await controller.handleRevokeSession( makeReq({ uuid: sessionUid }, { actor }), res, ); const body = res.body as { sessions: unknown[] }; expect(Array.isArray(body.sessions)).toBe(true); }); it('refuses to revoke the caller’s OWN current session row (400)', async () => { // Invariant: a self-revoke leaves the client in an ambiguous // identity state because the response can't write fresh auth // state. /logout is the only path that should end the session // you're currently authenticated under. const { user, actor } = await makeUserAndActor(); const sessionRes = await server.services.auth.createSessionToken( user, {}, ); const sessionUid = (sessionRes.session as { uuid: string }).uuid; const actorWithSession = { ...actor, session: { uid: sessionUid }, } as Actor; await expect( controller.handleRevokeSession( makeReq({ uuid: sessionUid }, { actor: actorWithSession }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request', }); }); it('still allows revoking a DIFFERENT session belonging to the same user', async () => { // Sanity check that the self-revoke guard only blocks the // caller's own uuid — sibling rows must still be revokable // (that's the whole point of manage-sessions). const { user, actor } = await makeUserAndActor(); const callerSession = await server.services.auth.createSessionToken( user, {}, ); const targetSession = await server.services.auth.createSessionToken( user, {}, ); const actorWithSession = { ...actor, session: { uid: (callerSession.session as { uuid: string }).uuid, }, } as Actor; const res = makeRes(); await controller.handleRevokeSession( makeReq( { uuid: (targetSession.session as { uuid: string }).uuid }, { actor: actorWithSession }, ), res, ); expect((res.body as { sessions: unknown[] }).sessions).toBeDefined(); }); }); // ── handleMigrateToken ────────────────────────────────────────────── describe('AuthController.handleMigrateToken', () => { const TEST_ORIGIN = 'https://migrate.test.local'; // PuterServer keeps config in a private field (#config), so we go // through the controller — IController stores it as `protected // config` which TS marks but JS doesn't enforce, and the controller // is the actual consumer of #isMigrateTokenOriginAllowed anyway. const controllerConfig = () => (controller as { config: Record }).config; // Mints a v1-shaped JWT signed under the test server's legacy // secret. The body matches what migrateLegacyToken expects per // `decoded.type`. const mintV1Token = (payload: Record): string => { const legacy = controllerConfig().jwt_secret as string | undefined; if (!legacy) throw new Error('test config missing jwt_secret'); return jwt.sign(payload, legacy); }; beforeAll(() => { // Make the origin allow-check pass for these tests. We mutate // the live config because setupTestServer is shared across the // file; the original value is undefined (default config has no // `origin`) so we don't need to restore. controllerConfig().origin = TEST_ORIGIN; }); it('rejects when the Origin header is missing', async () => { await expect( controller.handleMigrateToken(makeReq({}), makeRes()), ).rejects.toMatchObject({ statusCode: 403 }); }); it('allows the exchange from an unlisted origin (apps live on arbitrary domains)', async () => { // puter.js apps run on any third-party domain; the v1 bearer // token is the credential, so the exchange itself is not // origin-gated — only cookie issuance is (next test). const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'access-token', token_uid: uuidv4(), user_uid: user.uuid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: 'https://some-app.example', authorization: `Bearer ${v1}`, }, }, ), res, ); expect((res.body as { kind: string }).kind).toBe('access_token'); expect((res.body as { token: string }).token).toBeTruthy(); }); it('does NOT set the puter_token_v2 cookie for an app token from an untrusted origin', async () => { // Cookie planting on the GUI origin is what the allowlist // prevents: an attacker page may exchange a token it already // holds, but must not be able to set a session cookie. const { user } = await makeUserAndActor(); const appUid = `app-${uuidv4()}`; const v1 = mintV1Token({ type: 'app-under-user', user_uid: user.uuid, app_uid: appUid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: 'https://some-app.example', authorization: `Bearer ${v1}`, }, }, ), res, ); expect((res.body as { kind: string }).kind).toBe('app'); expect((res.body as { token: string }).token).toBeTruthy(); expect(res.cookies.puter_token_v2).toBeUndefined(); }); it('normalizes trailing slash on the request Origin (B4)', async () => { // The Origin header per spec doesn't carry a trailing slash, but // a misconfigured proxy or a deployment with config.origin // ending in `/` would otherwise force every call to reject. const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'access-token', token_uid: uuidv4(), user_uid: user.uuid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: `${TEST_ORIGIN}/`, // trailing slash authorization: `Bearer ${v1}`, }, }, ), res, ); expect((res.body as { kind: string }).kind).toBe('access_token'); }); it('normalizes case on the request Origin (B4)', async () => { const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'access-token', token_uid: uuidv4(), user_uid: user.uuid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: TEST_ORIGIN.toUpperCase(), authorization: `Bearer ${v1}`, }, }, ), res, ); expect((res.body as { kind: string }).kind).toBe('access_token'); }); it('returns 409 reauth_required for v1 web/session tokens', async () => { // Web tokens never migrate silently — they always go through // the interactive reauth flow. The body code is what puter.js / // GUI key on; the 409 status is what tells SDK code "this // isn't a generic auth failure, route through reauth". const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'session', user_uid: user.uuid, uuid: uuidv4(), }); await expect( controller.handleMigrateToken( makeReq( {}, { headers: { origin: TEST_ORIGIN, authorization: `Bearer ${v1}`, }, }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409, code: 'reauth_required', }); }); it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => { // Access tokens are programmatic — they ride in Authorization // headers, not browser cookies. Setting a cookie here would // confuse cookie-only middleware downstream. const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'access-token', token_uid: uuidv4(), user_uid: user.uuid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: TEST_ORIGIN, authorization: `Bearer ${v1}`, }, }, ), res, ); expect(res.cookies.puter_token_v2).toBeUndefined(); expect((res.body as { kind: string }).kind).toBe('access_token'); expect((res.body as { token: string }).token).toBeTruthy(); }); it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => { // App tokens DO get a cookie companion — the app runs inside an // iframe in the GUI, and the GUI's cookie-only middleware // authenticates subsequent calls from the iframe via the cookie // rather than the client having to plumb Authorization through // every request. const { user } = await makeUserAndActor(); const appUid = `app-${uuidv4()}`; const v1 = mintV1Token({ type: 'app-under-user', user_uid: user.uuid, app_uid: appUid, }); const res = makeRes(); await controller.handleMigrateToken( makeReq( {}, { headers: { origin: TEST_ORIGIN, authorization: `Bearer ${v1}`, }, }, ), res, ); expect((res.body as { kind: string }).kind).toBe('app'); const cookie = res.cookies.puter_token_v2; expect(cookie).toBeDefined(); expect(cookie.value).toBe((res.body as { token: string }).token); expect(cookie.opts?.httpOnly).toBe(true); }); }); // -- auth_id preservation on forced re-login -- describe('AuthController auth_id preservation on reauth', () => { const password = 'correct-horse-battery'; const mintReauth = (uuid: string): string => server.services.auth.signReauthToken(uuid); it('handleLogin with matching reauth_token completes login as the same user', async () => { const u = `aid_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.1`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); const res = makeRes(); await controller.handleLogin( makeReq( { username: u, password, reauth_token: mintReauth(seeded!.uuid), }, { ip }, ), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); expect((res.body as { user: { uuid: string } }).user.uuid).toBe( seeded!.uuid, ); }); it('handleLogin with mismatched reauth_token is rejected 409', async () => { const a = `aida_${Math.random().toString(36).slice(2, 10)}`; const b = `aidb_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.2`; await controller.handleSignup( makeReq( { username: a, email: `${a}@test.local`, password }, { ip }, ), makeRes(), ); await controller.handleSignup( makeReq( { username: b, email: `${b}@test.local`, password }, { ip }, ), makeRes(), ); const userB = await server.stores.user.getByUsername(b); await expect( controller.handleLogin( makeReq( { username: a, password, reauth_token: mintReauth(userB!.uuid), }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 409, fields: { code: 'auth_id_mismatch' }, }); }); it('handleLogin with reauth_token for an unknown user returns 404', async () => { const u = `aidu_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.3`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); await expect( controller.handleLogin( makeReq( { username: u, password, reauth_token: mintReauth(uuidv4()), }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('handleLogin with a non-string reauth_token returns 400', async () => { const u = `aidb_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.4`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); await expect( controller.handleLogin( makeReq({ username: u, password, reauth_token: 42 }, { ip }), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('handleLogin with a forged/garbage reauth_token returns 401', async () => { const u = `aidf_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.9`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); await expect( controller.handleLogin( makeReq( { username: u, password, reauth_token: 'not-a-jwt' }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); it('handleLogin OTP branch echoes auth_id into the OTP JWT', async () => { const u = `aidotp_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.5`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await server.stores.user.update(seeded!.id, { otp_enabled: 1, otp_secret: 'TESTSECRETBASE32', }); const res = makeRes(); await controller.handleLogin( makeReq( { username: u, password, reauth_token: mintReauth(seeded!.uuid), }, { ip }, ), res, ); expect(res.statusCode).toBe(202); const body = res.body as { otp_jwt_token: string }; const decoded = server.services.token.verify( 'otp', body.otp_jwt_token, ) as { user_uid: string; auth_id?: string; }; expect(decoded.auth_id).toBe(seeded!.uuid); }); it('handleSignup is_temp + matching reauth_token returns the SAME temp user', async () => { const tempRes1 = makeRes(); const ip = `127.0.${Math.floor(Math.random() * 200)}.6`; await controller.handleSignup( makeReq({ is_temp: true }, { ip }), tempRes1, ); const body1 = tempRes1.body as { user: { uuid: string } }; const tempUuid = body1.user.uuid; const tempUser1 = await server.stores.user.getByUuid(tempUuid); expect(tempUser1).toBeTruthy(); const markerId = tempUser1!.id; const tempRes2 = makeRes(); await controller.handleSignup( makeReq( { is_temp: true, reauth_token: mintReauth(tempUuid) }, { ip }, ), tempRes2, ); expect(isCompleteLoginResponse(tempRes2.body)).toBe(true); const body2 = tempRes2.body as { user: { uuid: string; is_temp: boolean }; }; expect(body2.user.uuid).toBe(tempUuid); expect(body2.user.is_temp).toBe(true); const tempUser2 = await server.stores.user.getByUuid(tempUuid); expect(tempUser2!.id).toBe(markerId); }); it('handleSignup is_temp + reauth_token pointing at a permanent user is rejected', async () => { const u = `aidperm_${Math.random().toString(36).slice(2, 10)}`; const ip = `127.0.${Math.floor(Math.random() * 200)}.7`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); await expect( controller.handleSignup( makeReq( { is_temp: true, reauth_token: mintReauth(seeded!.uuid), }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('handleSignup is_temp + reauth_token for an unknown user returns 404', async () => { const ip = `127.0.${Math.floor(Math.random() * 200)}.8`; await expect( controller.handleSignup( makeReq( { is_temp: true, reauth_token: mintReauth(uuidv4()), }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 404 }); }); it('handleSignup is_temp rejects a forged reauth_token (401)', async () => { const ip = `127.0.${Math.floor(Math.random() * 200)}.10`; await expect( controller.handleSignup( makeReq( { is_temp: true, reauth_token: 'not-a-jwt' }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 401 }); }); it('rate-limits reauth_token login attempts per IP', async () => { const ip = `10.99.${Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 200)}`; const u = `aidrl_${Math.random().toString(36).slice(2, 10)}`; await controller.handleSignup( makeReq( { username: u, email: `${u}@test.local`, password }, { ip }, ), makeRes(), ); const seeded = await server.stores.user.getByUsername(u); for (let i = 0; i < 5; i++) { const res = makeRes(); await controller.handleLogin( makeReq( { username: u, password, reauth_token: mintReauth(seeded!.uuid), }, { ip }, ), res, ); expect(isCompleteLoginResponse(res.body)).toBe(true); } await expect( controller.handleLogin( makeReq( { username: u, password, reauth_token: mintReauth(seeded!.uuid), }, { ip }, ), makeRes(), ), ).rejects.toMatchObject({ statusCode: 429 }); }); });