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( ctx: APIRequestContext, method: 'post', path: string, data: unknown, ): Promise { 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 => ((await (await ctx.request.get('/api/v1/auth/me')).json()) as { id: string }).id; const tokenStatus = async (ctx: BrowserContext, pageId: string): Promise => (await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`)).status(); const tokenMode = async (ctx: BrowserContext, pageId: string): Promise => { 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(); });