dorfteich/apps/web/e2e/permission-matrix.spec.ts
Claude Opus 5 30fd1ff53b
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m26s
CI / Auth e2e pack (pull_request) Failing after 6m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 1m20s
#302: the permission matrix counts the start page
Every pond created through the api now carries one, and the matrix pond
is created that way. The start page is an ordinary page with no grant of
its own, so it follows the pond-wide permissions: the three member
subjects each see one more, the label-restricted editor too, and the
outsider — who reaches only the explicitly public page — still sees one.

The 429 in the same run was the login rate limit, reached through the
retries of this failure rather than on its own.
2026-08-01 08:25:51 +02:00

245 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 M2M5 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<T>(ctx: APIRequestContext, path: string, data: unknown): Promise<T> {
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<number> =>
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<string | number> => {
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<number> => {
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<string> =>
((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 subjects 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<number> => {
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<number> =>
c
.post(`/api/v1/pages/${pageId}/comments`, { data: { body: 'matrix probe' } })
.then((r) => r.status());
const list = (c: APIRequestContext, pageId: string): Promise<number> =>
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
});