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
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
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
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<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 subject’s 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
|
||
});
|