| 1 | import assert from "node:assert/strict"; |
| 2 | import type { IpcMain } from "electron"; |
| 3 | import { test } from "node:test"; |
| 4 | import { IPC, type IpcResult } from "../shared/ipc.js"; |
| 5 | import { parseContract } from "./contract.js"; |
| 6 | import { isOpenableExternalURL, parseRendererDiagnostic, registerRendererIpc } from "./ipc.js"; |
| 7 | |
| 8 | function fakeIpc() { |
| 9 | const handlers = new Map<string, (event: unknown, ...args: unknown[]) => Promise<IpcResult>>(); |
| 10 | const listeners = new Map<string, (event: { sender: unknown; senderFrame: unknown; returnValue?: unknown }) => void>(); |
| 11 | const ipcMain = { |
| 12 | handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => Promise<IpcResult>) => handlers.set(channel, fn), |
| 13 | on: (channel: string, fn: (event: { sender: unknown; senderFrame: unknown; returnValue?: unknown }) => void) => listeners.set(channel, fn), |
| 14 | } as unknown as IpcMain; |
| 15 | return { ipcMain, handlers, listeners }; |
| 16 | } |
| 17 | |
| 18 | test("external URLs are limited to http, https and mailto", () => { |
| 19 | assert.equal(isOpenableExternalURL("https://example.com/x"), true); |
| 20 | assert.equal(isOpenableExternalURL("http://example.com"), true); |
| 21 | assert.equal(isOpenableExternalURL("mailto:a@b.c"), true); |
| 22 | assert.equal(isOpenableExternalURL("file:///etc/passwd"), false); |
| 23 | assert.equal(isOpenableExternalURL("javascript:alert(1)"), false); |
| 24 | assert.equal(isOpenableExternalURL("reasonix://app/"), false); |
| 25 | assert.equal(isOpenableExternalURL("not a url"), false); |
| 26 | assert.equal(isOpenableExternalURL(42), false); |
| 27 | }); |
| 28 | |
| 29 | test("renderer transcript diagnostics accept only the bounded stable schema", () => { |
| 30 | const valid = { |
| 31 | kind: "transcript", event: "failure", stage: "delta_validate", reason: "revision_gap", transport: "local", errorType: "classified", |
| 32 | revision: 12, commit: 8, attempts: 1, failures: 2, durationMs: 1500, |
| 33 | }; |
| 34 | assert.deepEqual(parseRendererDiagnostic(valid), valid); |
| 35 | assert.throws(() => parseRendererDiagnostic({ ...valid, reason: "raw server response" }), /invalid renderer diagnostic value/); |
| 36 | assert.throws(() => parseRendererDiagnostic({ ...valid, prompt: "secret" }), /invalid renderer diagnostic field/); |
| 37 | assert.throws(() => parseRendererDiagnostic({ ...valid, failures: -1 }), /invalid renderer diagnostic failures/); |
| 38 | assert.throws(() => parseRendererDiagnostic({ ...valid, prompt: "x".repeat(2100) }), /payload too large/); |
| 39 | }); |
| 40 | |
| 41 | test("renderer invokes are gated by sender identity and the contract allowlist", async (t) => { |
| 42 | let now = 1_000; |
| 43 | t.mock.method(Date, "now", () => now); |
| 44 | const { ipcMain, handlers, listeners } = fakeIpc(); |
| 45 | const trustedSender = { id: 1 }; |
| 46 | const trustedFrame = {}; |
| 47 | const invoked: Array<{ method: string; args: unknown[] }> = []; |
| 48 | const nativeCalls: string[] = []; |
| 49 | const logMessages: string[] = []; |
| 50 | const warningMessages: string[] = []; |
| 51 | let diagnosticReads = 0; |
| 52 | const performanceCalls: string[] = []; |
| 53 | registerRendererIpc({ |
| 54 | ipcMain, |
| 55 | contract: parseContract({ digest: "sha256:a", commands: ["OpenProjectTab", "MinimiseMainWindow", "ToggleMaximiseMainWindow", "IsMainWindowMaximised", "CloseMainWindow"] }), |
| 56 | window: { |
| 57 | isTrustedSender: (sender, frame) => sender === trustedSender && frame === trustedFrame, |
| 58 | minimise() { nativeCalls.push("minimise"); }, |
| 59 | toggleMaximise() { nativeCalls.push("toggle"); }, |
| 60 | isMaximised: () => true, |
| 61 | close() { nativeCalls.push("close"); }, |
| 62 | bounds: () => ({ x: 1, y: 2, width: 3, height: 4, maximised: false }), |
| 63 | setTheme() {}, |
| 64 | setBackgroundColour() {}, |
| 65 | getAppZoom: async () => 1, |
| 66 | setAppZoom: async () => 1, |
| 67 | resetAppZoom: async () => 1, |
| 68 | }, |
| 69 | serviceState: () => ({ phase: "ready" as const, generation: "g-test" }), |
| 70 | processDiagnostics: () => { diagnosticReads++; return { scope: "electron", samples: [] }; }, |
| 71 | performance: { |
| 72 | captureRendererProfile: async () => { performanceCalls.push("capture"); return { status: "captured" }; }, |
| 73 | cancelRendererProfile: () => { performanceCalls.push("cancel"); }, |
| 74 | exportHeapSnapshot: async () => { performanceCalls.push("heap"); return { status: "cancelled" }; }, |
| 75 | dispose() {}, |
| 76 | }, |
| 77 | invoke: async (method, args) => { |
| 78 | invoked.push({ method, args }); |
| 79 | if (method === "OpenProjectTab" && args[0] === "/missing") throw new Error("workspace not found"); |
| 80 | return { opened: args[0] }; |
| 81 | }, |
| 82 | clipboard: { writeText: async () => undefined, readText: async () => "clip" }, |
| 83 | openExternal: async () => undefined, |
| 84 | log: { info: (message) => logMessages.push(message), warn: (message) => warningMessages.push(message), error() {} }, |
| 85 | }); |
| 86 | const invoke = handlers.get(IPC.invoke); |
| 87 | assert.ok(invoke); |
| 88 | const trusted = { sender: trustedSender, senderFrame: trustedFrame }; |
| 89 | const diagnostics = handlers.get(IPC.processDiagnostics)!; |
| 90 | assert.deepEqual(await diagnostics({ sender: trustedSender, senderFrame: {} }), { ok: false, message: "untrusted sender" }); |
| 91 | assert.equal(diagnosticReads, 0); |
| 92 | assert.deepEqual(await diagnostics(trusted), { ok: true, value: { scope: "electron", samples: [] } }); |
| 93 | assert.equal(diagnosticReads, 1); |
| 94 | for (const channel of [IPC.captureRendererProfile, IPC.cancelRendererProfile, IPC.exportHeapSnapshot]) { |
| 95 | assert.deepEqual(await handlers.get(channel)!({ sender: trustedSender, senderFrame: {} }), { ok: false, message: "untrusted sender" }); |
| 96 | } |
| 97 | assert.deepEqual(performanceCalls, []); |
| 98 | await handlers.get(IPC.cancelRendererProfile)!(trusted); |
| 99 | assert.deepEqual(performanceCalls, [], "unscoped IPC cancellation is ignored"); |
| 100 | for (const channel of [IPC.captureRendererProfile, IPC.cancelRendererProfile, IPC.exportHeapSnapshot]) await handlers.get(channel)!(trusted, "test-request"); |
| 101 | assert.deepEqual(performanceCalls, ["capture", "cancel", "heap"]); |
| 102 | assert.deepEqual(await invoke({ sender: { id: 9 }, senderFrame: trustedFrame }, "OpenProjectTab", ["/p"]), { ok: false, message: "untrusted sender" }); |
| 103 | assert.deepEqual(await invoke({ sender: trustedSender, senderFrame: {} }, "OpenProjectTab", ["/p"]), { ok: false, message: "untrusted sender" }); |
| 104 | assert.deepEqual(await invoke(trusted, "OpenProjectTab", ["/p"]), { ok: true, value: { opened: "/p" } }); |
| 105 | assert.deepEqual(await invoke(trusted, "OpenProjectTab", ["/missing"]), { ok: false, message: "workspace not found" }); |
| 106 | assert.deepEqual(await invoke(trusted, "MinimiseMainWindow", []), { ok: true, value: undefined }); |
| 107 | assert.deepEqual(await invoke(trusted, "ToggleMaximiseMainWindow", []), { ok: true, value: undefined }); |
| 108 | assert.deepEqual(await invoke(trusted, "IsMainWindowMaximised", []), { ok: true, value: true }); |
| 109 | assert.deepEqual(await invoke(trusted, "CloseMainWindow", []), { ok: true, value: undefined }); |
| 110 | assert.deepEqual(nativeCalls, ["minimise", "toggle", "close"]); |
| 111 | assert.deepEqual(await invoke(trusted, "DeleteEverything", []), { ok: false, message: "-32601 method not found: DeleteEverything" }); |
| 112 | assert.deepEqual(await invoke(trusted, "__proto__", []), { ok: false, message: "-32601 method not found: __proto__" }); |
| 113 | assert.deepEqual(invoked.map((call) => call.method), ["OpenProjectTab", "OpenProjectTab"]); |
| 114 | |
| 115 | const contract = listeners.get(IPC.contract); |
| 116 | assert.ok(contract); |
| 117 | const event: { sender: unknown; senderFrame: unknown; returnValue?: unknown } = { ...trusted }; |
| 118 | contract(event); |
| 119 | assert.deepEqual(event.returnValue, { |
| 120 | protocolVersion: 1, |
| 121 | digest: "sha256:a", |
| 122 | commands: ["OpenProjectTab", "MinimiseMainWindow", "ToggleMaximiseMainWindow", "IsMainWindowMaximised", "CloseMainWindow"], |
| 123 | }); |
| 124 | const untrusted: { sender: unknown; senderFrame: unknown; returnValue?: unknown } = { sender: { id: 9 }, senderFrame: trustedFrame }; |
| 125 | contract(untrusted); |
| 126 | assert.equal(untrusted.returnValue, null); |
| 127 | |
| 128 | const open = handlers.get(IPC.openExternal); |
| 129 | assert.ok(open); |
| 130 | assert.deepEqual(await open(trusted, "file:///etc/passwd"), { ok: false, message: "refusing to open file:///etc/passwd" }); |
| 131 | assert.deepEqual(await open(trusted, "https://example.com"), { ok: true, value: undefined }); |
| 132 | const bounds = handlers.get(IPC.windowGetBounds); |
| 133 | assert.ok(bounds); |
| 134 | assert.deepEqual(await bounds(trusted), { ok: true, value: { x: 1, y: 2, width: 3, height: 4, maximised: false } }); |
| 135 | const read = handlers.get(IPC.clipboardRead); |
| 136 | assert.ok(read); |
| 137 | assert.deepEqual(await read(trusted), { ok: true, value: "clip" }); |
| 138 | |
| 139 | const rendererDiagnostic = handlers.get(IPC.rendererDiagnostic)!; |
| 140 | const diagnostic = { |
| 141 | kind: "transcript", event: "failure", stage: "delta_validate", reason: "revision_gap", transport: "local", errorType: "classified", |
| 142 | revision: 12, commit: 8, attempts: 1, failures: 2, durationMs: 1500, |
| 143 | }; |
| 144 | assert.deepEqual(await rendererDiagnostic({ sender: { id: 9 }, senderFrame: trustedFrame }, diagnostic), { ok: false, message: "untrusted sender" }); |
| 145 | assert.deepEqual(await rendererDiagnostic(trusted, diagnostic), { ok: true, value: undefined }); |
| 146 | assert.match(logMessages.at(-1) ?? "", /stage=delta_validate reason=revision_gap type=classified.*service=ready generation=g-test/); |
| 147 | assert.deepEqual(await rendererDiagnostic(trusted, { ...diagnostic, response: "raw" }), { ok: false, message: "invalid renderer diagnostic field" }); |
| 148 | for (let index = 0; index < 10; index++) await rendererDiagnostic(trusted, diagnostic); |
| 149 | assert.equal(logMessages.length, 10, "only ten renderer diagnostics are accepted in one second"); |
| 150 | now = 2_000; |
| 151 | await rendererDiagnostic(trusted, diagnostic); |
| 152 | assert.ok(warningMessages.some(message => message === "renderer diagnostics rate limited dropped=1")); |
| 153 | assert.equal(logMessages.length, 11, "the next rate window accepts diagnostics again"); |
| 154 | }); |
| 155 |