/** * Tests for the embedded HTTP server (browser bridge). * * The server runs in-process on a random high port (never 6002, which is * also the default port of the Next.js dev server). Requests go through * node:http so tests can set raw paths and Host/Origin headers. */ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync, } from "node:fs" import http from "node:http" import { tmpdir } from "node:os" import { join } from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { installDomPolyfill } from "../src/dom.ts" import { addHistory, getHistory } from "../src/history.ts" import { activePreviewUi, drawioEmbedParams, getApiToken, getState, isSameOriginDrawio, keepInHistory, onSessionRecreate, onStateChange, previewUiFromEnv, previewUrl, readSelection, requestExport, requestSelection, requestSync, restoreHistoryEntry, setDrawioDir, setShellDir, setState, shellConfig, shutdown, startHttpServer, waitForSelection, waitForSync, } from "../src/http-server.ts" let port = 0 // A stand-in for the bundled draw.io copy (dist/drawio), with a server-side // part of the war that must never be served let drawioDir = "" // A stand-in for the built canvas shell (dist/shell) let shellDir = "" beforeAll(async () => { // XML parsing, as the server installs it at startup installDomPolyfill() drawioDir = mkdtempSync(join(tmpdir(), "drawio-static-")) mkdirSync(join(drawioDir, "js")) mkdirSync(join(drawioDir, "WEB-INF")) writeFileSync(join(drawioDir, "index.html"), "draw.io") writeFileSync(join(drawioDir, ".version"), "v1\n") writeFileSync(join(drawioDir, "js/app.min.js"), "// app") writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "") setDrawioDir(drawioDir) shellDir = mkdtempSync(join(tmpdir(), "shell-static-")) writeFileSync( join(shellDir, "index.html"), "", ) writeFileSync(join(shellDir, "shell.js"), "// shell") writeFileSync(join(shellDir, "shell.css"), "body{}") setShellDir(shellDir) port = await startHttpServer(40000 + Math.floor(Math.random() * 10000)) }) afterAll(() => { shutdown() rmSync(drawioDir, { recursive: true, force: true }) rmSync(shellDir, { recursive: true, force: true }) }) interface Response { status: number headers: http.IncomingHttpHeaders body: string } /** * Send a request as the preview page does (same Host, the API token); * `body` may be split into several writes. */ function request( path: string, opts: { method?: string headers?: Record body?: Buffer[] } = {}, ): Promise { return new Promise((resolve, reject) => { const req = http.request( { host: "127.0.0.1", port, path, method: opts.method ?? "GET", headers: { host: `localhost:${port}`, "x-drawio-token": getApiToken(), ...opts.headers, }, }, (res) => { const chunks: Buffer[] = [] res.on("data", (c: Buffer) => chunks.push(c)) res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8"), }), ) }, ) req.on("error", reject) const parts = opts.body ?? [] // Pause between parts so the server reads them as separate chunks const writeNext = (i: number) => { if (i >= parts.length) return req.end() req.write(parts[i]) setTimeout(() => writeNext(i + 1), 30) } writeNext(0) }) } const postJson = (path: string, data: unknown, headers = {}) => request(path, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: [Buffer.from(JSON.stringify(data))], }) describe("session id in the page URL", () => { it("rejects a session id that could inject script", async () => { const res = await request(`/?mcp=${encodeURIComponent('";alert(1)//')}`) expect(res.status).toBe(400) expect(res.body).not.toContain("alert") }) it("writes a valid session id into the page script as a JSON string", async () => { const res = await request("/?mcp=mcp-test-page") expect(res.status).toBe(200) expect(res.body).toContain('const sessionId = "mcp-test-page";') }) }) describe("requests that used to crash the process", () => { it("answers 400 for a path that is not a valid URL", async () => { const res = await request("//") expect(res.status).toBe(400) // The server is still alive expect((await request("/api/state?sessionId=mcp-alive")).status).toBe( 200, ) }) it("never creates sessions with ids unsafe for the Location header", async () => { const badId = "mcp-中" await request(`/api/state?sessionId=${encodeURIComponent(badId)}`) expect(getState(badId)).toBeUndefined() const post = await postJson("/api/state", { sessionId: badId, xml: "", }) expect(post.status).toBe(400) expect(getState(badId)).toBeUndefined() const res = await request("/") expect([200, 302]).toContain(res.status) }) }) describe("request origin checks", () => { it("refuses a foreign Host header (DNS rebinding)", async () => { const res = await request("/api/state?sessionId=mcp-alive", { headers: { host: `evil.example:${port}` }, }) expect(res.status).toBe(403) }) it("refuses writes from another website", async () => { const res = await postJson( "/api/state", { sessionId: "mcp-csrf", xml: "" }, { origin: "https://evil.example" }, ) expect(res.status).toBe(403) expect(getState("mcp-csrf")).toBeUndefined() }) it("accepts writes from the page itself", async () => { const res = await postJson( "/api/state", { sessionId: "mcp-same-origin", xml: "" }, { origin: `http://localhost:${port}` }, ) expect(res.status).toBe(200) // Opened as 127.0.0.1, or through a forwarded port: Origin and // Host name the same host for (const host of [`127.0.0.1:${port}`, "localhost:7000"]) { const page = await postJson( "/api/state", { sessionId: "mcp-same-origin", xml: "" }, { origin: `http://${host}`, host }, ) expect(page.status).toBe(200) } }) it("refuses writes from a page on another localhost port", async () => { // A plain text POST needs no CORS preflight, so the server must // refuse it itself setState("mcp-other-port", "kept") for (const path of ["/api/state", "/api/history-svg"]) { const res = await postJson( path, { sessionId: "mcp-other-port", xml: "replaced", svg: "x", }, { origin: "http://localhost:3000" }, ) expect(res.status).toBe(403) } expect(getState("mcp-other-port")?.xml).toBe("kept") expect(getState("mcp-other-port")?.svg).toBeUndefined() }) }) describe("POST /api/state", () => { it("refuses a push without xml and keeps the diagram", async () => { setState("mcp-no-xml", "kept") const res = await postJson("/api/state", { sessionId: "mcp-no-xml", baseVersion: 99, }) expect(res.status).toBe(400) expect(getState("mcp-no-xml")?.xml).toBe("kept") }) it("decodes UTF-8 characters split across body chunks", async () => { const xml = `${"数据".repeat(30000)}` const body = Buffer.from(JSON.stringify({ sessionId: "mcp-utf8", xml })) // Cut inside a 3-byte character const cut = body.indexOf(Buffer.from("数")) + 1 const res = await request("/api/state", { method: "POST", headers: { "content-type": "application/json" }, body: [body.subarray(0, cut), body.subarray(cut)], }) expect(res.status).toBe(200) expect(getState("mcp-utf8")?.xml).toBe(xml) }) it("rejects a browser push based on a version older than an AI write", async () => { const id = "mcp-conflict" setState(id, "user v1", undefined, true) const aiVersion = setState(id, "AI edit") const stale = await postJson("/api/state", { sessionId: id, xml: "user edit on old version", baseVersion: aiVersion - 1, }) expect(stale.status).toBe(409) expect(getState(id)?.xml).toBe("AI edit") // Pushes based on the AI version are accepted, including a second // push sent before the first one's response updated the browser for (const xml of ["a", "b"]) { const ok = await postJson("/api/state", { sessionId: id, xml, baseVersion: aiVersion, }) expect(ok.status).toBe(200) expect(getState(id)?.xml).toBe(xml) } }) it("keeps a rejected user edit in history", async () => { const id = "mcp-conflict-history" setState(id, "user v1", undefined, true) const aiVersion = setState(id, "AI edit") const before = getHistory(id).length const stale = await postJson("/api/state", { sessionId: id, xml: "lost user edit", baseVersion: aiVersion - 1, }) expect(stale.status).toBe(409) expect(JSON.parse(stale.body).savedToHistory).toBe(true) const history = getHistory(id) expect(history).toHaveLength(before + 1) expect(history.at(-1)?.xml).toBe("lost user edit") }) it("asks for a save when a lost user edit is kept in history", async () => { const id = "mcp-conflict-save" setState(id, "user v1", undefined, true) const aiVersion = setState(id, "AI edit") const saves: string[] = [] onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`)) try { await postJson("/api/state", { sessionId: id, xml: "lost user edit", baseVersion: aiVersion - 1, }) } finally { onStateChange(() => {}) } // The canvas stays the AI write; the save takes the new History expect(saves).toEqual([`${id} AI edit`]) }) it("ends a pending sync when the sync reply is older than an AI write", async () => { const id = "mcp-stale-sync" setState(id, "before", undefined, true) const aiVersion = setState(id, "AI edit") requestSync(id) const before = getHistory(id).length // The browser exported its old diagram, then loaded the AI write const stale = await postJson("/api/state", { sessionId: id, xml: "before", baseVersion: aiVersion - 1, source: "sync", }) expect(stale.status).toBe(409) expect(JSON.parse(stale.body).savedToHistory).toBe(false) expect(getState(id)?.xml).toBe("AI edit") expect(getState(id)?.syncRequested).toBeUndefined() expect(getHistory(id)).toHaveLength(before) expect(await waitForSync(id, 200)).toBe(true) }) it("ignores a sync reply older than a user edit saved meanwhile", async () => { const id = "mcp-late-sync" const version = setState(id, "A", undefined, true) requestSync(id) // The user's edit is saved before the sync reply arrives const edit = await postJson("/api/state", { sessionId: id, xml: "B", baseVersion: version, }) expect(edit.status).toBe(200) const late = await postJson("/api/state", { sessionId: id, xml: "A", baseVersion: version, source: "sync", }) expect(late.status).toBe(409) expect(getState(id)?.xml).toBe("B") }) }) describe("export requests", () => { it("hands draw.io export options to the page and clears them after", async () => { const id = "mcp-export-options" setState(id, "x") requestExport(id, "png", undefined, { width: 1000, pageId: "p2" }) const poll = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) expect(poll.exportFormat).toBe("png") expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" }) await postJson("/api/state", { sessionId: id, exportData: "data:image/png;base64,AAAA", exportId: poll.exportId, }) expect(getState(id)?.exportOptions).toBeUndefined() }) it("ignores a late result of an export that already timed out", async () => { const id = "mcp-export-late" setState(id, "x") requestExport(id, "png") const first = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) // The server gave up on the first export and asked for the next requestExport(id, "svg") await postJson("/api/state", { sessionId: id, exportData: "data:image/png;base64,LATE", exportId: first.exportId, }) expect(getState(id)?.exportData).toBeUndefined() expect(getState(id)?.exportFormat).toBe("svg") }) it("names exports at random, so a result meant for another process is not taken", async () => { const id = "mcp-export-random-id" setState(id, "x") requestExport(id, "svg") const poll = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) expect(poll.exportId).toMatch(/^[0-9a-f-]{36}$/) // The page retries a result refused with 403 against the process // that took over the port; a per-process counter would restart at 1 await postJson("/api/state", { sessionId: id, exportData: "OLD", exportId: 1, }) expect(getState(id)?.exportData).toBeUndefined() expect(getState(id)?.exportFormat).toBe("svg") }) }) describe("selection requests (get_selection)", () => { const picked = { pageId: "p1", pageName: "Page-1", cells: [{ id: "3", label: "Login", edge: false }], } it("shows the request to the page and takes its answer once", async () => { const id = "mcp-selection" setState(id, "x") expect(requestSelection(id)).toBe(true) const poll = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) expect(poll.selectionId).toMatch(/^[0-9a-f-]{36}$/) // The request survives a write meanwhile setState(id, "y") const waiting = waitForSelection(id, 3000) const answer = await postJson("/api/state", { sessionId: id, selectionId: poll.selectionId, selection: picked, }) expect(answer.status).toBe(200) expect(await waiting).toEqual(picked) // Taken: the next poll shows no request const after = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) expect(after.selectionId).toBeNull() expect(getState(id)?.xml).toBe("y") }) it("ignores an answer to another request, and gives up in time", async () => { const id = "mcp-selection-late" setState(id, "x") requestSelection(id) const first = JSON.parse( (await request(`/api/state?sessionId=${id}`)).body, ) requestSelection(id) await postJson("/api/state", { sessionId: id, selectionId: first.selectionId, selection: picked, }) expect(getState(id)?.selection).toBeUndefined() expect(getState(id)?.selectionId).not.toBe(first.selectionId) // A malformed answer is not taken either const second = getState(id)?.selectionId await postJson("/api/state", { sessionId: id, selectionId: second, selection: { cells: "3" }, }) expect(getState(id)?.selection).toBeUndefined() expect(await waitForSelection(id, 250)).toBeNull() // The request is withdrawn with the timeout expect(getState(id)?.selectionId).toBeUndefined() expect(requestSelection("mcp-unknown-session")).toBe(false) }) it("takes no answer while no request is pending", async () => { const id = "mcp-selection-unasked" setState(id, "x") // Both undefined would compare equal await postJson("/api/state", { sessionId: id, selection: picked }) expect(getState(id)?.selection).toBeUndefined() requestSelection(id) const { selectionId } = getState(id) ?? {} await postJson("/api/state", { sessionId: id, selection: picked }) expect(getState(id)?.selection).toBeUndefined() expect(getState(id)?.selectionId).toBe(selectionId) await waitForSelection(id, 50) }) it("keeps the first answer when two tabs answer the same request", async () => { const id = "mcp-selection-two-tabs" setState(id, "x") requestSelection(id) const { selectionId } = getState(id) ?? {} await postJson("/api/state", { sessionId: id, selectionId, selection: picked, }) const other = { ...picked, cells: [] } await postJson("/api/state", { sessionId: id, selectionId, selection: other, }) expect(await waitForSelection(id, 1000)).toEqual(picked) }) it("lets overlapping get_selection calls take turns, each getting its own answer", async () => { const id = "mcp-selection-overlap" setState(id, "x") const poll = async () => JSON.parse((await request(`/api/state?sessionId=${id}`)).body) const first = readSelection(id, 3000) const second = readSelection(id, 3000) // The tab sees one request and answers it let shown = await poll() expect(shown.selectionId).toMatch(/^[0-9a-f-]{36}$/) await postJson("/api/state", { sessionId: id, selectionId: shown.selectionId, selection: picked, }) expect(await first).toEqual(picked) // Then the second call's request, another id, answered in turn await new Promise((r) => setTimeout(r, 150)) const next = await poll() expect(next.selectionId).toMatch(/^[0-9a-f-]{36}$/) expect(next.selectionId).not.toBe(shown.selectionId) const other = { ...picked, cells: [] } await postJson("/api/state", { sessionId: id, selectionId: next.selectionId, selection: other, }) expect(await second).toEqual(other) shown = await poll() expect(shown.selectionId).toBeNull() expect(await readSelection("mcp-unknown-session", 100)).toBeNull() }) }) describe("a session state recreated after it was lost", () => { const SAVED = `` const getJson = async (id: string) => JSON.parse((await request(`/api/state?sessionId=${id}`)).body) it("names each state, and says when it was made blank", async () => { const first = await getJson("mcp-sid-blank") expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/) expect(first.blank).toBe(true) setState("mcp-sid-blank", "AI write") const after = await getJson("mcp-sid-blank") // Same state, no longer blank expect(after.stateId).toBe(first.stateId) expect(after.blank).toBe(false) }) it("refuses a push made for another state, also before any poll", async () => { // The MCP process restarted; the tab's push comes before its poll onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null)) try { for (const stateId of ["from-before", null]) { const res = await postJson("/api/state", { sessionId: "mcp-sid-restart", xml: "tab's old copy", baseVersion: 7, stateId, }) expect(res.status).toBe(409) expect(JSON.parse(res.body).stateChanged).toBe(true) // The saved file was recovered first and is kept expect(getState("mcp-sid-restart")?.xml).toBe(SAVED) } } finally { onSessionRecreate(() => null) } }) it("accepts a push for the current state", async () => { const { stateId, version } = await getJson("mcp-sid-ok") const res = await postJson("/api/state", { sessionId: "mcp-sid-ok", xml: "user edit", baseVersion: version, stateId, }) expect(res.status).toBe(200) expect(getState("mcp-sid-ok")?.xml).toBe("user edit") }) it("keeps a recovering tab's copy in history, never on the canvas", async () => { setState("mcp-sid-recover", SAVED) const { stateId, version } = await getJson("mcp-sid-recover") const before = getHistory("mcp-sid-recover").length const res = await postJson("/api/state", { sessionId: "mcp-sid-recover", xml: "what the tab showed", baseVersion: version, stateId, source: "recover", }) expect(res.status).toBe(409) expect(JSON.parse(res.body).savedToHistory).toBe(true) expect(getState("mcp-sid-recover")?.xml).toBe(SAVED) expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1) expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe( "what the tab showed", ) }) it("keeps the old rules for a tab from an older version", async () => { // Its pushes have no stateId field const version = setState("mcp-sid-legacy", "AI") const res = await postJson("/api/state", { sessionId: "mcp-sid-legacy", xml: "edit", baseVersion: version, }) expect(res.status).toBe(200) }) }) describe("preview page", () => { it("shows the saved diagram of a session whose state expired", async () => { const saved = `` onSessionRecreate((id) => (id === "mcp-expired" ? saved : null)) try { await request("/?mcp=mcp-expired") expect(getState("mcp-expired")?.xml).toBe(saved) await request("/?mcp=mcp-never-saved") expect(getState("mcp-never-saved")?.xml).not.toContain("kept") } finally { onSessionRecreate(() => null) } }) it("serves scripts that parse, with every placeholder filled", async () => { const res = await request("/?mcp=mcp-test-script") expect(res.body).not.toContain("{{") // Both scripts share one global scope in the page const scripts = [...res.body.matchAll(/" try { const res = await request("/shell/?mcp=mcp-shell-escape") expect(res.body).not.toContain("