返回 DeepSeek-Reasonix
ipc.test.ts
根目录 / desktop / electron / src / main / ipc.test.ts
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
155 lines TYPESCRIPT