dorfteich/apps/web/e2e/permission-matrix.spec.ts
Claude Opus 4.8 ae8cdd0e1e
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m36s
CI / Auth e2e pack (push) Successful in 3m38s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Add cross-feature permission hardening matrix (#60)
Permissions cut across every M2–M5 feature; this pack pins the security-relevant
subject × surface combinations so a weakened guard is caught.

- `apps/web/e2e/permission-matrix.spec.ts`: an API-level (the UI adds nothing
  over the resolved status code) parameterized suite over the subjects — site
  admin, pond admin/owner, editor, the same editor label-restricted by a
  `secret`-label deny, reader, public (anonymous), and the foreign user (new
  `fixture-outsider`, a member of nothing) — across the surfaces: page read,
  edit (collab-token `rw`/`ro`), sidebar list, search, versions, media, and the
  public HTML endpoint. It enforces the 404-vs-403 policy: an unauthorized read
  is 404 (existence hidden), an unauthorized write on something readable is 403.
- wired into the pipeline as its own CI step; documented in
  `apps/web/e2e/README.md` (with the subject/surface list) so later features
  extend the matrix rather than writing bespoke permission tests.
- seeded-regression check (acceptance criterion): temporarily forcing the
  collab-token to always `rw` (ignoring write permission) makes the pack go red
  on the "reader gets `ro`" and public/foreign cells — verified locally, then
  reverted. Runs in ~1 s (well under the 10-minute budget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:43:16 +02:00

209 lines
8.8 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.
*/
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);
});