/** * 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); }); });