feat: finish the web-endpoint plugin, add ctx.desktop (B8)

This commit is contained in:
LukeGus committed 2026-09-23 18:36:41 -05:00
1 parent 1682861853
commit 535126fd1b
50 files changed
+2018 -898

No files matched your search

@@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
),
);
const setCalls: Array<[string, string, string, string, string]> = [];
const set = vi.fn(
async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
setCalls.push([pluginId, scope, scopeId, key, value]);
},
);
vi.mock("../../../utils/logger.js", () => {
const logger = {
@@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => {
});
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
}));
vi.mock("../../../plugins/index.js", () => ({
@@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
decryptSystemSecret: async (stored: string) => stored,
}));
const registryProviders = new Map<string, unknown>();
vi.mock("../../../plugins/registry.js", () => ({
consume: (key: string) => registryProviders.get(key),
}));
const {
attachHostPluginSettings,
loadHostPluginSettings,
withHostPluginSettings,
writeHostPluginSettings,
applyPluginHostImportSettings,
} = await import("../../../database/routes/host-plugin-settings.js");
function manifest(id: string, host: unknown): PluginManifest {
@@ -80,7 +99,10 @@ const DOCKER = manifest("docker", {
beforeEach(() => {
rows.length = 0;
loaded.length = 0;
setCalls.length = 0;
registryProviders.clear();
getAllForScopeIds.mockClear();
set.mockClear();
});
describe("loadHostPluginSettings", () => {
@@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => {
expect(await withHostPluginSettings(host)).toBe(host);
});
});
describe("writeHostPluginSettings", () => {
it("JSON-stringifies each value under the plugin's own namespace", async () => {
await writeHostPluginSettings("docker", 7, {
enableDocker: true,
socketPath: null,
});
expect(setCalls).toEqual([
["docker", "host", "7", "enableDocker", "true"],
["docker", "host", "7", "socketPath", "null"],
]);
});
it("skips a field whose value is undefined", async () => {
await writeHostPluginSettings("docker", 7, {
enableDocker: true,
socketPath: undefined,
});
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
});
});
describe("applyPluginHostImportSettings", () => {
it("runs every enabled plugin's registered normalizer and writes what it returns", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
loaded.push({
id: "web-endpoint",
manifest: manifest("web-endpoint", {
enableKey: "enableWebUi",
enableLabelKey: "k",
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
}),
state: "active",
});
registryProviders.set("docker.hostImportNormalizer", () => ({
enableDocker: true,
}));
registryProviders.set("web-endpoint.hostImportNormalizer", () => null);
await applyPluginHostImportSettings(7, { enableDocker: true });
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
});
it("skips a plugin with no registered normalizer", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
await applyPluginHostImportSettings(7, {});
expect(setCalls).toEqual([]);
});
it("logs and continues past a normalizer that throws", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
loaded.push({
id: "web-endpoint",
manifest: manifest("web-endpoint", {
enableKey: "enableWebUi",
enableLabelKey: "k",
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
}),
state: "active",
});
registryProviders.set("docker.hostImportNormalizer", () => {
throw new Error("boom");
});
registryProviders.set("web-endpoint.hostImportNormalizer", () => ({
enableWebUi: true,
}));
await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined();
expect(setCalls).toEqual([
["web-endpoint", "host", "7", "enableWebUi", "true"],
]);
});
});
@@ -0,0 +1,168 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createIsolatedWindows } = createRequire(import.meta.url)(
"../../../../electron/isolated-window.cjs",
);
const created: FakeWindow[] = [];
class FakeWindow extends EventEmitter {
options: Record<string, unknown>;
destroyed = false;
webContents = Object.assign(new EventEmitter(), {
mainFrame: {},
setWindowOpenHandler: vi.fn(),
});
setMenu = vi.fn();
loadURL = vi.fn().mockResolvedValue(undefined);
constructor(options: Record<string, unknown>) {
super();
this.options = options;
created.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
this.emit("closed");
}
}
let main: FakeWindow;
type TestSession = EventEmitter & {
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
clearStorageData: ReturnType<typeof vi.fn>;
clearCache: ReturnType<typeof vi.fn>;
closeAllConnections: ReturnType<typeof vi.fn>;
};
let sessions: TestSession[];
let fromPartition: ReturnType<typeof vi.fn>;
let manager: ReturnType<typeof createIsolatedWindows>;
beforeEach(() => {
created.length = 0;
sessions = [];
main = new FakeWindow({});
created.length = 0;
fromPartition = vi.fn(() => {
const session = Object.assign(new EventEmitter(), {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
webRequest: { onBeforeRequest: vi.fn() },
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined),
closeAllConnections: vi.fn().mockResolvedValue(undefined),
});
sessions.push(session);
return session;
});
manager = createIsolatedWindows({
BrowserWindow: FakeWindow,
session: { fromPartition },
getMainWindow: () => main,
});
});
describe("isolated windows", () => {
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
await manager.open({ url: "https://service.test" });
await manager.open({ url: "https://service.test" });
expect(fromPartition.mock.calls[0][0]).not.toBe(
fromPartition.mock.calls[1][0],
);
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
expect(created[0].options.webPreferences).toEqual({
session: sessions[0],
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
webSecurity: true,
});
});
it("uses a caller-supplied partition when given one", async () => {
await manager.open({ url: "https://service.test", partition: "fixed" });
expect(fromPartition.mock.calls[0][0]).toBe("fixed");
});
it("rejects non-web destinations before creating a session", async () => {
for (const url of [
"file:///etc/passwd",
"javascript:alert(1)",
"https://user:secret@service.test",
]) {
await expect(manager.open({ url })).rejects.toThrow();
}
expect(fromPartition).not.toHaveBeenCalled();
});
it("refuses when there is no window to attach to", async () => {
manager = createIsolatedWindows({
BrowserWindow: FakeWindow,
session: { fromPartition },
getMainWindow: () => null,
});
await expect(manager.open({ url: "https://service.test" })).rejects.toThrow(
/No window/,
);
expect(fromPartition).not.toHaveBeenCalled();
});
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
await manager.open({ url: "https://service.test" });
const win = created[0],
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
expect(
open({ url: "https://login.test" }).overrideBrowserWindowOptions
.webPreferences.session,
).toBe(sessions[0]);
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
const navigation = { preventDefault: vi.fn() };
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
expect(navigation.preventDefault).toHaveBeenCalled();
const callback = vi.fn();
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
{ url: "file:///tmp/secret" },
callback,
);
expect(callback).toHaveBeenCalledWith({ cancel: true });
});
it("limits invalid certificates to the opted-in origin, including its port", async () => {
await manager.open({
url: "https://service.test:8443",
ignoreCert: true,
});
const e = { preventDefault: vi.fn() },
callback = vi.fn();
expect(
manager.handleCertificateError(
e,
created[0].webContents,
"https://service.test:8443/path",
callback,
),
).toBe(true);
expect(callback).toHaveBeenLastCalledWith(true);
manager.handleCertificateError(
e,
created[0].webContents,
"https://service.test:9443",
callback,
);
expect(callback).toHaveBeenLastCalledWith(false);
expect(
manager.handleCertificateError(
e,
main.webContents,
"https://service.test:8443",
callback,
),
).toBe(false);
});
it("closes login popups and clears session data when the endpoint closes", async () => {
await manager.open({ url: "https://service.test" });
const root = created[0],
popup = new FakeWindow({});
root.webContents.emit("did-create-window", popup);
root.destroy();
expect(popup.isDestroyed()).toBe(true);
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,123 @@
/**
* ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and
* the call only reaches Electron's main process when the backend is actually
* running embedded in it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const bridge = vi.hoisted(() => ({
available: false,
requestFromElectronMain: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
isElectronIpcAvailable: () => bridge.available,
requestFromElectronMain: bridge.requestFromElectronMain,
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
return {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
}
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = manifestFor(pluginId, capabilities);
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
bridge.available = false;
bridge.requestFromElectronMain.mockClear();
});
describe("ctx.desktop.openIsolatedWindow", () => {
it("refuses without desktop:window, before reaching Electron", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop:window/);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_open_isolated_window",
success: false,
});
});
it("refuses when granted but not declared in the manifest", async () => {
grants.set("demo", ["desktop:window"]);
const { ctx } = contextFor("demo", []);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop:window/);
});
it("refuses outside the desktop app even with the capability", async () => {
grants.set("demo", ["desktop:window"]);
bridge.available = false;
const { ctx } = contextFor("demo", ["desktop:window"]);
await expect(
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
).rejects.toThrow(/desktop app/);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
});
it("relays to Electron main and audits success", async () => {
grants.set("demo", ["desktop:window"]);
bridge.available = true;
const { ctx } = contextFor("demo", ["desktop:window"]);
const request = { url: "https://example.test", title: "Example" };
await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({
success: true,
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"open-isolated-window",
request,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_open_isolated_window",
success: true,
});
});
});
@@ -0,0 +1,130 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
isElectronIpcAvailable,
requestFromElectronMain,
} from "../../utils/electron-ipc-bridge.js";
const originalEnv = process.env.ELECTRON_EMBEDDED;
const originalSend = process.send;
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
});
afterEach(() => {
process.env.ELECTRON_EMBEDDED = originalEnv;
process.send = originalSend;
});
describe("isElectronIpcAvailable", () => {
it("is false outside the embedded desktop backend", () => {
delete process.env.ELECTRON_EMBEDDED;
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is false when there is no fork IPC channel", () => {
process.send = undefined;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is true when embedded with a live channel", () => {
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(true);
});
});
describe("requestFromElectronMain", () => {
it("refuses when Electron IPC is unavailable", async () => {
process.send = undefined;
await expect(
requestFromElectronMain("open-isolated-window", {}),
).rejects.toThrow(/desktop app/);
});
// The module attaches its "message" listener to the real process.on only
// once, the first time requestFromElectronMain actually reaches that point
// (ensureListening's module-scoped flag mirrors how a real forked
// process's IPC channel is a single persistent thing it listens on for the
// process lifetime). vi.spyOn(process, "on") does not reliably intercept
// that call in this environment, so this replaces process.on directly for
// the one call the module makes and hands the captured listener a plain
// function this file can invoke to simulate a message from main -
// simpler and more direct than getting a spy to survive across a Node
// global.
let deliver: (msg: unknown) => void = () => {};
beforeAll(() => {
const originalOn = process.on.bind(process);
process.on = ((event: string, listener: (...args: unknown[]) => void) => {
if (event === "message") {
deliver = listener;
return process;
}
return originalOn(event, listener);
}) as typeof process.on;
});
let sent: unknown[] = [];
beforeEach(() => {
sent = [];
process.send = ((msg: unknown) => {
sent.push(msg);
return true;
}) as typeof process.send;
});
it("resolves with main's response, matched by request id", async () => {
const pending = requestFromElectronMain<{ success: true }>(
"open-isolated-window",
{ url: "https://example.test" },
);
const [message] = sent as Array<{
type: string;
id: string;
channel: string;
payload: unknown;
}>;
expect(message).toMatchObject({
type: "backend-request",
channel: "open-isolated-window",
payload: { url: "https://example.test" },
});
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
it("rejects with main's error", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({
type: "backend-response",
id: message.id,
ok: false,
error: "refused",
});
await expect(pending).rejects.toThrow(/refused/);
});
it("ignores a response for a different request id", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({ type: "backend-response", id: "other", ok: true, result: {} });
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
});
@@ -0,0 +1,184 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});