dorfteich/apps/collab/src/auth.test.ts
Claude Opus 4.8 7d8f331870
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
Add collab document persistence hooks and content-cache refresh (#35)
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
2026-07-08 17:06:41 +02:00

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