diff --git a/e2e/specs/04-host/moderation-ui.spec.ts b/e2e/specs/04-host/moderation-ui.spec.ts new file mode 100644 index 0000000..33a7cd0 --- /dev/null +++ b/e2e/specs/04-host/moderation-ui.spec.ts @@ -0,0 +1,149 @@ +/** + * 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 can remove the comment of a guest they have already banned', 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'); + 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); + + 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('unangebrachter Kommentar'); + await expect(comment).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click(); + await expect(comment).toHaveCount(0, { timeout: 10_000 }); + }); +}); diff --git a/frontend/src/lib/components/LightboxModal.svelte b/frontend/src/lib/components/LightboxModal.svelte index fa1d159..8c76170 100644 --- a/frontend/src/lib/components/LightboxModal.svelte +++ b/frontend/src/lib/components/LightboxModal.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api'; import { onSseEvent } from '$lib/sse'; import { getUserId } from '$lib/auth'; + import { isStaff } from '$lib/role-store'; import { dataMode, pickMediaUrl } from '$lib/data-mode-store'; import { doubletap } from '$lib/actions/doubletap'; import { focusTrap } from '$lib/actions/focus-trap'; @@ -114,9 +115,15 @@ } } - async function deleteComment(id: string) { + /** + * `asHost` routes to the moderation endpoint. The guest route only ever deletes the + * caller's OWN comment, and it refuses a banned author outright — so without this a + * host who banned an abusive guest was left with the abuse still on screen and no way + * to remove it, since the ban itself blocks the author's own delete. + */ + async function deleteComment(id: string, asHost: boolean) { try { - await api.delete(`/comment/${id}`); + await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`); comments = comments.filter((c) => c.id !== id); } catch (e) { toastError(e); @@ -246,11 +253,11 @@ {formatTime(comment.created_at)} - {#if comment.user_id === userId} + {#if comment.user_id === userId || $isStaff}