dorfteich/apps/web/e2e/access-rules.spec.ts
Claude Opus 4.8 406886c56c
Some checks failed
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Failing after 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
Add label- and page-scope access rules UI including deny (#55)
Pond Admins configure the vision's fine-grained cases through a plain-language
surface, on top of the base roles from #54.

- shared: `AccessRuleView` (a grant enriched with subject/scope display names)
  and pure conflict helpers `scopeSpecificity`/`sameGrantSubject`/
  `isRuleShadowed` (unit-tested) for the client-side shadowed-rule hint. New
  `access` i18n namespace (de+en) with sentence templates (ADR 0012).
- api: `GET /ponds/:id/grants/access-rules` (Pond-Admin) returns the pond's
  grants enriched with each user's display name and each label/page scope's
  name, resolved in one batched query per kind.
- web `access/`: `AccessRulesManager` in Pond Settings — the pond's rules
  grouped by subject and rendered as readable de/en sentences ("Anna may not
  edit pages labeled “Confidential”"), an add form (subject = member or the
  `signed-in`/`public` pseudo-subjects; scope = label from the tree or a
  specific page; role; allow/deny) that warns when a rule would be shadowed by
  a more specific existing one (shared algorithm) and requires an explicit
  confirmation before granting anything to `public`. Semantics are the shared
  resolver's — the UI only reflects permissions.md.
- tests: shared `conflicts.test.ts`; an api db case for the enriched endpoint;
  a browser `access-rules` pack that configures BOTH vision patterns through
  the UI and verifies their effect end to end — "deny label X" (an editor
  loses a labelled page) and "only label Y" (a signed-in non-member, new
  `fixture-viewer`, reads only the labelled pages) — plus the shadow hint and
  the public confirmation, with its own CI step.

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

170 lines
6.8 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();
});