import { expect, request, test } from '@playwright/test'; import type { APIRequestContext, BrowserContext } from '@playwright/test'; import { contextForUser } from './helpers'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** * Cross-feature permission hardening matrix (issue #60). Permissions cut across * every M2–M5 feature; this pack pins the security-relevant subject × surface * combinations so a weakened guard is caught. It is intentionally API-level * (the UI adds nothing over the resolved status codes) and covers the 404-vs-403 * policy: an unauthorized read reads as 404 (existence hidden), an unauthorized * write on something readable is 403. * * Subjects: site admin, pond admin (owner), editor, label-restricted editor * (same account, blocked on a "secret" label), reader, public (anonymous), * foreign (signed-in non-member). Surfaces: page read, edit (collab token * mode), sidebar list, search, versions, media, public HTML — and, since * issue #96, comments: reading follows page read, writing follows the pond's * `commentPolicy` (readers | editors), both under the same 404-vs-403 policy. */ interface Fixture { owner: BrowserContext; editor: BrowserContext; reader: BrowserContext; outsider: BrowserContext; admin: BrowserContext; anon: APIRequestContext; pondId: string; pondSlug: string; normalPage: { id: string; slug: string }; secretPage: { id: string; slug: string }; publicPage: { id: string; slug: string }; mediaId: string; } let f: Fixture; async function json(ctx: APIRequestContext, path: string, data: unknown): Promise { const res = await ctx.post(path, { data }); if (!res.ok()) throw new Error(`POST ${path} → ${res.status()} ${await res.text()}`); return (await res.json()) as T; } const status = (ctx: APIRequestContext, path: string): Promise => ctx.get(path).then((r) => r.status()); /** Collab-token access as a single label: 'rw' | 'ro' | the HTTP status. */ const tokenMode = async (ctx: APIRequestContext, id: string): Promise => { const res = await ctx.get(`/api/v1/pages/${id}/collab-token`); return res.ok() ? ((await res.json()) as { mode: string }).mode : res.status(); }; /** Number of pages the sidebar list surfaces (or the HTTP status on denial). */ const listCount = async (ctx: APIRequestContext, pondId: string): Promise => { const res = await ctx.get(`/api/v1/ponds/${pondId}/pages`); return res.ok() ? ((await res.json()) as unknown[]).length : res.status(); }; test.beforeAll(async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); const reader = await contextForUser(browser, BASE_URL, 'fixture-viewer'); const outsider = await contextForUser(browser, BASE_URL, 'fixture-outsider'); const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); const anon = await request.newContext({ baseURL: BASE_URL }); const idOf = async (c: BrowserContext): Promise => ((await (await c.request.get('/api/v1/auth/me')).json()) as { id: string }).id; const editorId = await idOf(editor); const pond = await json<{ id: string; slug: string }>(owner.request, '/api/v1/ponds', { name: `Matrix ${Date.now()}`, }); await json(owner.request, `/api/v1/ponds/${pond.id}/members`, { usernameOrEmail: 'fixture-editor', role: 'editor', }); await json(owner.request, `/api/v1/ponds/${pond.id}/members`, { usernameOrEmail: 'fixture-viewer', role: 'reader', }); const secretLabel = await json<{ id: string }>(owner.request, `/api/v1/ponds/${pond.id}/labels`, { name: 'secret', }); const mk = async (title: string): Promise<{ id: string; slug: string }> => json(owner.request, `/api/v1/ponds/${pond.id}/pages`, { title }); const normalPage = await mk('Normal'); const secretPage = await mk('Secret'); const publicPage = await mk('Public'); await json(owner.request, `/api/v1/pages/${secretPage.id}/labels`, { labelId: secretLabel.id }); // The editor is blocked on secret-labelled pages; the public page is open to all. await json(owner.request, `/api/v1/ponds/${pond.id}/grants`, { subjectType: 'user', subjectId: editorId, role: 'editor', scopeType: 'label', scopeId: secretLabel.id, effect: 'deny', }); await json(owner.request, `/api/v1/ponds/${pond.id}/grants`, { subjectType: 'public', role: 'reader', scopeType: 'page', scopeId: publicPage.id, effect: 'allow', }); // A version on the normal page (history = write permission) and one attachment. await json(owner.request, `/api/v1/pages/${normalPage.id}/versions`, { label: 'v1' }); // A tiny real PNG — uploads are restricted to image types. const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', ); const upload = await owner.request.post(`/api/v1/ponds/${pond.id}/files`, { multipart: { file: { name: 'a.png', mimeType: 'image/png', buffer: png } }, }); if (!upload.ok()) throw new Error(`upload failed: ${upload.status()} ${await upload.text()}`); const mediaId = ((await upload.json()) as { id: string }).id; f = { owner, editor, reader, outsider, admin, anon, pondId: pond.id, pondSlug: pond.slug, normalPage, secretPage, publicPage, mediaId, }; }); test.afterAll(async () => { await Promise.all([ f.owner.close(), f.editor.close(), f.reader.close(), f.outsider.close(), f.admin.close(), f.anon.dispose(), ]); }); test('page read & edit — the 404-vs-403 policy holds per subject', async () => { const req = (c: BrowserContext | APIRequestContext) => ('request' in c ? c.request : c); // Normal (member-only) page. expect(await status(req(f.admin), `/api/v1/pages/${f.normalPage.id}`)).toBe(200); expect(await tokenMode(req(f.admin), f.normalPage.id)).toBe('rw'); expect(await tokenMode(req(f.owner), f.normalPage.id)).toBe('rw'); expect(await tokenMode(req(f.editor), f.normalPage.id)).toBe('rw'); expect(await tokenMode(req(f.reader), f.normalPage.id)).toBe('ro'); // reader: readable, not writable expect(await status(req(f.outsider), `/api/v1/pages/${f.normalPage.id}`)).toBe(404); // foreign hidden expect(await tokenMode(req(f.outsider), f.normalPage.id)).toBe(404); // Secret page: the label-deny hides it from the editor, reader still reads it. expect(await tokenMode(req(f.editor), f.secretPage.id)).toBe(404); // deny → hidden expect(await tokenMode(req(f.reader), f.secretPage.id)).toBe('ro'); expect(await tokenMode(req(f.owner), f.secretPage.id)).toBe('rw'); // Public page: open to the anonymous visitor and the foreign user alike. expect(await tokenMode(f.anon, f.publicPage.id)).toBe('ro'); expect(await tokenMode(req(f.outsider), f.publicPage.id)).toBe('ro'); }); test('sidebar list is filtered to each subject’s visible pages', async () => { // Three fixture pages plus the pond's own start page (issue #302), which // every pond created through the api now carries. It is an ordinary page // with no grant of its own, so it follows the pond-wide permissions: the // outsider, who reaches only the explicitly public page, still sees one. expect(await listCount(f.admin.request, f.pondId)).toBe(4); expect(await listCount(f.owner.request, f.pondId)).toBe(4); expect(await listCount(f.reader.request, f.pondId)).toBe(4); expect(await listCount(f.editor.request, f.pondId)).toBe(3); // secret hidden expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page // Anonymous cannot hit the authenticated list endpoint at all. expect(await listCount(f.anon, f.pondId)).toBe(401); }); test('search is permission-filtered; anonymous cannot search', async () => { const hits = async (c: APIRequestContext): Promise => { const res = await c.get(`/api/v1/search?q=Secret&pondId=${f.pondId}`); return res.ok() ? ((await res.json()) as unknown[]).length : res.status(); }; expect(await hits(f.owner.request)).toBeGreaterThan(0); // owner finds "Secret" expect(await hits(f.editor.request)).toBe(0); // editor denied that page → no hit expect(await hits(f.anon)).toBe(401); }); test('versions require write; media follows page read; public HTML honours grants', async () => { const versions = (c: APIRequestContext, id: string) => status(c, `/api/v1/pages/${id}/versions`); expect(await versions(f.owner.request, f.normalPage.id)).toBe(200); expect(await versions(f.editor.request, f.normalPage.id)).toBe(200); expect(await versions(f.reader.request, f.normalPage.id)).toBe(403); // readable, not writable → 403 expect(await versions(f.outsider.request, f.normalPage.id)).toBe(404); // not readable → 404 // Media is permission-checked: a member reads the pond's attachment. (The // "no grant → 404" negative for media lives in the public pack, issue #56.) expect(await status(f.owner.request, `/api/v1/media/${f.mediaId}`)).toBe(200); expect(await status(f.reader.request, `/api/v1/media/${f.mediaId}`)).toBe(200); // Public HTML endpoint: only the public page, only where the grant reaches. expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.publicPage.slug}`)).toBe(200); expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.normalPage.slug}`)).toBe(404); }); test('comments follow page read plus the pond comment policy (issue #96)', async () => { const post = (c: APIRequestContext, pageId: string): Promise => c .post(`/api/v1/pages/${pageId}/comments`, { data: { body: 'matrix probe' } }) .then((r) => r.status()); const list = (c: APIRequestContext, pageId: string): Promise => status(c, `/api/v1/pages/${pageId}/comments`); // Default policy (readers): every reader writes, hidden pages stay hidden. expect(await post(f.owner.request, f.normalPage.id)).toBe(201); expect(await post(f.editor.request, f.normalPage.id)).toBe(201); expect(await post(f.reader.request, f.normalPage.id)).toBe(201); expect(await post(f.outsider.request, f.normalPage.id)).toBe(404); expect(await post(f.anon, f.normalPage.id)).toBe(401); // The label-restricted editor cannot even see the secret page's thread. expect(await post(f.editor.request, f.secretPage.id)).toBe(404); expect(await list(f.editor.request, f.secretPage.id)).toBe(404); expect(await list(f.reader.request, f.normalPage.id)).toBe(200); // Editors-only policy: readable-but-barred is an explicit 403. const patched = await f.owner.request.patch(`/api/v1/ponds/${f.pondId}`, { data: { commentPolicy: 'editors' }, }); expect(patched.ok()).toBe(true); expect(await post(f.reader.request, f.normalPage.id)).toBe(403); expect(await post(f.editor.request, f.normalPage.id)).toBe(201); expect(await post(f.outsider.request, f.normalPage.id)).toBe(404); expect(await list(f.reader.request, f.normalPage.id)).toBe(200); // reading stays open });