All checks were successful
CD / Build and push images (push) Successful in 2m51s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
The collaboration server becomes the writer of page state (ADR 0003, realtime-collaboration.md §lifecycle): - onLoadDocument reconstructs a page's Y.Doc from PostgreSQL by applying `pages.ydoc_state` and then every `page_updates` row in order, so a page with a long update log loads correctly. - onStoreDocument persists debounced (2 s, max 30 s): it appends the delta since the last flush to `page_updates`, periodically merges the log back into `ydoc_state` (inline threshold; the session-aware compaction of idle pages remains the separate job, #40), refreshes `page_content_cache` (plain text / Markdown / HTML / outline via the shared derivation, #24), bumps `pages.updated_at`, and keeps `Attachment.pageId` pointed at the embedding page (#31). Each flush runs in one transaction and its duration is logged. - The document size ceiling (MAX_PAGE_DOCUMENT_BYTES) is enforced on store: an oversize document is not persisted and the clients are notified with a stateless error so they can revert. Persistence is an injected port (PagePersistence): the Postgres implementation is covered by a DB-backed test (store/load round-trip, content-cache refresh, a 1000-entry update log, size-ceiling rejection, not-found), and the hook wiring — two-client sync, survival across a server restart, and the size-ceiling stateless notification — by an integration test using an in-memory fake. The collab package gains its own vitest setup that provisions an isolated `_collab` test database. The REST `PUT /pages/:id/state` write path stays in place for now and is retired (410) together with switching the editor to live collaboration in #36, so the deployed editor is never left unable to save between the two deploys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import { HocuspocusProvider } from '@hocuspocus/provider';
|
|
import type { CollabTokenClaims } from '@dorfteich/shared';
|
|
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
|
import { pino } from 'pino';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import * as Y from 'yjs';
|
|
|
|
import { createCollabServer } from './server.js';
|
|
import { freePort } from './testing/free-port.js';
|
|
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
|
|
|
|
const secret = 'integration-test-secret-32-chars!!';
|
|
const logger = pino({ enabled: false });
|
|
|
|
/** Poll `predicate` until it is true or the timeout elapses. */
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
throw new Error('timed out waiting for condition');
|
|
}
|
|
|
|
describe('collab authentication', () => {
|
|
let server: ReturnType<typeof createCollabServer>;
|
|
let url: string;
|
|
|
|
beforeAll(async () => {
|
|
server = createCollabServer({
|
|
version: 'test',
|
|
logger,
|
|
tokenSecret: secret,
|
|
pingDatabase: async () => ({ ok: true }),
|
|
persistence: new InMemoryPagePersistence(),
|
|
});
|
|
const port = await freePort();
|
|
await server.listen(port);
|
|
url = `ws://127.0.0.1:${port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.destroy();
|
|
});
|
|
|
|
function connect(
|
|
name: string,
|
|
token: string,
|
|
): {
|
|
provider: HocuspocusProvider;
|
|
doc: Y.Doc;
|
|
authenticated: () => boolean;
|
|
failed: () => boolean;
|
|
synced: () => boolean;
|
|
} {
|
|
const doc = new Y.Doc();
|
|
let authenticated = false;
|
|
let failed = false;
|
|
let synced = false;
|
|
// No WebSocketPolyfill: the provider falls back to Node's global WebSocket.
|
|
const provider = new HocuspocusProvider({
|
|
url,
|
|
name,
|
|
token,
|
|
document: doc,
|
|
onAuthenticated: () => {
|
|
authenticated = true;
|
|
},
|
|
onAuthenticationFailed: () => {
|
|
failed = true;
|
|
},
|
|
onSynced: () => {
|
|
synced = true;
|
|
},
|
|
});
|
|
return {
|
|
provider,
|
|
doc,
|
|
authenticated: () => authenticated,
|
|
failed: () => failed,
|
|
synced: () => synced,
|
|
};
|
|
}
|
|
|
|
it('accepts a valid token whose page matches the document', async () => {
|
|
const token = signCollabToken({ userId: 'u1', pageId: 'page-ok', mode: 'rw' }, secret, 60);
|
|
const client = connect('page-ok', token);
|
|
await waitFor(client.authenticated);
|
|
expect(client.authenticated()).toBe(true);
|
|
expect(client.failed()).toBe(false);
|
|
client.provider.destroy();
|
|
client.doc.destroy();
|
|
});
|
|
|
|
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' };
|
|
const rejectionCases: { label: string; name: string; token: () => string }[] = [
|
|
{
|
|
label: 'an expired token',
|
|
name: 'page-x',
|
|
token: () => signCollabToken(claims, secret, -60),
|
|
},
|
|
{
|
|
label: 'a tampered token',
|
|
name: 'page-x',
|
|
token: () => {
|
|
const [h, p, s = ''] = signCollabToken(claims, secret, 60).split('.');
|
|
// Flip the last character of the signature.
|
|
const flipped = s.slice(0, -1) + (s.endsWith('A') ? 'B' : 'A');
|
|
return `${h}.${p}.${flipped}`;
|
|
},
|
|
},
|
|
{
|
|
label: 'a token minted for a different page',
|
|
name: 'page-y',
|
|
token: () => signCollabToken(claims, secret, 60),
|
|
},
|
|
{
|
|
label: 'a token signed with the wrong secret',
|
|
name: 'page-x',
|
|
token: () => signCollabToken(claims, 'a-completely-different-secret-32c', 60),
|
|
},
|
|
];
|
|
|
|
it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
|
|
const client = connect(name, token());
|
|
await waitFor(client.failed);
|
|
expect(client.failed()).toBe(true);
|
|
expect(client.authenticated()).toBe(false);
|
|
client.provider.destroy();
|
|
client.doc.destroy();
|
|
});
|
|
|
|
it('lets a read-only client receive updates but discards its writes server-side', async () => {
|
|
const page = 'page-shared';
|
|
const rw = connect(
|
|
page,
|
|
signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
|
|
);
|
|
const ro = connect(
|
|
page,
|
|
signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60),
|
|
);
|
|
await waitFor(() => rw.synced() && ro.synced());
|
|
|
|
const rwMap = rw.doc.getMap<string>('m');
|
|
const roMap = ro.doc.getMap<string>('m');
|
|
|
|
// A write from the read-write client reaches the read-only client.
|
|
rw.doc.transact(() => rwMap.set('fromRw', 'a'));
|
|
await waitFor(() => roMap.get('fromRw') === 'a');
|
|
|
|
// The read-only client writes; the server must drop it.
|
|
ro.doc.transact(() => roMap.set('fromRo', 'b'));
|
|
// Barrier: a second rw write round-trips to ro; by the time it arrives the
|
|
// server has already processed (and dropped) the ro write that preceded it.
|
|
rw.doc.transact(() => rwMap.set('barrier', 'c'));
|
|
await waitFor(() => roMap.get('barrier') === 'c');
|
|
|
|
expect(rwMap.get('fromRo')).toBeUndefined();
|
|
|
|
rw.provider.destroy();
|
|
ro.provider.destroy();
|
|
rw.doc.destroy();
|
|
ro.doc.destroy();
|
|
});
|
|
});
|