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

@@ -56,4 +56,42 @@ test.describe('Media gating — moderation revokes preview access (F2)', () => {
const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`);
expect(afterDelete.status, 'moderation must revoke preview access').toBe(404);
});
test('the preview is revoked when the UPLOADER is banned (not just on delete)', async ({
host,
api,
guest,
}) => {
test.setTimeout(30_000);
// The header of this file claims delete AND ban-hide both revoke access, but only delete was
// ever exercised. A ban hides the user's content everywhere (the visibility check filters
// `is_banned` inside `find_by_id_visible`, which gates preview AND thumbnail identically), and
// its whole point is that a direct-URL holder loses the image — so it must 404 the gated
// preview too, exactly like a delete.
const offender = await guest('BannedUploader');
const id = await seedUpload(offender.jwt, { caption: 'to be hidden' });
// Wait for the compression worker to produce the preview.
await expect
.poll(
async () => {
const row = (await api.getFeed(host.jwt)).uploads?.find((u: any) => u.id === id);
return row?.preview_url;
},
{ timeout: 20_000, intervals: [500] }
)
.toBe(`/api/v1/upload/${id}/preview`);
// Served while the uploader is in good standing.
expect((await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status).toBe(200);
// Ban the uploader (default: hide their uploads).
await api.banUser(host.jwt, offender.userId);
// Must now 404 — the direct-URL holder loses the image, same as a takedown.
expect(
(await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status,
"a banned uploader's preview must be revoked"
).toBe(404);
});
});