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>
59 lines
2.4 KiB
TypeScript
59 lines
2.4 KiB
TypeScript
/**
|
|
* "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);
|
|
});
|
|
});
|