336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
// Tests for the per-conversation browser-view registry (src/browserViewRegistry.js),
|
|
// run with `node --test` (no extra deps). The registry is a pure factory — all
|
|
// Electron deps are injected — so we drive it with stub views + spies.
|
|
//
|
|
// The load-bearing case is the FIRST-navigate activation signal: on a fresh
|
|
// conversation, openOrNavigate creates the view DETACHED (activeConversationId
|
|
// is null), so no `browser-host-active-changed` fires. Without the
|
|
// `browser-view-created` emit the React pane would never learn a view exists,
|
|
// never mount its placeholder, and never call setActive — the pane would stay
|
|
// invisible (the bug this test guards against).
|
|
|
|
const { describe, it, beforeEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { createBrowserViewRegistry } = require("../src/browserViewRegistry");
|
|
const { createBrowserViewBoundsController } = require("../src/browserViewBounds");
|
|
|
|
/** Build a registry with spy-backed injected deps. Returns the registry plus
|
|
* the recorded renderer sends / attach / detach calls for assertions. */
|
|
function makeRegistry() {
|
|
const sent = []; // { channel, payload }
|
|
const attached = [];
|
|
const detached = [];
|
|
const makeStubView = () => ({
|
|
setBounds() {},
|
|
webContents: {
|
|
loadURL() {},
|
|
close() {},
|
|
removeListener() {},
|
|
on() {},
|
|
setWindowOpenHandler() {},
|
|
},
|
|
});
|
|
const registry = createBrowserViewRegistry({
|
|
WebContentsViewCtor: () => makeStubView(),
|
|
createBoundsController: createBrowserViewBoundsController,
|
|
attachToHost: (view) => attached.push(view),
|
|
detachFromHost: (view) => detached.push(view),
|
|
sendToRenderer: (channel, payload) => sent.push({ channel, payload }),
|
|
getHostZoomFactor: () => 1,
|
|
});
|
|
return { registry, sent, attached, detached };
|
|
}
|
|
|
|
describe("browserViewRegistry — first-navigate activation signal", () => {
|
|
let ctx;
|
|
beforeEach(() => {
|
|
ctx = makeRegistry();
|
|
});
|
|
|
|
it("emits browser-view-created on first openOrNavigate for a fresh conversation", () => {
|
|
const r = ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.created, true);
|
|
const created = ctx.sent.filter((s) => s.channel === "browser-view-created");
|
|
assert.equal(created.length, 1, "exactly one create event");
|
|
assert.deepEqual(created[0].payload, { conversationId: "conv_1" });
|
|
});
|
|
|
|
it("creates the view DETACHED when the conversation isn't active (no attach, no host-active event)", () => {
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
// Fresh conversation → activeConversationId stayed null → never attached.
|
|
assert.equal(ctx.attached.length, 0, "view is created detached");
|
|
assert.equal(ctx.registry.activeConversationId(), null);
|
|
const active = ctx.sent.filter((s) => s.channel === "browser-host-active-changed");
|
|
assert.equal(active.length, 0, "no host-active event on detached create");
|
|
});
|
|
|
|
it("does NOT re-emit browser-view-created on a subsequent navigate of the same conversation", () => {
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.org");
|
|
const created = ctx.sent.filter((s) => s.channel === "browser-view-created");
|
|
assert.equal(created.length, 1, "create fires once, on first create only");
|
|
});
|
|
|
|
it("setActive after create attaches the view and fires host-active-changed", () => {
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
const r = ctx.registry.setActive("conv_1");
|
|
assert.equal(r.ok, true);
|
|
assert.equal(ctx.attached.length, 1, "setActive attaches the view");
|
|
assert.equal(ctx.registry.activeConversationId(), "conv_1");
|
|
const active = ctx.sent.filter(
|
|
(s) => s.channel === "browser-host-active-changed" && s.payload.conversationId === "conv_1",
|
|
);
|
|
assert.equal(active.length, 1);
|
|
});
|
|
|
|
it("has() reports view existence for the re-mount probe", () => {
|
|
assert.equal(ctx.registry.has("conv_1"), false);
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
assert.equal(ctx.registry.has("conv_1"), true);
|
|
});
|
|
|
|
it("full first-navigate path: create signal → setActive → attached + bounds synced", () => {
|
|
// Mirrors what BrowserPane does: it learns of the view from the create
|
|
// event, mounts the placeholder, then setActive attaches + syncs bounds.
|
|
let sawCreate = null;
|
|
// (the pane's onBrowserViewCreated listener)
|
|
ctx.sent.length = 0;
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
sawCreate = ctx.sent.find((s) => s.channel === "browser-view-created");
|
|
assert.ok(sawCreate, "pane would receive the create event");
|
|
|
|
// Pane reacts: setActive(conversationId) then a resize (bounds).
|
|
ctx.registry.setActive("conv_1");
|
|
const entry = ctx.registry.get("conv_1");
|
|
assert.ok(entry, "entry resolvable for resize");
|
|
entry.boundsController.setRendererBounds({
|
|
x: 10,
|
|
y: 20,
|
|
width: 300,
|
|
height: 400,
|
|
devicePixelRatio: 1,
|
|
});
|
|
assert.equal(ctx.attached.length, 1, "view attached to host");
|
|
});
|
|
|
|
it("close() detaches any design-mode console listener stored on the entry", () => {
|
|
ctx.registry.openOrNavigate("conv_1", "https://example.com");
|
|
const entry = ctx.registry.get("conv_1");
|
|
// Simulate what browserIpc's enable-design-mode handler does: stash a
|
|
// listener + its webContents on the entry. close() must detach it so a
|
|
// destroyed view leaves no dangling console-message listener.
|
|
let removed = null;
|
|
const handler = () => {};
|
|
entry.designModeListener = handler;
|
|
entry.designModeWebContents = {
|
|
removeListener: (evt, fn) => {
|
|
removed = { evt, fn };
|
|
},
|
|
};
|
|
const r = ctx.registry.close("conv_1");
|
|
assert.equal(r.removed, true);
|
|
assert.deepEqual(removed, { evt: "console-message", fn: handler });
|
|
assert.equal(entry.designModeListener, null);
|
|
assert.equal(entry.designModeWebContents, null);
|
|
});
|
|
});
|
|
|
|
// Build a registry whose stub views record every loadURL call, so we can
|
|
// assert the agent-navigation allowlist blocks BEFORE loadURL is reached.
|
|
function makeLoadTrackingRegistry() {
|
|
const loaded = []; // urls passed to loadURL
|
|
const registry = createBrowserViewRegistry({
|
|
WebContentsViewCtor: () => ({
|
|
setBounds() {},
|
|
webContents: {
|
|
loadURL(u) {
|
|
loaded.push(u);
|
|
},
|
|
close() {},
|
|
removeListener() {},
|
|
on() {},
|
|
setWindowOpenHandler() {},
|
|
},
|
|
}),
|
|
createBoundsController: createBrowserViewBoundsController,
|
|
attachToHost() {},
|
|
detachFromHost() {},
|
|
sendToRenderer() {},
|
|
getHostZoomFactor: () => 1,
|
|
});
|
|
return { registry, loaded };
|
|
}
|
|
|
|
describe("browserViewRegistry — agent-navigation allowlist", () => {
|
|
it("rejects agent navigation to file:// and never calls loadURL", () => {
|
|
const { registry, loaded } = makeLoadTrackingRegistry();
|
|
const r = registry.openOrNavigate("conv_1", "file:///home/user/.ssh/id_rsa", undefined, {
|
|
agent: true,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /navigation blocked/);
|
|
assert.equal(loaded.length, 0, "loadURL must not run on a rejected agent nav");
|
|
});
|
|
|
|
it("rejects agent navigation to the cloud-metadata IP (169.254.169.254)", () => {
|
|
const { registry, loaded } = makeLoadTrackingRegistry();
|
|
const r = registry.openOrNavigate(
|
|
"conv_1",
|
|
"http://169.254.169.254/latest/meta-data/",
|
|
undefined,
|
|
{
|
|
agent: true,
|
|
},
|
|
);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(loaded.length, 0);
|
|
});
|
|
|
|
it("rejects agent navigation to localhost", () => {
|
|
const { registry, loaded } = makeLoadTrackingRegistry();
|
|
const r = registry.openOrNavigate("conv_1", "http://localhost:6767/health", undefined, {
|
|
agent: true,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(loaded.length, 0);
|
|
});
|
|
|
|
it("allows agent navigation to a normal public https URL (loadURL runs)", () => {
|
|
const { registry, loaded } = makeLoadTrackingRegistry();
|
|
const r = registry.openOrNavigate("conv_1", "https://example.com/", undefined, {
|
|
agent: true,
|
|
});
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(loaded, ["https://example.com/"]);
|
|
});
|
|
|
|
it("does NOT gate the user-typed URL-bar path (agent flag absent) — file:// still loads", () => {
|
|
// The URL bar is user-initiated; the strict allowlist is scoped to the
|
|
// agent path only. A user who types a local address keeps that ability.
|
|
const { registry, loaded } = makeLoadTrackingRegistry();
|
|
const r = registry.openOrNavigate("conv_1", "file:///tmp/report.html", undefined, {
|
|
force: true,
|
|
});
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(loaded, ["file:///tmp/report.html"]);
|
|
});
|
|
});
|
|
|
|
// A stub view that captures the webContents event handlers (will-navigate /
|
|
// will-redirect / will-frame-navigate) and the window-open handler, so tests
|
|
// can fire a redirect and assert whether it was cancelled (preventDefault).
|
|
function makeEventCapturingRegistry() {
|
|
const sent = []; // { channel, payload }
|
|
let handlers; // { [event]: fn } for the single created view
|
|
let windowOpenHandler;
|
|
const registry = createBrowserViewRegistry({
|
|
WebContentsViewCtor: () => {
|
|
handlers = {};
|
|
return {
|
|
setBounds() {},
|
|
webContents: {
|
|
loadURL() {},
|
|
close() {},
|
|
removeListener() {},
|
|
on(event, fn) {
|
|
handlers[event] = fn;
|
|
},
|
|
setWindowOpenHandler(fn) {
|
|
windowOpenHandler = fn;
|
|
},
|
|
},
|
|
};
|
|
},
|
|
createBoundsController: createBrowserViewBoundsController,
|
|
attachToHost() {},
|
|
detachFromHost() {},
|
|
sendToRenderer: (channel, payload) => sent.push({ channel, payload }),
|
|
getHostZoomFactor: () => 1,
|
|
});
|
|
return {
|
|
registry,
|
|
sent,
|
|
fire: (event, targetUrl) => {
|
|
const ev = {
|
|
url: targetUrl,
|
|
prevented: false,
|
|
preventDefault() {
|
|
this.prevented = true;
|
|
},
|
|
};
|
|
handlers[event](ev, targetUrl);
|
|
return ev;
|
|
},
|
|
windowOpen: (url) => windowOpenHandler({ url }),
|
|
};
|
|
}
|
|
|
|
describe("browserViewRegistry — redirect/nav guard (SSRF: allowlist on every hop)", () => {
|
|
it("blocks an agent-locked will-redirect to the cloud-metadata IP", () => {
|
|
const { registry, sent, fire } = makeEventCapturingRegistry();
|
|
// Agent navigates to an allowed host (locks the view to agent policy).
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
// The server 302s to the metadata endpoint — the guard must cancel it.
|
|
const ev = fire("will-redirect", "http://169.254.169.254/latest/meta-data/");
|
|
assert.equal(ev.prevented, true, "will-redirect to metadata must be preventDefault'd");
|
|
const blocked = sent.filter((s) => s.channel === "browser-nav-blocked");
|
|
assert.equal(blocked.length, 1, "a browser-nav-blocked signal is emitted");
|
|
assert.match(blocked[0].payload.error, /navigation blocked/);
|
|
});
|
|
|
|
it("blocks an agent-locked will-navigate to a loopback host", () => {
|
|
const { registry, fire } = makeEventCapturingRegistry();
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
const ev = fire("will-navigate", "http://127.0.0.1:8080/");
|
|
assert.equal(ev.prevented, true);
|
|
});
|
|
|
|
it("blocks an agent-locked will-navigate to an RFC-1918 private host", () => {
|
|
const { registry, fire } = makeEventCapturingRegistry();
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
const ev = fire("will-navigate", "http://10.1.2.3/admin");
|
|
assert.equal(ev.prevented, true);
|
|
});
|
|
|
|
it("allows an agent-locked redirect between normal public https hosts", () => {
|
|
const { registry, sent, fire } = makeEventCapturingRegistry();
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
const ev = fire("will-redirect", "https://cdn.example.net/asset");
|
|
assert.equal(ev.prevented, false, "a normal https→https redirect must not be blocked");
|
|
assert.equal(sent.filter((s) => s.channel === "browser-nav-blocked").length, 0);
|
|
});
|
|
|
|
it("does NOT block redirects on a user-typed (non-agent) navigation", () => {
|
|
const { registry, fire } = makeEventCapturingRegistry();
|
|
// User types a URL (no agent flag) → the view is NOT agent-locked, so its
|
|
// redirects — even to an internal host (auth-redirect chains) — are allowed.
|
|
registry.openOrNavigate("conv_1", "https://intranet.example.com/", undefined, {
|
|
force: true,
|
|
});
|
|
const ev = fire("will-redirect", "http://10.0.0.5/sso/callback");
|
|
assert.equal(ev.prevented, false, "user-driven nav stays permissive");
|
|
});
|
|
|
|
it("re-locks correctly: a user nav after an agent nav flips enforcement off", () => {
|
|
const { registry, fire } = makeEventCapturingRegistry();
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
// Then the user types a new address on the same view.
|
|
registry.openOrNavigate("conv_1", "https://intranet.example.com/", undefined, {
|
|
force: true,
|
|
});
|
|
const ev = fire("will-redirect", "http://192.168.1.10/");
|
|
assert.equal(ev.prevented, false, "the later user nav unlocked the view");
|
|
});
|
|
});
|
|
|
|
describe("browserViewRegistry — child window.open is denied (S3)", () => {
|
|
it("installs a window-open handler that denies every popup", () => {
|
|
const { registry, windowOpen } = makeEventCapturingRegistry();
|
|
registry.openOrNavigate("conv_1", "https://example.com/", undefined, { agent: true });
|
|
assert.deepEqual(windowOpen("https://evil.example.com/popup"), { action: "deny" });
|
|
assert.deepEqual(windowOpen("https://example.com/ok"), { action: "deny" });
|
|
});
|
|
});
|