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
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
131 lines
5.7 KiB
TypeScript
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();
|
|
});
|