/** * USER_JOURNEYS.md §9 — ban / unban / promote / demote. Driven mostly through * the API because the host dashboard UI changes shape often; integration * coverage of the buttons lives in a separate UI-focused spec. */ import { test, expect } from '../../fixtures/test'; import { seedUpload, seedComment } from '../../helpers/seed'; test.describe('Host — moderation API', () => { test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({ api, host, guest }) => { // Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint // takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone). const target = await guest('Banned2'); await api.banUser(host.jwt, target.userId); const users = await api.listUsers(host.jwt); const row = users.find((u: any) => u.id === target.userId); expect(row?.is_banned).toBe(true); expect(row?.uploads_hidden).toBe(true); }); test('banned user cannot call /upload', async ({ api, host, guest }) => { const target = await guest('Banned3'); await api.banUser(host.jwt, target.userId); // Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing. const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', { method: 'POST', headers: { Authorization: `Bearer ${target.jwt}` }, body: new FormData(), }); expect(res.status).toBe(403); }); test('host can promote a guest to host', async ({ api, host, guest }) => { const target = await guest('Promoted'); await api.setRole(host.jwt, target.userId, 'host'); const users = await api.listUsers(host.jwt); const row = users.find((u: any) => u.id === target.userId); expect(row?.role).toBe('host'); }); }); /** * Regression for the review's H1: role & ban used to be trusted from the JWT and * never re-checked against the DB, so a demoted/banned host kept full powers for * the life of their token (up to 30d) and a banned host could even unban * themselves. The auth extractor now re-reads the live user row, so these take * effect on the *existing* session with no re-login. */ test.describe('Host — live role/ban revocation (H1)', () => { test('a demoted host loses host powers on their existing session', async ({ api, adminToken, guest, }) => { const u = await guest('DemoteMidSession'); await api.setRole(adminToken, u.userId, 'host'); // Fresh host token (role is encoded at mint time). const { body } = await api.recover(u.displayName, u.pin); const hostJwt = body.jwt; // Sanity: the token currently has host powers. await api.listUsers(hostJwt); // Demote via admin — the host does NOT re-login. await api.setRole(adminToken, u.userId, 'guest'); // Same token is now rejected with exactly 403 (RequireHost sees the DB role, // not the JWT claim). Asserting the status guards against a spurious 500 // masquerading as "revoked". await expect(api.listUsers(hostJwt)).rejects.toThrow(/→ 403/); }); test('a banned host is locked out immediately on their existing session', async ({ api, adminToken, host, }) => { // Sanity: host token works. await api.listUsers(host.jwt); await api.banUser(adminToken, host.userId); // Banned users are rejected with 403 by the auth extractor before any handler runs. await expect(api.listUsers(host.jwt)).rejects.toThrow(/→ 403/); }); test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => { await api.banUser(adminToken, host.userId); await expect( api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] }) ).rejects.toThrow(/→ 403/); }); // H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is // rejected with 403. The ban is enforced on write handlers + Require{Host,Admin}, // NOT in the base extractor (which would wrongly block reads too). test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => { const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const target = await guest('BannedRW'); const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); // Seed content the target OWNS *before* the ban, so the delete/edit paths get // past the ownership check and it's genuinely the ban guard being exercised. const ownUpload = await seedUpload(target.jwt, { caption: 'mine' }); const ownComment = await seedComment(target.jwt, ownUpload, 'my comment'); await api.banUser(host.jwt, target.userId); // Reads still succeed. const read = await fetch(base + '/api/v1/me/context', { headers: auth(target.jwt) }); expect(read.status).toBe(200); // Every write is rejected — cover ALL guest-reachable mutations, not just // /upload. delete_upload / delete_comment / edit_upload previously skipped the // ban guard (a banned owner could still delete/edit their own content). const create = await fetch(base + '/api/v1/upload', { method: 'POST', headers: auth(target.jwt), body: new FormData(), }); expect(create.status).toBe(403); const delUpload = await fetch(base + `/api/v1/upload/${ownUpload}`, { method: 'DELETE', headers: auth(target.jwt), }); expect(delUpload.status).toBe(403); const editUpload = await fetch(base + `/api/v1/upload/${ownUpload}`, { method: 'PATCH', headers: { ...auth(target.jwt), 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: 'edited while banned' }), }); expect(editUpload.status).toBe(403); const delComment = await fetch(base + `/api/v1/comment/${ownComment}`, { method: 'DELETE', headers: auth(target.jwt), }); expect(delComment.status).toBe(403); // The upload survived every blocked write — the delete was rejected, so the row still // exists (deleted_at IS NULL). We check the DB directly rather than the feed: ban now // ALWAYS hides, so a banned user's own upload is (correctly) filtered out of every feed // even though the row is intact. Read access to *other* content is proven by the 200 on // /me/context above. expect(await db.countUploadsForUser(target.userId)).toBe(1); }); });