// The editor's way to its shared document (packages/editor's // connectSharedDoc, F5.3b) against a real server F: ready with the // document, and whether the person only reads, and whether the project is // frozen; frozen and thawed while it is open — thawed, the document comes // anew (what was changed while reading must not stay in the page's copy); // refused without a session; let go when the grant ends or the project is // deleted; and let go by the editor. import { randomUUID } from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import { addDevice } from '@mdg-filius/doc'; import { peersOf } from '../../editor/src/remote/presence.js'; import { connectSharedDoc, type SharedDocState } from '../../editor/src/remote/sharedDoc.js'; import { PostgresSessions, PostgresStore } from '../src/index.js'; import { testDatabase, type TestDatabase } from './database.js'; import { newProject, SERVICE_KEY, signIn, startTestServer, until, type TestServer } from './harness.js'; let database: TestDatabase; let store: PostgresStore; let sessions: PostgresSessions; beforeAll(async () => { database = await testDatabase(); store = PostgresStore.connect(database.url); sessions = new PostgresSessions(store.pool); await store.migrate(); await sessions.migrate(); }, 60_000); afterAll(async () => { await store?.close(); await database?.stop(); }); /** The editor's connection as a browser with this cookie makes it; the states it went through. */ function connectEditor(server: TestServer, project: string, cookie?: string) { class Browser extends WebSocket { constructor(url: string, protocols?: string | string[]) { super(url, protocols, { headers: cookie === undefined ? {} : { cookie } }); this.on('error', () => {}); } } const states: SharedDocState[] = []; const close = connectSharedDoc({ server: server.base, project, onState: (state) => states.push(state), WebSocketPolyfill: Browser }); const last = () => states.at(-1)!; return { states, last, close, ready: () => until(() => last().kind === 'ready') }; } const service = (server: TestServer, path: string, body: unknown, method = 'POST') => fetch(`${server.base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, body: JSON.stringify(body) }); describe("the editor's shared document", () => { it('is ready with the document, writable with a write grant and read-only with a read grant', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const writer = connectEditor(server, project, await signIn(server, project)); await writer.ready(); expect(writer.states[0]).toEqual({ kind: 'connecting' }); expect(writer.last()).toMatchObject({ kind: 'ready', readOnly: false, frozen: false }); const doc = (writer.last() as Extract).doc; addDevice(doc, { id: 'a', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person'); const reader = connectEditor(server, project, await signIn(server, project, { level: 'read' })); await reader.ready(); expect(reader.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: false }); await until(() => (reader.last() as Extract).doc.getMap('nodes').size === 1); writer.close(); reader.close(); await server.close(); }); it('knows who it is, from F, and sees the others through the awareness', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const [miaId, benId] = [randomUUID(), randomUUID()]; const mia = connectEditor(server, project, await signIn(server, project, { user: miaId, name: 'Mia' })); const ben = connectEditor(server, project, await signIn(server, project, { user: benId, name: 'Ben', level: 'read' })); await mia.ready(); await ben.ready(); const miaReady = mia.last() as Extract; const benReady = ben.last() as Extract; expect(miaReady.you).toEqual({ id: miaId, name: 'Mia', color: '#3366cc' }); expect(benReady.you).toMatchObject({ id: benId, name: 'Ben' }); // A reader is seen too: the awareness is not the document. benReady.awareness.setLocalState({ v: 1, user: benReady.you, selection: 'router', windows: [], moving: null }); await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 1); expect(peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID)[0]!.state).toEqual({ v: 1, user: benReady.you, selection: 'router', windows: [], moving: null }); // Gone, he is gone for her too. ben.close(); await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 0); mia.close(); await server.close(); }); it('reads while the project is frozen and gets the document anew when it is thawed', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const editor = connectEditor(server, project, await signIn(server, project)); await editor.ready(); const first = (editor.last() as Extract).doc; await service(server, `/svc/projects/${project}/freeze`, { frozen: true }); await until(() => editor.last().kind === 'ready' && (editor.last() as Extract).frozen); expect(editor.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: true, doc: first }); // A change that got there anyway while it read: F did not take it. addDevice(first, { id: 'stray', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person'); await service(server, `/svc/projects/${project}/freeze`, { frozen: false }); await until(() => editor.last().kind === 'ready' && !(editor.last() as Extract).readOnly); const fresh = editor.last() as Extract; expect(fresh.doc).not.toBe(first); expect(fresh.doc.getMap('nodes').size).toBe(0); expect(editor.states.map((state) => state.kind).slice(-2)).toEqual(['connecting', 'ready']); // Writing works again: another person sees it. addDevice(fresh.doc, { id: 'b', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person'); const other = connectEditor(server, project, await signIn(server, project)); await other.ready(); await until(() => (other.last() as Extract).doc.getMap('nodes').size === 1); editor.close(); other.close(); await server.close(); }); it('starts frozen and says so', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); await service(server, `/svc/projects/${project}/freeze`, { frozen: true }); const editor = connectEditor(server, project, await signIn(server, project)); await until(() => editor.last().kind === 'ready' && (editor.last() as Extract).frozen); expect(editor.last()).toMatchObject({ readOnly: true, frozen: true }); editor.close(); await server.close(); }); it('is refused without a session, and let go when the grant ends or the project is deleted', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const stranger = connectEditor(server, project); await until(() => stranger.last().kind === 'refused'); expect(stranger.last()).toEqual({ kind: 'refused', reason: 'notSignedIn' }); const user = randomUUID(); const editor = connectEditor(server, project, await signIn(server, project, { user })); await editor.ready(); await service(server, '/svc/revoke', { user, project }); await until(() => editor.last().kind === 'closed'); expect(editor.last()).toEqual({ kind: 'closed', reason: 'revoked' }); const other = connectEditor(server, project, await signIn(server, project)); await other.ready(); await service(server, `/svc/projects/${project}`, undefined, 'DELETE'); await until(() => other.last().kind === 'closed'); expect(other.last()).toEqual({ kind: 'closed', reason: 'deleted' }); expect(server.sync.hocuspocus.getConnectionsCount()).toBe(0); await server.close(); }); it('stays ready when only the network drops, and comes back by itself', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const editor = connectEditor(server, project, await signIn(server, project)); await editor.ready(); const doc = (editor.last() as Extract).doc; for (const connection of server.sync.connections(project)) connection.webSocket.terminate(); await until(() => server.sync.connections(project).length === 1, 10_000); expect(editor.last()).toMatchObject({ kind: 'ready', doc }); addDevice(doc, { id: 'after', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person'); await until(async () => (await store.pending(project)) === 1); editor.close(); await server.close(); }); it('lets go when the editor does, and tells nothing after', async () => { const server = await startTestServer(store, sessions); const project = await newProject(store); const editor = connectEditor(server, project, await signIn(server, project)); await editor.ready(); const count = editor.states.length; editor.close(); await until(() => server.sync.hocuspocus.getConnectionsCount() === 0); await service(server, `/svc/projects/${project}/freeze`, { frozen: true }); await new Promise((resolve) => setTimeout(resolve, 200)); expect(editor.states).toHaveLength(count); await server.close(); }); });