// What the server's tests share: the log kept in memory, a server F with // test secrets, a project as the service makes it, a person signed in with // a ticket as the dashboard signs one, and that person in a project with // Hocuspocus' own provider over a real WebSocket (bringing the session's // cookie, as the browser does). import { randomUUID } from 'node:crypto'; import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; import { expect } from 'vitest'; import WebSocket from 'ws'; import * as Y from 'yjs'; import { emptyProject, writeProject } from '@mdg-filius/doc'; import { startServer, type DocStore, type FiliusServer, type Log, type ServerOptions, type SessionStore } from '../src/index.js'; import { signTicket } from './tickets.js'; export const TICKET_SECRET = 'test-ticket-secret'; export const SERVICE_KEY = 'test-service-key'; export const EDITOR_ORIGIN = 'http://editor.test'; export type MemoryLog = Log & { readonly lines: Record[] }; /** The log's lines, kept. */ export function memoryLog(): MemoryLog { const lines: Record[] = []; const entry = (level: string) => (event: string, fields: Record = {}) => { lines.push(JSON.parse(JSON.stringify({ level, event, ...fields })) as Record); }; return { lines, info: entry('info'), warn: entry('warn'), error: entry('error') }; } export type TestServer = FiliusServer & { readonly log: MemoryLog; readonly base: string }; export async function startTestServer(store: DocStore, sessions: SessionStore, options: Partial = {}): Promise { const log = memoryLog(); const server = await startServer({ port: 0, host: '127.0.0.1', store, sessions, log, ticketSecret: TICKET_SECRET, serviceKey: SERVICE_KEY, origins: [EDITOR_ORIGIN], secureCookie: false, ...options, }); return Object.assign(server, { log, base: `http://127.0.0.1:${server.port}` }); } /** A new project as the service makes it: an empty FILIUS project. */ export async function newProject(store: DocStore): Promise { const project = randomUUID(); const doc = new Y.Doc(); writeProject(doc, emptyProject()); expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true); return project; } export interface Person { readonly user?: string; readonly login?: string; readonly level?: 'read' | 'write'; /** The cookie the browser brings to /__auth. */ readonly cookie?: string; } /** The answer of /__auth to a fresh ticket. */ export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', level = 'write', cookie }: Person = {}, next = '/'): Promise { const ticket = signTicket({ u: user, n: login, d: 'Ada Beispiel', c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET); return fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(next)}`, { redirect: 'manual', ...(cookie === undefined ? {} : { headers: { cookie } }), }); } /** Signed in through /__auth: the cookie for the browser's next requests. */ export async function signIn(server: TestServer, project: string, person: Person = {}): Promise { const response = await exchange(server, project, person); expect(response.status).toBe(302); return response.headers.get('set-cookie')!.split(';')[0]!; } export interface Client { readonly doc: Y.Doc; readonly provider: HocuspocusProvider; /** Why F closed the connection, in order. */ readonly closings: string[]; /** Why F refused it after it had been in, in order. */ readonly refusals: string[]; /** Stateless messages from F. */ readonly messages: unknown[]; close(): void; } export interface JoinOptions { readonly cookie?: string; /** Comes back at once when let go (no delay, as a client may). */ readonly eager?: boolean; readonly origin?: string; } /** A person in a project: synced, or refused. */ export function join(server: FiliusServer, project: string, { cookie, eager = false, origin }: JoinOptions = {}): Promise { const doc = new Y.Doc(); const headers = { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) }; class Browser extends WebSocket { constructor(url: string, protocols?: string | string[]) { super(url, protocols, { headers }); // Let go while it is still connecting (the test ends, the service has gone), ws reports an error no one awaits. this.on('error', () => {}); } } const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: Browser, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) }); const closings: string[] = []; const refusals: string[] = []; const messages: unknown[] = []; return new Promise((resolve, reject) => { let synced = false; const timer = setTimeout(() => { provider.destroy(); socket.destroy(); reject(new Error('not synced within 3 s')); }, 3_000); const provider = new HocuspocusProvider({ websocketProvider: socket, name: project, document: doc, token: 'cookie', onSynced: () => { if (synced) return; synced = true; clearTimeout(timer); resolve({ doc, provider, closings, refusals, messages, close: () => (provider.destroy(), socket.destroy()) }); }, onAuthenticationFailed: ({ reason }) => { if (synced) { refusals.push(reason); return; } clearTimeout(timer); provider.destroy(); socket.destroy(); reject(new Error(`refused: ${reason}`)); }, onStateless: ({ payload }) => messages.push(JSON.parse(payload)), onClose: ({ event }) => { if (synced) closings.push(event.reason); else reject(new Error(`closed: ${event.reason}`)); }, }); provider.attach(); }); } /** Signed in with a fresh ticket, then in the project. */ export async function enter(server: TestServer, project: string, person: Person = {}, options: Omit = {}): Promise { return join(server, project, { ...options, cookie: await signIn(server, project, person) }); } export const until = async (condition: () => boolean | Promise, milliseconds = 5_000) => { for (let waited = 0; !(await condition()); waited += 25) { if (waited > milliseconds) throw new Error('timed out'); await new Promise((resolve) => setTimeout(resolve, 25)); } };