// The `frame-ancestors` source the plugin origin names the console with. // // This exists because getting it wrong is SILENT on the server: the header is well-formed, every // curl of the plugin origin returns 200, and the only symptom is that a browser quietly refuses to // paint the frame (`ERR_BLOCKED_BY_RESPONSE`) — so the console shows an empty panel and the reason // is only in devtools. That is exactly how `http://host:47992` shipped as the permitted ancestor of // an `https://host:47992` console. import { describe, expect, test } from "bun:test"; import { frameAncestorSource, isPluginUiPath } from "./pluginOrigin"; describe("frameAncestorSource", () => { test("uses the listener's scheme, NOT the request's", () => { // The regression. Nitro's localFetch synthesises a request with no TLS socket, so the request // says `http:` on an HTTPS listener. The listener's own scheme has to win, or the browser // refuses to frame the plugin. expect( frameAncestorSource({ requestScheme: "http:", listenerScheme: "https", hostname: "192.168.1.21", port: 47992, }), ).toBe("https://192.168.1.21:47992"); }); test("a plain-HTTP console (dev) still gets http", () => { expect( frameAncestorSource({ requestScheme: "http:", listenerScheme: "http", hostname: "localhost", port: 3000, }), ).toBe("http://localhost:3000"); }); test("x-forwarded-proto wins — only a proxy knows the browser's scheme", () => { expect( frameAncestorSource({ forwardedProto: "https", requestScheme: "http:", listenerScheme: "http", hostname: "console.lan", port: 47992, }), ).toBe("https://console.lan:47992"); }); test("a multi-hop x-forwarded-proto uses the first hop", () => { expect( frameAncestorSource({ forwardedProto: "https, http", requestScheme: "http:", listenerScheme: "http", hostname: "console.lan", port: 47992, }), ).toBe("https://console.lan:47992"); }); test("a junk x-forwarded-proto is ignored rather than echoed", () => { expect( frameAncestorSource({ forwardedProto: "javascript:alert(1)", requestScheme: "http:", listenerScheme: "https", hostname: "192.168.1.21", port: 47992, }), ).toBe("https://192.168.1.21:47992"); }); test("falls back to the request scheme when nothing is stamped", () => { expect( frameAncestorSource({ listenerScheme: null, requestScheme: "https:", hostname: "host", port: 47992, }), ).toBe("https://host:47992"); }); test("keeps whatever hostname the operator browsed to", () => { // The policy has to match their address bar, not a name we prefer. for (const hostname of ["192.168.1.21", "punktfunk.local", "deck"]) { expect( frameAncestorSource({ requestScheme: "http:", listenerScheme: "https", hostname, port: 47992, }), ).toBe(`https://${hostname}:47992`); } }); }); describe("isPluginUiPath", () => { // The two refusals that keep a plugin off the console's origin depend on this split. test("claims the plugin-UI prefix", () => { expect(isPluginUiPath("/plugin-ui")).toBe(true); expect(isPluginUiPath("/plugin-ui/")).toBe(true); expect(isPluginUiPath("/plugin-ui/rom-manager/index.html")).toBe(true); }); test("claims nothing else — above all not /api", () => { expect(isPluginUiPath("/api/v1/status")).toBe(false); expect(isPluginUiPath("/")).toBe(false); expect(isPluginUiPath("/login")).toBe(false); // A near-miss must not be swept in by a loose startsWith. expect(isPluginUiPath("/plugin-uix")).toBe(false); expect(isPluginUiPath("/plugin-ui-admin")).toBe(false); }); });