/**
* 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 {
drawioEmbedParams,
getApiToken,
getState,
isSameOriginDrawio,
keepInHistory,
onSessionRecreate,
onStateChange,
previewUrl,
requestExport,
requestSync,
restoreHistoryEntry,
setDrawioDir,
setShellDir,
setState,
shellConfig,
shutdown,
startHttpServer,
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: "",
exportId: 1,
})
expect(getState(id)?.exportData).toBeUndefined()
expect(getState(id)?.exportFormat).toBe("svg")
})
})
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("