dorfteich/apps/web/e2e/access-rules.spec.ts
Claude Opus 4.8 f5f1310eb2
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Add effective-permissions inspector (#57)
Pond Admins can answer "what can X actually see/do here?" — load-bearing for
trust in the grant system (permissions.md §UI obligations).

- shared: `explainPageCapability` — the resolver's decision chain (deciding
  level + the single deciding grant), sharing one code path with the boolean
  `resolvePageCapability` (now a thin wrapper), so the trace can never diverge
  from real access. Unit-tested against the permissions.md worked examples.
- api: `GET /ponds/:id/effective-permissions?subjectType=&subjectId=&pageId=`
  (Pond-Admin-gated, one pond only) resolves as the chosen subject (a user with
  their real Site-Admin flag, all signed-in users, or the public), optionally
  against a page, and returns the read + write outcome with the deciding rule
  enriched with subject/scope names.
- web: `EffectivePermissionsInspector` in Pond Settings — pick a subject and
  optionally a page → see the resolved read/edit verdict, the level that
  decided it, and the deciding rule spelled out as a de/en sentence (reusing
  the #55 sentence renderer). Hidden from non-admins.
- tests: explain-mode unit tests (worked examples + trace-matches-boolean);
  `inspector.e2e.db.test.ts` (deciding rule on a labelled page, pond-level base
  capability, public default-closed, Pond-Admin gating); a browser assertion in
  the access-rules pack.

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

220 lines
8.6 KiB
TypeScript

import { expect, 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';
/**
* Fine-grained access rules (issue #55): both vision patterns are configured
* through the pond-settings UI and their effect is verified end to end —
* "deny label X" (an editor loses a labelled page) and "only label Y" (a
* signed-in non-member reads only the labelled pages). Also covers the
* shadowed-rule hint and the public confirmation.
*/
async function json<T>(
ctx: APIRequestContext,
method: 'post',
path: string,
data: unknown,
): Promise<T> {
const res = await ctx[method](path, { data });
if (!res.ok()) throw new Error(`${method} ${path}${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
const meId = async (ctx: BrowserContext): Promise<string> =>
((await (await ctx.request.get('/api/v1/auth/me')).json()) as { id: string }).id;
const tokenStatus = async (ctx: BrowserContext, pageId: string): Promise<number> =>
(await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`)).status();
const tokenMode = async (ctx: BrowserContext, pageId: string): Promise<string | number> => {
const res = await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`);
return res.ok() ? ((await res.json()) as { mode: string }).mode : res.status();
};
test('both vision patterns are configurable in the UI and take effect', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer');
const editorId = await meId(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', {
name: `Access ${Date.now()}`,
});
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const labelX = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Confidential',
},
);
const labelY = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Handbook',
},
);
const px = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Secret',
});
const py = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Guide',
});
const pz = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Misc',
});
await json(owner.request, 'post', `/api/v1/pages/${px.id}/labels`, { labelId: labelX.id });
await json(owner.request, 'post', `/api/v1/pages/${py.id}/labels`, { labelId: labelY.id });
// Baseline: the editor may write every page.
expect(await tokenMode(editor, px.id)).toBe('rw');
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// Pattern 1 — "deny label X": the editor may not touch Confidential pages.
await page.locator('.rule-add__subject').selectOption(`user:${editorId}`);
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(labelX.id);
await page.locator('.rule-add__role').selectOption('editor');
await page.locator('.rule-add__effect').selectOption('deny');
await page.locator('.rule-add__submit').click();
await expect(page.locator('.rule-sentence', { hasText: 'Confidential' })).toBeVisible();
await expect.poll(() => tokenStatus(editor, px.id)).toBe(404); // labelled page now hidden
expect(await tokenMode(editor, pz.id)).toBe('rw'); // unlabelled page still editable
// Pattern 2 — "only label Y": signed-in users read only Handbook pages.
await page.locator('.rule-add__subject').selectOption('authenticated');
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(labelY.id);
await page.locator('.rule-add__role').selectOption('reader');
await page.locator('.rule-add__effect').selectOption('allow');
await page.locator('.rule-add__submit').click();
await expect(page.locator('.rule-sentence', { hasText: 'Handbook' })).toBeVisible();
// A signed-in non-member reads the labelled page, nothing else.
await expect.poll(() => tokenMode(viewer, py.id)).toBe('ro');
expect(await tokenStatus(viewer, px.id)).toBe(404);
expect(await tokenStatus(viewer, pz.id)).toBe(404);
await owner.close();
await editor.close();
await viewer.close();
});
test('hints at shadowed rules and confirms before going public', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const editorId = await meId(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', {
name: `Access2 ${Date.now()}`,
});
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const label = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Docs',
},
);
const pg = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Page',
});
// A more specific existing rule (page-scope deny) will shadow a label-scope allow.
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: editorId,
role: 'editor',
scopeType: 'page',
scopeId: pg.id,
effect: 'deny',
});
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// Shadowed: a label-scope allow for the same editor, opposite to the page deny.
await page.locator('.rule-add__subject').selectOption(`user:${editorId}`);
await page.locator('.rule-add__scope-type').selectOption('label');
await page.locator('.rule-add__scope-target').selectOption(label.id);
await page.locator('.rule-add__role').selectOption('editor');
await page.locator('.rule-add__effect').selectOption('allow');
await expect(page.locator('.rule-add__shadow')).toBeVisible();
// Public + allow: a confirmation gate blocks submission until acknowledged.
await page.locator('.rule-add__subject').selectOption('public');
await expect(page.locator('.rule-add__public-warning')).toBeVisible();
await expect(page.locator('.rule-add__submit')).toBeDisabled();
await page.locator('.rule-add__public-confirm').check();
await expect(page.locator('.rule-add__submit')).toBeEnabled();
await owner.close();
await editor.close();
});
test('the effective-permissions inspector shows the outcome and deciding rule', async ({
browser,
}) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const editorId = await meId(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', {
name: `Inspect ${Date.now()}`,
});
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const label = await json<{ id: string }>(
owner.request,
'post',
`/api/v1/ponds/${pond.id}/labels`,
{
name: 'Confidential',
},
);
const page = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, {
title: 'Salaries',
});
await json(owner.request, 'post', `/api/v1/pages/${page.id}/labels`, { labelId: label.id });
// Pond-wide editor minus a label-scope deny on Confidential.
await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: editorId,
role: 'editor',
scopeType: 'label',
scopeId: label.id,
effect: 'deny',
});
const view = await owner.newPage();
await view.goto(`/p/${pond.slug}/settings`);
await view.locator('.inspector__subject').selectOption(`user:${editorId}`);
await view.locator('.inspector__page').selectOption(page.id);
// Editing is denied, decided at the label level, and the rule is spelled out.
const write = view.locator('.inspector__outcome[data-capability="write"]');
await expect(write).toHaveClass(/inspector__outcome--deny/);
await expect(write.locator('.inspector__deciding')).toContainText('Confidential');
await owner.close();
await editor.close();
});