import { expect, test } from '@playwright/test'; import type { APIRequestContext, BrowserContext, Page } from '@playwright/test'; import { contextForUser } from './helpers'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** * Live-collab permission behaviour in the real browser stack (issue #53): a * reader holds a read-only collab token (the editor surface is not writable, * yet live edits still stream in), and revoking an editor's write access flips * their running session to read-only within seconds — the milestone's * revocation promise. The precise revocation *mechanism* (a real WebSocket * close so the client reconnects and re-authenticates at once) is unit-proven * against a real Hocuspocus server in * `apps/collab/src/access-listener.db.test.ts`. * * These need a second regular (non site-admin) account so one user can be a * plain reader/editor of another user's pond: fixture-user owns the pond and is * its Pond Admin, fixture-editor is the participant whose access we vary. */ async function userId(api: APIRequestContext): Promise { const me = await api.get('/api/v1/auth/me'); return ((await me.json()) as { id: string }).id; } /** Creates a fresh shared pond with one page, owned by `owner`. */ async function sharedPondWithPage( owner: BrowserContext, ): Promise<{ pondId: string; pondSlug: string; pageSlug: string }> { const pondRes = await owner.request.post('/api/v1/ponds', { data: { name: `Collab Perms ${Date.now()}` }, }); const pond = (await pondRes.json()) as { id: string; slug: string }; const pageRes = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: `Shared Page ${Date.now()}` }, }); const page = (await pageRes.json()) as { slug: string }; return { pondId: pond.id, pondSlug: pond.slug, pageSlug: page.slug }; } /** Grants `role` on the pond to `subjectId`; returns the new grant's id. */ async function grant( owner: BrowserContext, pondId: string, subjectId: string, role: 'reader' | 'editor', ): Promise { const res = await owner.request.post(`/api/v1/ponds/${pondId}/grants`, { data: { subjectType: 'user', subjectId, role, scopeType: 'pond', effect: 'allow' }, }); if (!res.ok()) throw new Error(`grant ${role} failed: ${res.status()} ${await res.text()}`); return ((await res.json()) as { id: string }).id; } /** Opens the page in edit mode and waits for the live connection to be up. */ async function openConnectedEditor( context: BrowserContext, pondSlug: string, slug: string, ): Promise { const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${slug}`); // Select by class, not button text: the fixture-editor account's display // name ("Fixture Editor") would also match an /edit/i name filter. await page.locator('.editor-page__mode-toggle').click(); await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { timeout: 15000, }); return page; } test('a reader participant sees live edits but cannot type (issue #53)', async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const readerId = await userId(reader.request); const { pondId, pondSlug, pageSlug } = await sharedPondWithPage(owner); await grant(owner, pondId, readerId, 'reader'); const ownerPage = await openConnectedEditor(owner, pondSlug, pageSlug); const readerPage = await openConnectedEditor(reader, pondSlug, pageSlug); // The reader holds an `ro` token, so the editor surface is not editable even // in "edit" mode (mode toggle is available to everyone; writing is gated). await expect(readerPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false'); // But live changes from the owner still stream in — read-only, not offline. await ownerPage.locator('.ProseMirror').click(); await ownerPage.keyboard.type('owner writes for the reader '); await expect(readerPage.locator('.ProseMirror')).toContainText('owner writes for the reader', { timeout: 10000, }); await owner.close(); await reader.close(); }); test('downgrading a live editor to reader flips the session to read-only (issue #53)', async ({ browser, }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); const editorUserId = await userId(editor.request); const { pondId, pondSlug, pageSlug } = await sharedPondWithPage(owner); const editorGrantId = await grant(owner, pondId, editorUserId, 'editor'); const editorPage = await openConnectedEditor(editor, pondSlug, pageSlug); // As an editor they may type. await expect(editorPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); await editorPage.locator('.ProseMirror').click(); await editorPage.keyboard.type('typed while still an editor '); // The Pond Admin downgrades them: add a reader grant first (so access never // fully lapses), then remove the editor grant. Each grant change emits the // pond-level NOTIFY; the collab server closes the affected socket and the // client reconnects, re-acquiring a token that now resolves to `ro`. await grant(owner, pondId, editorUserId, 'reader'); const del = await owner.request.delete(`/api/v1/ponds/${pondId}/grants/${editorGrantId}`); expect(del.status()).toBe(204); // Within seconds the running session becomes read-only — no full reload, no // manual action by the downgraded user. await expect(editorPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false', { timeout: 15000, }); await owner.close(); await editor.close(); });