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
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
220 lines
8.6 KiB
TypeScript
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();
|
|
});
|