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

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
const manifest = manifestJson as unknown as PluginManifest;
let mock: MockPluginContext | null = null;
afterEach(async () => {
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
mock = null;
});
function activateWith(capabilities: string[]) {
mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities,
router: () => express.Router(),
});
return activate(mock.ctx);
}
describe("web-endpoint activate", () => {
it("mounts its router at /plugin-api/web-endpoint", async () => {
await activateWith(manifest.capabilities);
expect(mock?.httpRouters).toHaveLength(1);
});
it("registers a hostImportNormalizer and revokes it on deactivate", async () => {
await activateWith(manifest.capabilities);
expect(
typeof mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
).toBe("function");
for (const dispose of [...(mock?.disposals ?? [])].reverse())
await dispose();
expect(
mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
).toBeUndefined();
});
it("fails closed without network:serve", async () => {
await expect(
activateWith(manifest.capabilities.filter((c) => c !== "network:serve")),
).rejects.toThrow(/network:serve/);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
describe("hostImportNormalizer", () => {
it("returns null when the row carries neither field", () => {
expect(hostImportNormalizer({})).toBeNull();
});
it("normalizes and validates webUiConfig, dropping invalid endpoints", () => {
const result = hostImportNormalizer({
enableWebUi: true,
webUiConfig: {
endpoints: [
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
access: "direct",
render: "embedded",
},
{
id: "e2",
label: "",
scheme: "https",
port: 1,
access: "direct",
render: "embedded",
},
],
},
});
expect(result?.enableWebUi).toBe(true);
expect(result?.webUiConfig).toEqual({
endpoints: [
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
ignoreCert: false,
},
],
});
});
it("clears webUiConfig to null when enabling with no endpoints", () => {
const result = hostImportNormalizer({ enableWebUi: true });
expect(result).toEqual({ enableWebUi: true, webUiConfig: null });
});
it("normalizes enableWebUi to a boolean", () => {
expect(hostImportNormalizer({ enableWebUi: "yes" })).toEqual({
enableWebUi: true,
webUiConfig: null,
});
});
});
@@ -1,106 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* This codebase enumerates host columns by hand in a lot of places, and
* "added a column, missed one update path" is its most repeated bug -- during
* the first attempt at this feature a field was missed at one of these points
* six separate times, each surfacing as a different mystery (endpoints that
* saved but never appeared, config that vanished on reload).
*
* Every one of these files already enumerates the Docker pair, which is the
* exact shape the web endpoint pair copies. So "mentions enableDocker but not
* enableWebUi" is a reliable proxy for "was not updated", and it fails loudly
* the moment someone adds the tenth enumeration point without this one.
*
* This is a coarse guard on purpose: it cannot tell whether the field is used
* CORRECTLY, only that the file knows it exists. Behavioural coverage lives in
* the route tests.
*/
// Repo-root relative, resolved from this file rather than cwd: the plugin
// suite runs with the plugin directory as cwd.
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../..",
);
function source(relative: string): string {
return readFileSync(path.resolve(REPO_ROOT, relative), "utf8");
}
const ENUMERATION_POINTS = [
"src/backend/database/database.ts",
"src/backend/database/db/index.ts",
"src/backend/database/routes/host-normalizers.ts",
"src/backend/database/routes/host.ts",
"src/backend/database/routes/host-bulk-routes.ts",
"src/ui/sidebar/host-export-payload.ts",
"src/ui/sidebar/HostManagerData.ts",
"src/ui/sidebar/HostEditorData.ts",
"src/ui/lib/host-to-ssh-host.ts",
];
describe("web endpoint column enumeration", () => {
it.each(ENUMERATION_POINTS)(
"%s enumerates the enable flag alongside enableDocker",
(file) => {
const text = source(file);
// Guards the guard: if the Docker anchor ever disappears from a file,
// this test would otherwise pass vacuously forever.
expect(text).toMatch(/enableDocker|enable_docker/);
expect(text).toMatch(/enableWebUi|enable_web_ui/);
},
);
it.each(ENUMERATION_POINTS)(
"%s enumerates the config column alongside dockerConfig",
(file) => {
const text = source(file);
expect(text).toMatch(/dockerConfig|docker_config/);
expect(text).toMatch(/webUiConfig|web_ui_config/);
},
);
it("parses webUiConfig through the guarded parser, not a bare JSON.parse", () => {
// dockerConfig uses a bare JSON.parse, so one malformed value takes out
// the whole host listing. Do not copy that.
const text = source("src/backend/database/routes/host-normalizers.ts");
expect(text).toContain("parseWebUiConfig");
expect(text).not.toMatch(/JSON\.parse\(\s*host\.webUiConfig/);
});
it("clears webUiConfig on disable in both host.ts write paths", () => {
// Three write paths disagreed on this during the first attempt. The export
// payload ships webUiConfig unconditionally, so a host disabled without
// clearing still exports its endpoint list -- internal hostnames, ports and
// paths -- while the UI reads as off everywhere, and re-enabling silently
// resurrects stale endpoints.
const text = source("src/backend/database/routes/host.ts");
// Create and update. Anchored on the exact conditional, not a loose
// pattern: a looser regex here passed even with the guard deleted.
const occurrences = text.split("webUiConfig: enableWebUi").length - 1;
expect(occurrences).toBe(2);
});
it("clears webUiConfig on a bulk disable", () => {
const text = source("src/backend/database/routes/host-bulk-routes.ts");
expect(text).toContain(
"if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;",
);
});
it("shares webUiConfig with connect-level recipients", () => {
// A connect-level recipient is ALREADY authorized to open these tunnels
// (the open route gates on canAccessHost(..., "connect")), so withholding
// the config only breaks discovery while the flag advertises the feature.
// The dockerConfig precedent does not transfer: Docker's tab works without
// its config, whereas a web endpoint IS its config.
const text = source("src/backend/database/routes/host-normalizers.ts");
const block = text.slice(text.indexOf("CONNECT_LEVEL_FIELDS"));
const list = block.slice(0, block.indexOf("]"));
expect(list).toContain("enableWebUi");
expect(list).toContain("webUiConfig");
});
});
+210 -133
View File
@@ -1,135 +1,160 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { createWebEndpointRoutes } from "../../src/backend/routes.js";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById,
}));
const manifest = manifestJson as unknown as PluginManifest;
const forward = vi.fn();
const openIsolatedWindow = vi.fn();
function response() {
const res = {
statusCode: 0,
body: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
};
return res as unknown as express.Response & {
statusCode: number;
body: { port?: number; error?: string };
function endpoint(overrides: Record<string, unknown> = {}) {
return {
id: "e1",
label: "Proxmox",
scheme: "http",
port: 8006,
path: "/",
access: "tunnel",
render: "embedded",
...overrides,
};
}
function request(body: unknown) {
return { body } as unknown as express.Request;
}
const endpoint = (overrides: Record<string, unknown> = {}) => ({
id: "e1",
label: "Proxmox",
scheme: "http",
port: 8006,
path: "/",
access: "tunnel",
render: "embedded",
...overrides,
});
function host(overrides: Record<string, unknown> = {}) {
return {
id: 7,
userId: "u1",
name: "nas",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
enableWebUi: true,
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
...overrides,
};
}
/** Activates the routes against a fake ctx, with or without the tunnels service. */
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
const fake = createFakeContext({
let mock: MockPluginContext;
let server: http.Server;
let baseUrl: string;
async function start(
options: {
hostOverrides?: Record<string, unknown>;
noHost?: boolean;
enableWebUi?: boolean;
endpoints?: unknown[];
capabilities?: string[];
tunnelsAvailable?: boolean;
} = {},
) {
mock = createMockCtx({
manifest,
pluginId: "web-endpoint",
actor: options.actor ?? "u1",
actor: "u1",
capabilities: options.capabilities ?? [
"hosts:read",
"network:serve",
"ui:surface",
"desktop:window",
],
hosts: options.noHost ? [] : [host(options.hostOverrides)],
});
if (options.tunnels !== false) {
fake.ctx.services.provide("tunnels.access", { forward });
const hostId = (options.hostOverrides?.id as number) ?? 7;
if (options.enableWebUi !== false) {
await mock.ctx.settings.setHost(hostId, "enableWebUi", true);
await mock.ctx.settings.setHost(hostId, "webUiConfig", {
endpoints: options.endpoints ?? [endpoint()],
});
}
const routes = await import("../../src/backend/routes.js");
routes.startWebEndpointService(express.Router(), fake.ctx);
return routes;
if (options.tunnelsAvailable !== false) {
mock.ctx.services.provide("tunnels.access", { forward });
}
const app = express();
app.use(express.json());
app.use(createWebEndpointRoutes(mock.ctx));
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
}
beforeEach(() => {
forward.mockReset();
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
resolveHostById.mockReset();
resolveHostById.mockResolvedValue(host());
openIsolatedWindow.mockReset();
openIsolatedWindow.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.resetModules();
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("POST /open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(403);
await start({ noHost: true });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(403);
expect(forward).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(400);
await start({ enableWebUi: false });
await mock.ctx.settings.setHost(7, "webUiConfig", {
endpoints: [endpoint()],
});
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(400);
expect(forward).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
res,
);
expect(res.statusCode).toBe(400);
await start();
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "nope" }),
});
expect(res.status).toBe(400);
});
it("returns the port the tunnels service bound", async () => {
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(200);
expect(res.body.port).toBe(41234);
await start();
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(200);
expect((await res.json()).port).toBe(41234);
});
it("asks for a loopback forward under the reserved web name", async () => {
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
await start();
await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(forward).toHaveBeenCalledWith(
7,
{
@@ -143,85 +168,137 @@ describe("POST /open", () => {
});
it("passes the endpoint's own bind address and fixed port through", async () => {
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
}),
}),
);
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
await start({
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
});
await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
const target = forward.mock.calls[0][1];
expect(target.bindHost).toBe("0.0.0.0");
expect(target.bindPort).toBe(38080);
});
it("reports the service's reason as a 502 instead of a dead 200", async () => {
await start();
forward.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/connect failed/);
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(502);
expect(String((await res.json()).error)).toMatch(/connect failed/);
});
it("answers 503 while the tunnels plugin is not available", async () => {
const { handleWebEndpointOpen } = await load({ tunnels: false });
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(503);
await start({ tunnelsAvailable: false });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(503);
});
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } = await load();
it("rejects a malformed request before touching settings", async () => {
await start();
const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost");
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
{ hostId: 0, endpointId: "e1" },
]) {
const res = response();
await handleWebEndpointOpen(request(body), res);
expect(res.statusCode).toBe(400);
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
expect(res.status).toBe(400);
}
expect(resolveHostById).not.toHaveBeenCalled();
expect(getHostSpy).not.toHaveBeenCalled();
});
it("refuses a direct-access endpoint", async () => {
await start({ endpoints: [endpoint({ access: "direct" })] });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(400);
});
});
/**
* The client path and the server path have to agree. Core mounts every
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
* and the only half this plugin owns is "/open".
*/
describe("route registration", () => {
it("registers /open on the router core hands it", async () => {
const routes = await load();
describe("POST /open-window", () => {
it("refuses without desktop:window", async () => {
await start({
capabilities: ["hosts:read", "network:serve", "ui:surface"],
});
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(502);
});
it("opens a tunnel first, then the window at the resolved port", async () => {
await start();
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(200);
expect(forward).toHaveBeenCalledOnce();
expect(mock.desktopWindows).toEqual([
{ url: "http://127.0.0.1:41234/", title: "Proxmox", ignoreCert: false },
]);
});
it("opens a direct endpoint at the host's own address without a tunnel", async () => {
await start({
endpoints: [endpoint({ access: "direct", scheme: "https", port: 443 })],
});
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
});
expect(res.status).toBe(200);
expect(forward).not.toHaveBeenCalled();
expect(mock.desktopWindows).toEqual([
{ url: "https://10.0.0.5:443/", title: "Proxmox", ignoreCert: true },
]);
});
it("ignores ignoreCert for a tunnel endpoint, whose host component is already loopback", async () => {
await start();
await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
});
expect(mock.desktopWindows[0].ignoreCert).toBe(false);
});
});
describe("route registration", () => {
it("registers /open and /open-window", async () => {
await start();
const routes = createWebEndpointRoutes(mock.ctx);
const paths = (
routes.router as unknown as {
routes as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
}
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is reached through the plugin mount rather than a port of its own", async () => {
const source = await readFile(
new URL("../../src/backend/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain("ctx.http.router()");
expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"]));
});
});
@@ -4,7 +4,7 @@ import {
normalizeWebEndpoints,
parseWebUiConfig,
serializeWebUiConfig,
} from "../../../../src/backend/database/routes/host-web-endpoints.js";
} from "../../src/shared/web-endpoint-config.js";
function valid(overrides: Record<string, unknown> = {}) {
return {
@@ -249,8 +249,7 @@ describe("parseWebUiConfig", () => {
});
it("returns empty endpoints for malformed JSON instead of throwing", () => {
// A half-written config must not take out the whole host listing, which
// is what dockerConfig's bare JSON.parse does today.
// A half-written config must not take out the whole host listing.
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
});
@@ -263,8 +262,7 @@ describe("parseWebUiConfig", () => {
describe("serializeWebUiConfig", () => {
it("round-trips through normalization", () => {
expect(
parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] }))
.endpoints[0].id,
serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id,
).toBe("e1");
});
@@ -1,165 +0,0 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createWebEndpointWindows } = createRequire(import.meta.url)(
"../../../../electron/web-endpoint-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 createWebEndpointWindows>;
function event() {
return { sender: main.webContents, senderFrame: main.webContents.mainFrame };
}
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 = createWebEndpointWindows({
BrowserWindow: FakeWindow,
session: { fromPartition },
getMainWindow: () => main,
});
});
describe("isolated endpoint windows", () => {
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
await manager.open(event(), { url: "https://service.test" });
await manager.open(event(), { 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("rejects subframes, other windows and non-web destinations before creating a session", async () => {
await expect(
manager.open(
{ ...event(), senderFrame: {} },
{ url: "https://service.test" },
),
).rejects.toThrow(/main Termix/);
await expect(
manager.open({ ...event(), sender: {} }, { url: "https://service.test" }),
).rejects.toThrow(/main Termix/);
for (const url of [
"file:///etc/passwd",
"javascript:alert(1)",
"https://user:secret@service.test",
]) {
await expect(manager.open(event(), { url })).rejects.toThrow();
}
expect(fromPartition).not.toHaveBeenCalled();
});
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
await manager.open(event(), { 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(event(), {
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(event(), { 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();
});
});
@@ -1,186 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { WebEndpoint, WebUiConfig } from "@/types/index";
const isElectron = vi.hoisted(() => vi.fn(() => true));
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
}),
}));
import { HostEditorWebUiSection } from "../../src/frontend/HostEditorWebUiSection";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
function setup(
config: WebUiConfig = { endpoints: [] },
{
enableWebUi = true,
tunnelAvailable = true,
}: { enableWebUi?: boolean; tunnelAvailable?: boolean } = {},
) {
const setField = vi.fn();
render(
<HostEditorWebUiSection
enableWebUi={enableWebUi}
webUiConfig={config}
tunnelAvailable={tunnelAvailable}
setField={setField}
/>,
);
return setField;
}
beforeEach(() => {
isElectron.mockReturnValue(true);
});
afterEach(cleanup);
describe("HostEditorWebUiSection", () => {
it("hides the endpoint list until the feature is enabled", () => {
setup({ endpoints: [endpoint()] }, { enableWebUi: false });
expect(screen.queryByLabelText("hosts.webUiLabel")).not.toBeInTheDocument();
expect(
screen.queryByText("hosts.webUiAddEndpoint"),
).not.toBeInTheDocument();
});
it("adds an endpoint with a non-colliding label", () => {
const setField = setup({
endpoints: [endpoint({ label: "hosts.webUiNewEndpointLabel" })],
});
fireEvent.click(screen.getByText("hosts.webUiAddEndpoint"));
const [, value] = setField.mock.calls[0];
const added = (value as WebUiConfig).endpoints[1];
// Labels identify an endpoint in the sidebar picker, so a fresh row must
// not silently duplicate one already there.
expect(added.label).toBe("hosts.webUiNewEndpointLabel 2");
expect(added.id).toBeTruthy();
});
it("stops adding at the cap", () => {
const many = Array.from({ length: 16 }, (_, i) =>
endpoint({ id: `e${i}`, label: `E${i}` }),
);
setup({ endpoints: many });
expect(screen.getByText("hosts.webUiAddEndpoint")).toBeDisabled();
});
it("refuses to commit a port the normalizer would drop", () => {
// Number("") is 0, which the normalizer rejects -- committing it would
// silently discard the endpoint on save with no error shown.
const setField = setup({ endpoints: [endpoint()] });
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
target: { value: "" },
});
expect(setField).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
target: { value: "9000" },
});
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].port).toBe(
9000,
);
});
it("shows the bind-host fields only for a tunnel endpoint", () => {
setup({ endpoints: [endpoint({ access: "direct" })] });
expect(screen.queryByLabelText("hosts.bindHost")).not.toBeInTheDocument();
cleanup();
setup({ endpoints: [endpoint({ access: "tunnel" })] });
expect(screen.getByLabelText("hosts.bindHost")).toBeInTheDocument();
expect(screen.getByLabelText("hosts.webUiLocalPort")).toBeInTheDocument();
});
it("warns that a non-loopback bind is unauthenticated exposure", () => {
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
expect(screen.getByText("hosts.webUiBindHostExposed")).toBeInTheDocument();
});
it("does not warn about exposure for a loopback bind", () => {
setup({
endpoints: [endpoint({ access: "tunnel", bindHost: "127.0.0.1" })],
});
expect(
screen.queryByText("hosts.webUiBindHostExposed"),
).not.toBeInTheDocument();
});
it("warns at config time that a loopback bind is unreachable from a browser", () => {
isElectron.mockReturnValue(false);
setup({ endpoints: [endpoint({ access: "tunnel" })] });
// Said while configuring, not only when the tab fails to load.
expect(
screen.getByText("hosts.webUiBindHostUnreachable"),
).toBeInTheDocument();
});
it("warns at config time about the session-cookie collision", () => {
// The page host in jsdom is "localhost", which HAS a loopback alias, so
// force a host that does not to reach the second refusal.
isElectron.mockReturnValue(false);
const realLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: "termix.example.com" }),
});
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
expect(
screen.getByText("hosts.webUiBindHostSharesSessionCookie"),
).toBeInTheDocument();
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
});
it("explains why tunnelling is unavailable rather than leaving a dead option", () => {
// A disabled control with no stated reason reads as the dropdown being
// broken.
setup({ endpoints: [endpoint()] }, { tunnelAvailable: false });
expect(
screen.getByText("hosts.webUiAccessTunnelUnavailable"),
).toBeInTheDocument();
});
it("reports a row the normalizer would drop", () => {
setup({ endpoints: [endpoint({ label: " " })] });
expect(
screen.getByText(/hosts.webUiErrorLabelRequired/),
).toBeInTheDocument();
});
it("removes an endpoint", () => {
const setField = setup({
endpoints: [endpoint(), endpoint({ id: "e2", label: "NAS" })],
});
fireEvent.click(screen.getAllByLabelText("hosts.webUiRemoveEndpoint")[0]);
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints).toHaveLength(
1,
);
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].id).toBe(
"e2",
);
});
});
@@ -7,7 +7,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import type { WebEndpoint } from "@/types/index";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
import type { Host } from "@/types/ui-types";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
@@ -59,7 +59,7 @@ function host(
return {
id: "7",
ip: "192.168.1.10",
webUiConfig: { endpoints },
pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } },
...overrides,
} as unknown as Host;
}
@@ -1,14 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
import type { Host } from "@/types/ui-types";
import type { WebEndpoint } from "@/types";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
@@ -25,13 +27,19 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
};
}
function host(overrides: Partial<Host> = {}): Host {
function host(
endpoints: WebEndpoint[] | undefined,
overrides: Partial<Host> = {},
): Host {
return {
id: "7",
ip: "10.0.0.5",
name: "nas",
enableSsh: false,
enableWebUi: false,
pluginSettings:
endpoints === undefined
? {}
: { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } },
...overrides,
} as unknown as Host;
}
@@ -58,37 +66,32 @@ describe("web-endpoint activate", () => {
});
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
expect(
await webAction(
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
),
).toBeUndefined();
const target = host([endpoint()], {
pluginSettings: {
"web-endpoint": {
enableWebUi: false,
webUiConfig: { endpoints: [endpoint()] },
},
},
});
expect(await webAction(target)).toBeUndefined();
});
it("offers no entry when enabled but no endpoints exist", async () => {
expect(
await webAction(
host({ enableWebUi: true, webUiConfig: { endpoints: [] } }),
),
).toBeUndefined();
expect(await webAction(host([]))).toBeUndefined();
});
it("offers no entry when the host has no web-endpoint settings at all", async () => {
expect(await webAction(host(undefined))).toBeUndefined();
});
it("appears without SSH", async () => {
const action = await webAction(
host({
enableSsh: false,
enableWebUi: true,
webUiConfig: { endpoints: [endpoint()] },
}),
);
const action = await webAction(host([endpoint()], { enableSsh: false }));
expect(action).toBeDefined();
});
it("labels the entry with the endpoint when there is only one", async () => {
const target = host({
enableWebUi: true,
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
});
const target = host([endpoint({ label: "Proxmox" })]);
const action = await webAction(target);
expect(action?.label?.(target)).toBe("Proxmox");
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
@@ -98,17 +101,14 @@ describe("web-endpoint activate", () => {
const endpoints = Array.from({ length: 16 }, (_, i) =>
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
);
const target = host({ enableWebUi: true, webUiConfig: { endpoints } });
const target = host(endpoints);
const action = await webAction(target);
expect(action?.label?.(target)).toBeUndefined();
expect(action?.items?.(target)).toHaveLength(16);
});
it("opens an embedded endpoint as a tab carrying its id", async () => {
const target = host({
enableWebUi: true,
webUiConfig: { endpoints: [endpoint()] },
});
const target = host([endpoint()]);
const action = await webAction(target);
const calls: unknown[][] = [];
const shell = {
@@ -124,3 +124,36 @@ describe("web-endpoint activate", () => {
]);
});
});
describe("host editor section", () => {
it("writes into the form's web-endpoint plugin settings", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
let form: Record<string, unknown> = {
name: "nas",
pluginSettings: { "web-endpoint": { enableWebUi: false } },
};
const updateForm = vi.fn(
(
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
form = patch(form);
},
);
const section = rendered.renderHostEditorSection("web-ui", {
form,
setField: vi.fn(),
updateForm,
protocols: { enableSsh: true },
});
// The enable switch is the only button on the section before any
// endpoint rows exist (which only render once enabled).
fireEvent.click(section.querySelector("button") as HTMLButtonElement);
const settings = (form.pluginSettings as Record<string, unknown>)[
"web-endpoint"
] as { enableWebUi: boolean };
expect(settings.enableWebUi).toBe(true);
});
});
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebEndpoint } from "@/types/index";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
const tunnelPost = vi.hoisted(() => vi.fn());
const pluginPost = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
handleApiError: (error: unknown) => {
throw new Error(`generic: ${String(error)}`);
@@ -27,32 +27,17 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
const host = { id: "7", ip: "192.168.1.10" };
let pageHostname = "localhost";
const realLocation = window.location;
const windowOpen = vi.fn();
beforeEach(async () => {
const { setWebEndpointApi } =
await import("../../src/frontend/web-endpoint-api");
setWebEndpointApi({ post: tunnelPost } as never);
pageHostname = "localhost";
setWebEndpointApi({ post: pluginPost } as never);
isElectron.mockReturnValue(false);
tunnelPost.mockReset();
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
vi.stubGlobal("open", windowOpen);
pluginPost.mockReset();
pluginPost.mockResolvedValue({ data: { port: 41234 } });
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
vi.unstubAllGlobals();
windowOpen.mockReset();
});
describe("openWebEndpointTunnel", () => {
@@ -63,7 +48,7 @@ describe("openWebEndpointTunnel", () => {
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost).toHaveBeenCalledWith("/open", {
expect(pluginPost).toHaveBeenCalledWith("/open", {
hostId: 7,
endpointId: "e1",
});
@@ -86,7 +71,7 @@ describe("openWebEndpointTunnel", () => {
data: { error: "Timed out reaching the endpoint port" },
},
});
tunnelPost.mockRejectedValue(axiosError);
pluginPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("../../src/frontend/web-endpoint-api");
@@ -101,7 +86,7 @@ describe("openWebEndpointTunnel", () => {
it("falls back to the shared handler when the body carries no string reason", async () => {
// A body shaped { error: <object> } would otherwise render as
// "[object Object]" to the user.
tunnelPost.mockRejectedValue(
pluginPost.mockRejectedValue(
Object.assign(new Error("boom"), {
isAxiosError: true,
response: { status: 500, data: { error: { nested: true } } },
@@ -113,7 +98,7 @@ describe("openWebEndpointTunnel", () => {
});
it("treats a missing port as a failure rather than returning undefined", async () => {
tunnelPost.mockResolvedValue({ data: {} });
pluginPost.mockResolvedValue({ data: {} });
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
@@ -132,35 +117,45 @@ describe("requireNumericHostId", () => {
});
/**
* "Open externally" is not the safer path. The cookie jar belongs to the
* browser either way, so a tunnel URL on the host string serving Termix hands
* the tunnelled service this session exactly as an embedded frame would.
* The backend now resolves and validates the target URL itself (host address,
* tunnel port, loopback/session-cookie checks) before ever calling
* ctx.desktop.openIsolatedWindow, so this call is a thin ask-and-report over
* the plugin's own /open-window route rather than a client-side URL builder.
*/
describe("openWebEndpointExternally", () => {
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
it("refuses outside the desktop app before calling the backend", async () => {
const { openWebEndpointExternally } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/,
);
expect(windowOpen).not.toHaveBeenCalled();
expect(tunnelPost).not.toHaveBeenCalled();
expect(pluginPost).not.toHaveBeenCalled();
});
it("opens desktop endpoints through the isolated-window bridge", async () => {
it("posts hostId, endpointId and ignoreCert to /open-window", async () => {
isElectron.mockReturnValue(true);
const invoke = vi.fn().mockResolvedValue({ success: true });
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
pluginPost.mockResolvedValue({ data: { success: true } });
const { openWebEndpointExternally } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
url: "https://127.0.0.1:41234/",
expect(pluginPost).toHaveBeenCalledWith("/open-window", {
hostId: 7,
endpointId: "e1",
ignoreCert: true,
});
expect(windowOpen).not.toHaveBeenCalled();
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
it("preserves the backend's reason instead of the generic message", async () => {
isElectron.mockReturnValue(true);
const axiosError = Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: { status: 502, data: { error: "No window to attach to" } },
});
pluginPost.mockRejectedValue(axiosError);
const { openWebEndpointExternally, WebEndpointTunnelError } =
await import("../../src/frontend/web-endpoint-api");
await expect(
openWebEndpointExternally(host, endpoint()),
).rejects.toBeInstanceOf(WebEndpointTunnelError);
});
});
@@ -6,7 +6,7 @@ import {
sharesCookieSiteWithPage,
webEndpointRefusalReason,
} from "../../src/frontend/web-endpoint-url";
import type { WebEndpoint } from "@/types/index";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
@@ -6,8 +6,8 @@ import {
webEndpointErrorKey,
webEndpointRowError,
} from "../../src/frontend/web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import type { WebEndpoint } from "@/types/index";
import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {