Some checks failed
CI / Lint, typecheck, test (push) Failing after 1m39s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m51s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s
Token-authenticated machine access at /api/public/v1 — the foundation for the built-in MCP endpoint (#105). Personal access tokens: - api_tokens table (SHA-256 hash, scope read|write, optional pond restriction, expiry, revocation, throttled last-used) + migration; secrets are dt_pat_<random>, shown exactly once - lifecycle endpoints under /users/me/api-tokens (session-only — a leaked token can never mint more tokens) with audit entries api.token_created/api.token_revoked - settings UI section (create with scope/expiry/pond restriction, one-time reveal with copy, list with status + revoke), de+en Activation (404 semantics per #60 on both levels): - instance setting api.enabled (default off, admin settings switch) - pond setting apiEnabled (default off, pond settings toggle; the PondsService settings-merge learned the key — the #92 lesson) Surface (/api/public/v1, excluded from the SPA's global prefix): - me, ponds, pages (list/read as Markdown+HTML, create from Markdown via the shared pipeline, PATCH title/content, DELETE to trash), search (permission-filtered + narrowed to exposed ponds, highlights as **…**), markdown ZIP export, labels (tree, create/rename/recolour/move/delete, assign/unassign), comments (threads, create, resolve/reopen) - content replacement travels the collab-owned document path: the new state lands as a MANUAL version "API update", then the established restore NOTIFY applies it — open editors converge, history stays append-only, no second lineage (VersionsService.replaceContent) - hand-maintained OpenAPI 3.1 document at /openapi.json, pinned to the controller by a route-coverage test in both directions Enforcement: - PublicApiGuard: instance switch → bearer PAT auth (request.user is the token's user) → per-token rate limit (429 + Retry-After) → scope (403 scope_required) → pond opt-in + token restriction - the shared PermissionGuard then applies the unchanged permission model; PageParamSource gained pondSlugParam for the slug+slug routes - no cookies anywhere → no CSRF surface (pinned by a hostile-Origin test) - every write audit-logged as api.write with the token attributed Tests/verification: - 12-test e2e pack: lifecycle, switches, permission matrix (reader/editor/outsider × scopes), restriction, page roundtrip incl. restore-NOTIFY assertion, labels, comments incl. policy, search narrowing, ZIP export, rate limit; full api suite 60/60 green (quota fixture via per-user override — never the instance default) - new collab-pack test proves an open editor converges onto an API content replacement (green against a local seeded stack) - UI smoke against the built SPA: token create/reveal/revoke, pond opt-in persists, admin switch persists (10/10) - docs/self-hosting/public-api.md + README link Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
165 lines
7.2 KiB
TypeScript
165 lines
7.2 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { BrowserContext, Page } from '@playwright/test';
|
|
|
|
import { contextForUser } from './helpers';
|
|
|
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> {
|
|
const ponds = await context.request.get('/api/v1/ponds');
|
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
|
return { id: pond.id, slug: pond.slug };
|
|
}
|
|
|
|
/** Opens the page in edit mode and waits for the live connection to be up. */
|
|
async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise<Page> {
|
|
const page = await context.newPage();
|
|
await page.goto(`/p/${pondSlug}/${slug}`);
|
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
|
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
|
|
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
|
|
timeout: 15000,
|
|
});
|
|
return page;
|
|
}
|
|
|
|
test('two browsers editing one page converge (the milestone headline)', async ({ browser }) => {
|
|
// fixture-user owns the pond; fixture-admin is a site admin and may modify it,
|
|
// so both receive a read-write collab token under the interim access model.
|
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const pond = await personalPond(owner);
|
|
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Collab Converge ${Date.now()}` },
|
|
});
|
|
const { slug } = await created.json();
|
|
|
|
const pageA = await openEditor(owner, pond.slug, slug);
|
|
const pageB = await openEditor(admin, pond.slug, slug);
|
|
const editorA = pageA.locator('.ProseMirror');
|
|
const editorB = pageB.locator('.ProseMirror');
|
|
|
|
await editorA.click();
|
|
await pageA.keyboard.type('AAA from owner ');
|
|
await expect(editorB).toContainText('AAA from owner', { timeout: 10000 });
|
|
|
|
await editorB.click();
|
|
await pageB.keyboard.type('BBB from admin ');
|
|
await expect(editorA).toContainText('BBB from admin', { timeout: 10000 });
|
|
|
|
await owner.close();
|
|
await admin.close();
|
|
});
|
|
|
|
test('offline edits continue locally and sync on reconnect', async ({ browser }) => {
|
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const pond = await personalPond(owner);
|
|
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Collab Offline ${Date.now()}` },
|
|
});
|
|
const { slug } = await created.json();
|
|
|
|
const pageA = await openEditor(owner, pond.slug, slug);
|
|
const pageB = await openEditor(admin, pond.slug, slug);
|
|
const editorA = pageA.locator('.ProseMirror');
|
|
const editorB = pageB.locator('.ProseMirror');
|
|
|
|
// Admin drops offline: the indicator reflects it and editing stays local.
|
|
await admin.setOffline(true);
|
|
await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'offline', {
|
|
timeout: 10000,
|
|
});
|
|
await editorB.click();
|
|
await pageB.keyboard.type('written while offline ');
|
|
await expect(editorB).toContainText('written while offline');
|
|
// The owner, still online, has not received the offline edit.
|
|
await expect(editorA).not.toContainText('written while offline');
|
|
|
|
// Back online: the provider reconnects (re-fetching a fresh token) and the
|
|
// offline edit converges to the other participant.
|
|
await admin.setOffline(false);
|
|
await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
|
|
timeout: 20000,
|
|
});
|
|
await expect(editorA).toContainText('written while offline', { timeout: 20000 });
|
|
|
|
await owner.close();
|
|
await admin.close();
|
|
});
|
|
|
|
test('remote carets and the presence strip reflect participants (#37)', async ({ browser }) => {
|
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const ownerMe = (await (await owner.request.get('/api/v1/auth/me')).json()) as {
|
|
displayName: string;
|
|
};
|
|
const pond = await personalPond(owner);
|
|
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Collab Presence ${Date.now()}` },
|
|
});
|
|
const { slug } = await created.json();
|
|
|
|
const pageA = await openEditor(owner, pond.slug, slug);
|
|
const pageB = await openEditor(admin, pond.slug, slug);
|
|
|
|
// Both participants appear in each presence strip.
|
|
await expect(pageA.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
|
|
await expect(pageB.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
|
|
|
|
// The owner's cursor shows up as a named caret in the admin's editor.
|
|
await pageA.locator('.ProseMirror').click();
|
|
await pageA.keyboard.type('cursor is here');
|
|
await expect(pageB.locator('.collab-caret__label')).toHaveText(ownerMe.displayName, {
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Disconnecting the owner drops them from the admin's presence strip.
|
|
await owner.close();
|
|
await expect(pageB.locator('.presence-avatar')).toHaveCount(1, { timeout: 15000 });
|
|
|
|
await admin.close();
|
|
});
|
|
|
|
// Read-only participants (live changes visible, typing blocked) and the live
|
|
// read-write→read-only downgrade need real grants, so they live in the
|
|
// `collab-permissions` pack (issue #53) alongside the grant setup they require.
|
|
|
|
test('a public-API content replacement converges an open editor (#104)', async ({ browser }) => {
|
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const pond = await personalPond(owner);
|
|
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `API Replace ${Date.now()}` },
|
|
});
|
|
const { slug } = await created.json();
|
|
|
|
// Expose the surface: instance switch (Site Admin) + pond opt-in (owner),
|
|
// then mint a write token for the owner.
|
|
await admin.request.patch('/api/v1/admin/settings', { data: { 'api.enabled': true } });
|
|
await owner.request.patch(`/api/v1/ponds/${pond.id}`, { data: { apiEnabled: true } });
|
|
const minted = await owner.request.post('/api/v1/users/me/api-tokens', {
|
|
data: { name: `collab-e2e-${Date.now()}`, scope: 'write' },
|
|
});
|
|
const { token } = await minted.json();
|
|
|
|
const pageA = await openEditor(owner, pond.slug, slug);
|
|
const editorA = pageA.locator('.ProseMirror');
|
|
await editorA.click();
|
|
await pageA.keyboard.type('typed live before the API replace');
|
|
await expect(editorA).toContainText('typed live before the API replace');
|
|
|
|
// Replace the whole content through the public API: the change travels
|
|
// over the collab-owned restore path, so the open editor converges.
|
|
const replaced = await owner.request.patch(`/api/public/v1/ponds/${pond.slug}/pages/${slug}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
data: { markdown: 'Replaced through the public API.' },
|
|
});
|
|
expect(replaced.ok()).toBeTruthy();
|
|
await expect(editorA).toContainText('Replaced through the public API.', { timeout: 15000 });
|
|
await expect(editorA).not.toContainText('typed live before the API replace');
|
|
|
|
await owner.close();
|
|
await admin.close();
|
|
});
|