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