import { afterEach, describe, expect, it } from "vitest"; import { type CanvasServer, startCanvasServer } from "../src/server.ts"; import { type CanvasAction, type CanvasState, changeScene, emptyState, } from "../src/store.ts"; const servers: CanvasServer[] = []; afterEach( async () => await Promise.all(servers.splice(0).map((server) => server.close())), ); function open( state: CanvasState = emptyState(), actions: CanvasAction[] = [], ): Promise { return startCanvasServer( () => state, async (action) => { actions.push(action); }, ); } function request( server: CanvasServer, path: string, init: RequestInit = {}, ): Promise { return fetch(new URL(path, server.url), init); } function bearer(server: CanvasServer): string { return `Bearer ${new URL(server.url).hash.slice(1)}`; } function headers(server: CanvasServer): HeadersInit { return { Authorization: bearer(server), Origin: new URL(server.url).origin, "Content-Type": "application/json", }; } describe("canvas server", () => { it("guards state and accepts only origin-bound new protocol actions", async () => { const actions: CanvasAction[] = []; const server = await open(emptyState(), actions); servers.push(server); expect((await request(server, "/api/state")).status).toBe(401); expect( ( await request(server, "/api/state", { headers: { Authorization: bearer(server) }, }) ).status, ).toBe(200); const action = await request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify({ kind: "prepare", requestId: "request", clientId: "browser", additions: [], }), }); expect(action.status).toBe(200); expect(actions).toEqual([ { kind: "prepare", requestId: "request", clientId: "browser", additions: [], }, ]); for (const value of [ { kind: "commit", requestId: "request", clientId: "browser", revision: 0, elements: [], }, { kind: "cancel", requestId: "request", clientId: "browser" }, ]) expect( ( await request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify(value), }) ).status, ).toBe(200); expect( ( await request(server, "/api/action", { method: "POST", headers: { ...headers(server), Origin: "http://localhost" }, body: JSON.stringify({ kind: "scene", revision: 0, elements: [] }), }) ).status, ).toBe(403); for (const value of [ { kind: "ask", text: "removed" }, { kind: "reply", annotationId: "removed", text: "removed" }, { kind: "resolve", annotationId: "removed" }, ]) expect( ( await request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify(value), }) ).status, ).toBe(400); expect( ( await request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify({ kind: "prepare", requestId: "x".repeat(121), clientId: "browser", additions: [], }), }) ).status, ).toBe(400); }); it("reports oversized scenes as 413 without changing state, while stale revisions remain 409", async () => { let state = emptyState(); const server = await startCanvasServer( () => state, (action) => { if (action.kind === "scene") state = changeScene(state, action.revision, action.elements); }, ); servers.push(server); const post = (revision: number) => request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify({ kind: "scene", revision, elements: [ { id: "large", type: "rectangle", customData: { data: "x".repeat(512 * 1024) }, }, ], }), }); const oversized = await post(0); expect(oversized.status).toBe(413); expect(await oversized.json()).toEqual({ error: "Canvas payload exceeds size limit.", }); expect((await post(1)).status).toBe(409); expect(state).toEqual(emptyState()); }); it("validates bounded viewport images without exposing them in state", async () => { const actions: CanvasAction[] = []; const server = await open(emptyState(), actions); servers.push(server); const viewport = { data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", mimeType: "image/png", imageWidth: 1, imageHeight: 1, width: 390, height: 844, scrollX: -50, scrollY: 100, zoom: 2, }; const post = (image: unknown) => request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify({ kind: "scene", revision: 0, elements: [], viewport: image, }), }); expect((await post(viewport)).status).toBe(200); expect(actions[0]).toMatchObject({ viewport }); for (const invalid of [ null, { ...viewport, data: "not an image" }, { ...viewport, data: "a".repeat(256 * 1024 + 4) }, { ...viewport, mimeType: "image/svg+xml" }, { ...viewport, imageWidth: 1025 }, { ...viewport, zoom: 0 }, { ...viewport, scrollX: "10" }, { ...viewport, extra: true }, ]) expect((await post(invalid)).status).toBe(400); expect(actions).toHaveLength(1); expect( await ( await request(server, "/api/state", { headers: { Authorization: bearer(server) }, }) ).json(), ).not.toHaveProperty("viewport"); }); it("preserves SSE, host, method, CSP, and size guards", async () => { const server = await open({ ...emptyState(), revision: 2 }); servers.push(server); const page = await request(server, "/"); expect(page.headers.get("content-security-policy")).toContain( "default-src 'self'", ); expect( (await fetch(server.url.replace("127.0.0.1", "localhost"))).status, ).toBe(400); expect( ( await request(server, "/api/state", { method: "POST", headers: { Authorization: bearer(server) }, }) ).status, ).toBe(405); expect((await request(server, "/api/events")).status).toBe(401); const response = await request(server, "/api/events", { headers: { Authorization: bearer(server) }, }); expect(response.status).toBe(200); const reader = response.body?.getReader(); const chunk = await reader?.read(); expect(new TextDecoder().decode(chunk?.value)).toContain('"revision":2'); await reader?.cancel(); expect( ( await request(server, "/api/action", { method: "POST", headers: headers(server), body: "{", }) ).status, ).toBe(400); expect( ( await request(server, "/api/action", { method: "POST", headers: headers(server), body: JSON.stringify({ kind: "scene", revision: 0, elements: "x".repeat(1024 * 1024 + 1), }), }) ).status, ).toBe(413); }); });