dorfteich/apps/web/e2e/collab-permissions.spec.ts
Claude Opus 4.8 9d288b2ad0
All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Wire real permissions into collab tokens and revocation (#53)
Live editing now obeys the same rules as REST: the collab-token mode comes
from the shared grant resolution, anonymous visitors can join public pages,
and revoking write access flips a running session to read-only within
seconds.

- Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but
  still permission-guarded, so a logged-out visitor gets an `ro` token where
  a `public` grant makes the page readable (404 otherwise). The token's
  `userId` is nullable (shared schema + collab context) for anonymous
  subjects.
- Prompt revocation: the pond-level NOTIFY (#39) now also fires on label
  tree/assignment changes (LabelsService move/remove/assign/unassign), and
  the collab server closes the *actual* WebSocket instead of only sending an
  application-level close message. Hocuspocus' `closeConnections` leaves the
  socket open so the client only re-checks on its ~30s message timeout;
  `closeDocumentConnections` drops the socket so the client reconnects and
  re-authenticates with a freshly-resolved token at once — the "within
  seconds" downgrade the milestone promises.
- Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`,
  none → 404); a collab db test proves an editor downgraded to reader goes
  read-only on reconnect (its post-downgrade edits no longer reach a peer);
  a new browser `collab-permissions` pack covers the read-only participant
  and the live downgrade end to end (new plain `fixture-editor` account).

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

131 lines
5.7 KiB
TypeScript

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<string> {
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<string> {
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<Page> {
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();
});