dorfteich/apps/web/e2e/permission-matrix.spec.ts
Claude Fable 5 6d51c0d099
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
Pin the comments/notifications semantics as a regression pack (#96)
The permission-matrix pack now covers comments: reading follows page
read, writing follows the pond's commentPolicy, label-restricted editors
cannot see a secret page's thread, all under the 404-vs-403 policy. A
new API-level social pack runs the two-user watch → notify flows: the
watcher is notified, the actor never, read-all sticks server-side, a
revoked watcher receives nothing new, and the digest job runs end to end
through the system panel's manual trigger. The digest mail's structure
is pinned by a normalized vitest snapshot (grouping, counts, actors,
unsubscribe framing) — changing the mail requires an explicit snapshot
update. Both packs ran flaky-free across five consecutive local rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 00:00:34 +02:00

241 lines
11 KiB
TypeScript
Raw Permalink 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 () => {
expect(await listCount(f.admin.request, f.pondId)).toBe(3);
expect(await listCount(f.owner.request, f.pondId)).toBe(3);
expect(await listCount(f.reader.request, f.pondId)).toBe(3);
expect(await listCount(f.editor.request, f.pondId)).toBe(2); // 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
});