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 { 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; 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 = await 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: () => Promise }[] = [ { label: 'an expired token', name: 'page-x', token: () => signCollabToken(claims, secret, -60), }, { label: 'a tampered token', name: 'page-x', token: async () => { const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.'); // Flip the FIRST signature character — its bits are all significant. // The last character is not: its low bits are base64url padding that // decoders ignore, so a last-char flip of a signature ending in 'A' // decodes to the same bytes and verifies (jose compares decoded // bytes, unlike the pre-#188 code that compared encoded strings). const flipped = (s.startsWith('A') ? 'B' : 'A') + s.slice(1); 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, await 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, await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60), ); const ro = connect( page, await signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60), ); await waitFor(() => rw.synced() && ro.synced()); const rwMap = rw.doc.getMap('m'); const roMap = ro.doc.getMap('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(); }); });