test(e2e): cover the untested security lifecycles (PIN reset, logout-all, ban gating, caption)

Closes the coverage gaps the audit flagged and I verified were real:

  pin-reset-lifecycle.spec.ts (new): the whole "I forgot my PIN" journey (§4) had only its
    403 gate tested. Now: the always-204 non-enumeration contract (unknown name looks
    identical to known); dedup (ON CONFLICT); admins are EXCLUDED from the queue; the
    3-per-15-min throttle; and a host reset REVOKES the target's sessions (the pre-reset JWT
    401s afterward) and clears the request. Sessions are validated per-request against the
    DB, so the revoke is observable.

  logout-everywhere.spec.ts (new): DELETE /sessions ("sign out everywhere") had ZERO tests.
    Proves it revokes ALL of the caller's sessions across two devices (both tokens 401 after),
    not just the current one, and doesn't touch another user's sessions.

  media-gating: the file claimed delete AND ban-hide both revoke preview access, but only
    delete was exercised. Add the ban case — a banned uploader's gated preview 404s, same as
    a takedown. (Thumbnail shares the identical find_by_id_visible gate; the seed fixture
    produces no thumbnail derivative, so preview is the honest thing to assert.)

  export caption test: an edit after release regenerates the viewer (epoch bumps) while the
    ZIP is carried forward, not rebuilt — guards the fix in the previous commit.

Helpers: api.listPinResetRequests; db.countSessionsForUser / countPinResetRequestsForUser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 19:48:31 +02:00
parent db7c4459d7
commit c48d43f5b3
6 changed files with 277 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
/**
* "Sign out everywhere" — DELETE /api/v1/sessions. A security control (revoke every device after a
* lost/stolen phone) that had ZERO coverage. Sessions are validated per-request against the DB
* (auth middleware resolves the token hash to a live session row), so revocation is observable: a
* revoked token must stop authenticating.
*/
import { test, expect } from '../../fixtures/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const ctx = (jwt: string) =>
fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } });
test.describe('Auth — sign out everywhere', () => {
test('DELETE /sessions revokes ALL of the caller\'s sessions, not just the current one', async ({
api,
guest,
db,
}) => {
// Two devices for one guest: the join session, plus a second session from a recover login.
const g = await guest('MultiDevice');
const second = await api.recover(g.displayName, g.pin);
const secondJwt = second.body.jwt;
// Both tokens work, and there really are two distinct sessions.
expect((await ctx(g.jwt)).status).toBe(200);
expect((await ctx(secondJwt)).status).toBe(200);
expect(await db.countSessionsForUser(g.userId)).toBeGreaterThanOrEqual(2);
// Sign out everywhere, authenticated as device one.
const res = await fetch(`${BASE}/api/v1/sessions`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(res.status).toBe(204);
// BOTH devices are now logged out — the whole point of the control. If it only killed the
// caller's own session, device two would still be live and a stolen phone would keep access.
expect((await ctx(g.jwt)).status, 'the calling device is signed out').toBe(401);
expect((await ctx(secondJwt)).status, 'the OTHER device is signed out too').toBe(401);
expect(await db.countSessionsForUser(g.userId)).toBe(0);
});
test('one user signing out everywhere does not touch another user\'s sessions', async ({
guest,
}) => {
const a = await guest('SignsOut');
const b = await guest('StaysIn');
await fetch(`${BASE}/api/v1/sessions`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${a.jwt}` },
});
expect((await ctx(a.jwt)).status).toBe(401);
expect((await ctx(b.jwt)).status, "another user's session must be untouched").toBe(200);
});
});

View File

@@ -0,0 +1,119 @@
/**
* 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';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
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,
adminToken,
}) => {
// The admin user exists (adminToken logged them in). Their 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);
});
});