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>
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
/**
|
||
* 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 else’s 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 author’s 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);
|
||
});
|
||
});
|