feat: F5.3b the editor on a project of server F
?project=<uuid> opens the shared document over /sync and the room over /sim (RemoteSimHost: messages wait for the socket, a lost socket comes back with what it watched, F's refusal or parting ends it); instead of the editor, a word why when F refuses or lets go. F tells each connection its access at once. Who only reads sees everything and changes nothing: no palette or undo, a locked surface, the sheet's fields disabled (tabs stay), no starting or stopping; thawed, the document comes anew. The toolbar names who started the simulation. Refusals carry no message, so Hocuspocus writes nothing outside F's JSON log. A browser test runs three people against a real F. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,12 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
|
||||
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
|
||||
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||
import { cookieValue, PostgresSessions, PostgresStore, refusal, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||
import { testDatabase, type TestDatabase } from './database.js';
|
||||
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
|
||||
import { signTicket } from './tickets.js';
|
||||
@@ -226,6 +226,21 @@ describe('the way into a shared document', () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('refuses without a word on the console: the reason goes to the editor, the log has the rest', async () => {
|
||||
expect([refusal('noAccess').reason, refusal('noAccess').message]).toEqual(['noAccess', '']);
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const written: unknown[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => void written.push(args));
|
||||
try {
|
||||
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('reads a cookie among others, and nothing that only looks like it', () => {
|
||||
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
|
||||
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
|
||||
@@ -306,25 +321,30 @@ describe("the dashboard's service", () => {
|
||||
const project = await newProject();
|
||||
const writer = await enter(server, project, { level: 'write' });
|
||||
const reader = await enter(server, project, { level: 'read' });
|
||||
// Each is told its access as it comes in.
|
||||
await until(() => writer.messages.length === 1 && reader.messages.length === 1);
|
||||
expect([writer.messages, reader.messages]).toEqual([[{ v: 1, kind: 'access', readOnly: false, frozen: false }], [{ v: 1, kind: 'access', readOnly: true, frozen: false }]]);
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
|
||||
await until(() => writer.messages.length === 1);
|
||||
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||
await until(() => writer.messages.length === 2);
|
||||
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true });
|
||||
// Whoever comes now reads too, with a write grant; what they change is not taken.
|
||||
const late = await enter(server, project, { level: 'write' });
|
||||
expect(late.provider.authorizedScope).toBe('readonly');
|
||||
await until(() => late.messages.length === 1);
|
||||
expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||
place(late.doc, 'a');
|
||||
await sleep(300);
|
||||
expect(await store.pending(project)).toBe(0);
|
||||
expect(deviceCount(writer.doc)).toBe(0);
|
||||
late.close();
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
|
||||
await until(() => writer.messages.length === 2);
|
||||
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||
await until(() => writer.messages.length === 3);
|
||||
expect(writer.messages[2]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||
place(writer.doc, 'b');
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
await until(() => deviceCount(reader.doc) === 1);
|
||||
// The reader was told nothing: it read before and reads still.
|
||||
expect(reader.messages).toEqual([]);
|
||||
// The reader was told nothing more: it read before and reads still.
|
||||
expect(reader.messages).toHaveLength(1);
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
|
||||
writer.close();
|
||||
reader.close();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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 { 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<SharedDocState, { kind: 'ready' }>).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<SharedDocState, { kind: 'ready' }>).doc.getMap('nodes').size === 1);
|
||||
writer.close();
|
||||
reader.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<SharedDocState, { kind: 'ready' }>).doc;
|
||||
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
|
||||
await until(() => editor.last().kind === 'ready' && (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).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<SharedDocState, { kind: 'ready' }>).readOnly);
|
||||
const fresh = editor.last() as Extract<SharedDocState, { kind: 'ready' }>;
|
||||
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<SharedDocState, { kind: 'ready' }>).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<SharedDocState, { kind: 'ready' }>).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<SharedDocState, { kind: 'ready' }>).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();
|
||||
});
|
||||
});
|
||||
@@ -220,7 +220,8 @@ describe('a simulation room on F', () => {
|
||||
again.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch left.txt' }, reqId: 'r' });
|
||||
await until(() => again.got.some((message) => message.kind === 'ack'));
|
||||
again.socket.close();
|
||||
await until(() => server.sims.stats().rooms === 0, 5_000);
|
||||
await until(() => server.log.lines.some((line) => line.event === 'sim.roomClosed'), 5_000);
|
||||
expect(server.sims.stats().rooms).toBe(0);
|
||||
expect(server.log.lines.filter((line) => line.event === 'sim.roomClosed')).toEqual([{ level: 'info', event: 'sim.roomClosed', project, writeBack: true }]);
|
||||
// The document let go once the room had written back.
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
|
||||
Reference in New Issue
Block a user