test(e2e): make nine red specs assert the contracts the code actually implements

The e2e suite had never been run during this audit. It failed 9 of 256; seven of those
predated the audit's changes, established by building a stack from a clean HEAD worktree
and running the same specs against it rather than guessing.

Most were stale assertions rather than product defects:

- quota.spec solved for a target limit using the observed uploader count, but the divisor is
  max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it
  aimed for came out 100x small and every "within quota" upload 413'd.
- rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with
  `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a
  release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was
  never reached; it now does a real release and asserts 200 rather than "not 429".
- ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence
  it exercises: four tickets per session survive and the rest correctly 401. Now asserts
  exactly four, which a tightened cap or an inverted eviction order would catch.
- auth-tampering asserted a throttled IP is refused EVEN with the correct password. That
  contract was deliberately removed — it let any phone on the venue NAT lock the operator
  out of their own admin panel, with a circular escape hatch. Inverted, plus a new check
  that a success does not refill an attacker's bucket.
- moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters
  banned authors, so it is hidden from everyone including the host. Now pins the pair that
  matters — the ban hides it, and the host's permanent removal survives an unban — and the
  UI leg it used to own is restored as a separate test on a reachable comment.

The export specs mint with `?kind=` now that a download ticket is bound to one archive, and
four of them assert the mint's 404 rather than the download's: with the kind always known,
the pre-check refuses up front instead of after charging a daily download for an archive
that cannot be served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:48 +02:00
parent a53729a704
commit 32dfe6874a
19 changed files with 351 additions and 88 deletions

View File

@@ -108,31 +108,25 @@ test.describe('Host — moderation from the UI', () => {
).toBeVisible({ timeout: 10_000 });
});
test('a host can remove the comment of a guest they have already banned', async ({
test('a host removes a guest comment through the lightbox, via the confirm sheet', async ({
page,
api,
host,
guest,
signIn,
}) => {
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
// on the author gets 403 on their own delete, so if the host has no removal affordance
// the comment is stuck on screen forever.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
// uploads, so if the comment sat on their own photo the whole card would vanish and
// there would be nothing left to moderate.
const victim = await guest('PhotoOwner');
// The UI leg of host comment moderation, and the ONLY test that clicks it. The affordance is
// rendered solely by LightboxModal (`$isStaff` gates the trash button, and it routes through a
// ConfirmSheet rather than deleting on first tap). Without this, `pendingCommentDelete` could
// stop being wired to the sheet's onConfirm, or the staff gate could invert, and every
// remaining comment-moderation test would still pass — they all call the API directly.
//
// The author is NOT banned here, deliberately. A ban hides the comment from every reader
// including the host (see the next test), so a banned author's comment is unreachable in the
// UI by construction and cannot exercise this path.
const victim = await guest('LightboxPhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
await api.banUser(host.jwt, author.userId);
// Confirm the deadlock really exists — the author cannot retract it themselves.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
const author = await guest('LightboxCommenter');
await seedComment(author.jwt, uploadId, 'bitte entfernen');
await signIn(page, host);
await page.goto('/feed');
@@ -141,9 +135,88 @@ test.describe('Host — moderation from the UI', () => {
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const comment = page.getByText('unangebrachter Kommentar');
const comment = page.getByText('bitte entfernen');
await expect(comment).toBeVisible({ timeout: 10_000 });
// "entfernen" (host removing someone else's) rather than "löschen" (deleting your own) —
// the aria-label distinguishes them and the host must get the host one.
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
// It must NOT delete on first tap; the comment is still there behind the sheet.
await expect(comment).toBeVisible();
await page.getByTestId('confirm-sheet-confirm').click();
await expect(comment).toHaveCount(0, { timeout: 10_000 });
});
test('banning hides a comment for everyone, and the host can still delete it permanently', async ({
page,
api,
host,
guest,
signIn,
}) => {
// This used to assert that the host could remove a banned author's comment FROM THE FEED,
// on the premise that a ban leaves the comment "stuck on screen forever". That premise no
// longer holds: `Comment::list_for_upload` filters `NOT u.is_banned`, so a ban hides the
// comment from every reader — host included — which is why there was nothing on screen to
// click. The export and hashtag-count queries already filtered banned authors, so this
// brought the live read path in line with them.
//
// But hiding is derived AT READ TIME, and a ban is reversible. Unbanning a guest — because
// the host was hasty, or the guest apologised — would republish the abusive comment. So the
// property worth pinning is the pair: the ban hides it immediately, and the host's permanent
// removal outlives the ban.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN uploads,
// so if the comment sat on their own photo the whole card would vanish with it.
const victim = await guest('PhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
const listFor = async (jwt: string) =>
(await (
await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
headers: { Authorization: `Bearer ${jwt}` },
})
).json()) as Array<{ id: string }>;
expect(
(await listFor(host.jwt)).map((c) => c.id),
'before the ban the comment is live'
).toContain(commentId);
await api.banUser(host.jwt, author.userId);
// The author cannot retract it themselves — so removal has to be the host's to make.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
// Gone for the host and the photo's owner alike, with no further action.
expect((await listFor(host.jwt)).map((c) => c.id)).not.toContain(commentId);
expect((await listFor(victim.jwt)).map((c) => c.id)).not.toContain(commentId);
// ...and gone from the rendered feed, which is what the host actually looks at.
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: victim.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
await expect(page.getByText('unangebrachter Kommentar')).toHaveCount(0);
// The permanent removal the host still needs: soft-delete survives an unban, so letting the
// guest back in does not republish what they were banned for.
const removed = await fetch(`${BASE}/api/v1/host/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(removed.status, 'the host can delete a banned authors comment outright').toBe(204);
await api.unbanUser(host.jwt, author.userId);
expect(
(await listFor(host.jwt)).map((c) => c.id),
'an unban must not resurrect a comment the host deleted'
).not.toContain(commentId);
});
});