Files
EventSnap/e2e/specs/04-host/moderation-ui.spec.ts
fabi 32dfe6874a 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>
2026-08-11 22:44:48 +02:00

223 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression guard — a host must be able to remove a guest's content FROM THE UI.
*
* `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were fully implemented,
* transactional, SSE-broadcasting, audit-logged — and had zero frontend callers. The feed's
* context sheet offered "Löschen" only when `target.user_id === myUserId`, so the only lever
* a host actually had against an unwanted photo was banning the uploader. That is both
* disproportionate and ineffective: a ban doesn't retract what was already posted, and
* because the ban check runs BEFORE the ownership check on the guest delete route, banning
* the author makes their abusive comment permanently undeletable by them too.
*
* The API side was already covered (04-host/moderation). What was missing is the wiring,
* so these tests drive the real UI.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Host — moderation from the UI', () => {
test("a host removes a guest's photo via the feed context sheet", async ({
page,
host,
guest,
signIn,
}) => {
const g = await guest('PhotoOffender');
const uploadId = await seedUpload(g.jwt);
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: g.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
const remove = page.getByRole('button', { name: /beitrag entfernen/i });
await expect(remove, 'a host must be offered a removal action on a guest post').toBeVisible();
await remove.click();
const sheet = page.getByTestId('confirm-sheet');
await expect(sheet).toBeVisible();
// Moderation copy, not "delete my post" copy.
await expect(sheet).toContainText(/beitrag entfernen/i);
await page.getByTestId('confirm-sheet-confirm').click();
await expect(card).not.toBeVisible({ timeout: 10_000 });
// And it is really gone server-side, not just dropped from the local list.
const res = await fetch(`${BASE}/api/v1/feed`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
const body = await res.json();
expect(body.uploads.some((u: { id: string }) => u.id === uploadId)).toBe(false);
});
test('a guest is NOT offered any delete action on someone elses post', async ({
page,
guest,
signIn,
}) => {
// The mirror that makes the test above meaningful: if this affordance rendered for
// everyone, the host test would still pass on a build that shipped moderation to guests.
const author = await guest('SomeAuthor');
await seedUpload(author.jwt);
const viewer = await guest('NosyViewer');
await signIn(page, viewer);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(page.getByRole('button', { name: /beitrag entfernen/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^löschen$/i })).toHaveCount(0);
});
test('a promoted guest gets host powers without signing out and back in', async ({
page,
api,
adminToken,
guest,
signIn,
}) => {
// The JWT is never reissued — the backend slides the session row forward and treats the
// DB row as authoritative. So the token of a promoted guest still claims `role: guest`
// for up to 30 days. The UI read that frozen claim, which meant a guest promoted at the
// party saw no Host-Dashboard and no moderation actions until they signed out and back
// in — while `/me/context` had been handing the client the real role all along.
const g = await guest('LatePromotion');
await signIn(page, g);
await page.goto('/account');
await expect(page.getByRole('link', { name: /host-dashboard/i })).toHaveCount(0);
// Promote mid-session. The token in localStorage is deliberately NOT refreshed.
await api.setRole(adminToken, g.userId, 'host');
const claim = JSON.parse(Buffer.from(g.jwt.split('.')[1], 'base64').toString());
expect(
claim.role,
'the token must still carry the stale claim for this to prove anything'
).toBe('guest');
await page.reload();
await expect(
page.getByRole('link', { name: /host-dashboard/i }),
'the live role from /me/context must win over the frozen JWT claim'
).toBeVisible({ timeout: 10_000 });
});
test('a host removes a guest comment through the lightbox, via the confirm sheet', async ({
page,
host,
guest,
signIn,
}) => {
// 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('LightboxCommenter');
await seedComment(author.jwt, uploadId, 'bitte entfernen');
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();
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);
});
});