/** * USER_JOURNEYS §4 — the "I forgot my PIN" in-app request lifecycle, end to end. * * This whole journey had ZERO functional coverage (only the 403 gate, from the authz sweep). The * invariants below are all security-relevant and none of them were tested: * - the always-204 non-enumeration contract (an unknown name must look identical to a known one) * - the per-IP+name throttle (3 / 15 min) * - admins are EXCLUDED from the request queue (they recover via password, not PIN) * - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out) */ import { test, expect } from '../../fixtures/test'; import { BASE } from '../../helpers/env'; const requestReset = (displayName: string) => fetch(`${BASE}/api/v1/recover/request`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: displayName }), }); test.describe('PIN reset — in-app request lifecycle', () => { test('a request for a real guest queues exactly one request the host can see', async ({ api, host, guest, db, }) => { const g = await guest('ForgetfulFrida'); expect((await requestReset('ForgetfulFrida')).status).toBe(204); // The host sees it... const list = await api.listPinResetRequests(host.jwt); expect(list.some((r: any) => r.user_id === g.userId)).toBe(true); // ...and it's deduped: a second request does not stack a duplicate (ON CONFLICT DO NOTHING). expect((await requestReset('ForgetfulFrida')).status).toBe(204); expect(await db.countPinResetRequestsForUser(g.userId)).toBe(1); }); test('an UNKNOWN name is indistinguishable from a known one (204, no queue, no enumeration)', async ({ host, api, guest, }) => { // Same status code and no error body as the real path — the only observable difference must be // in the host's queue, which the requester cannot see. const known = await guest('KnownKarl'); expect((await requestReset('KnownKarl')).status).toBe(204); expect((await requestReset('no-such-guest-' + Date.now())).status).toBe(204); // The unknown name queued nothing; the known one queued exactly one. const list = await api.listPinResetRequests(host.jwt); expect(list).toHaveLength(1); expect(list[0].user_id).toBe(known.userId); }); test('an ADMIN never gets a reset request queued (admins recover via password)', async ({ api, host, db, }) => { // The admin user exists (the host fixture logs an admin in to create it). Its display name is "Admin" by // convention; request a reset for it and confirm the queue stays empty — the INSERT filters // `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak. const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin'); expect(admin, 'an admin user must exist').toBeTruthy(); expect((await requestReset(admin.display_name)).status).toBe(204); expect(await db.countPinResetRequestsForUser(admin.id)).toBe(0); expect(await api.listPinResetRequests(host.jwt)).toHaveLength(0); }); test('requests are throttled to 3 per 15 min per IP+name', async ({ api, adminToken, guest }) => { await api.patchConfig(adminToken, { rate_limits_enabled: 'true' }); await guest('ThrottleTarget'); const statuses: number[] = []; for (let i = 0; i < 5; i++) statuses.push((await requestReset('ThrottleTarget')).status); // First 3 accepted, the rest throttled — proving the limiter is real and keyed. expect(statuses.filter((s) => s === 204)).toHaveLength(3); expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0); // Restore so the next test's fixtures aren't affected (rate limits share the backend). await api.patchConfig(adminToken, { rate_limits_enabled: 'false' }); }); test('a host PIN reset revokes the targets sessions and clears the request', async ({ api, host, guest, db, }) => { const g = await guest('CompromisedCarl'); await requestReset('CompromisedCarl'); // The guest has a live session (the join minted one) and a queued request. expect(await db.countSessionsForUser(g.userId)).toBeGreaterThan(0); expect(await db.countPinResetRequestsForUser(g.userId)).toBe(1); // Host resets the PIN. await api.resetUserPin(host.jwt, g.userId); // The old device is logged out: the guest's pre-reset JWT no longer authenticates. This is the // security point of a reset — sessions are token-bound, so without the revoke the compromised // device would stay logged in with full access despite the new PIN. const stale = await fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${g.jwt}` }, }); expect(stale.status, 'the pre-reset session must be revoked').toBe(401); expect(await db.countSessionsForUser(g.userId)).toBe(0); // And the pending request was resolved. expect(await db.countPinResetRequestsForUser(g.userId)).toBe(0); }); });