feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
-75
View File
@@ -1,75 +0,0 @@
/**
* Web Endpoint - first-party, in-process plugin.
*
* This plugin runs on the main thread because its one route is a real
* transport consumer: it polls the tunnel manager's live maps
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), needs the
* plaintext host credentials ctx.hosts deliberately withholds (PluginHostView
* is a 13-field allowlist excluding password, key and passphrase), and hands a
* live ssh2.Client to forwardOut to probe the target. None of that survives
* the structured-clone postMessage boundary a worker plugin talks over.
*
* Unlike every other dispatcher-backed plugin, this one does not register with
* a dispatcher in database.ts: its route is served by the tunnel service on
* port 30003, so the dispatcher lives at
* src/backend/hosts/tunnel/web-endpoint-dispatch.ts instead. Both that service
* and the plugin runtime load into the same backend process, so the
* dispatcher's module-level slot is shared between them. Keeping the URL
* (/ssh/tunnel/web-endpoint/open) byte-identical is also why no nginx change
* was needed -- the generic /ssh/tunnel/ location block already covers it.
*
* Three pieces stay in core rather than moving here:
*
* - The tunnel manager itself (src/backend/hosts/tunnel/manager.ts). It
* serves the whole server-tunnels feature, and the reserved "web:" name
* prefix that exempts these tunnels from the retry machinery is part of
* its own disconnect path.
*
* - host-web-endpoints.ts (the webUiConfig normalizer). Despite its name it
* has no router: it is imported by host.ts, host-bulk-routes.ts and
* host-normalizers.ts to sanitize the column on every host save and list.
* Disabling this plugin must not stop that validation, and core cannot
* import plugin code in any case.
*
* - The Electron half (electron/web-endpoint-window.cjs and the two
* ipcMain handlers in main.cjs). BrowserWindow and session are
* main-process-only, the renderer is what invokes those channels, and the
* main process loads no plugin code at all today.
*
* The enableWebUi/webUiConfig columns stay on core's ssh_data table, declared
* here through contributes.hostCapability -- this plugin system has no
* owned-table migration mechanism of its own.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled .js
* for routes.js sits right next to this .mjs. Under vitest/dev nothing imports
* this plugin directly (the loader loads in-process plugins with a plain
* dynamic import, no tsx loader involved), so the only layout that needs to
* resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
}
export async function deactivate() {
if (routesModule) {
routesModule.stopWebEndpointService();
routesModule = null;
}
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -35,8 +35,6 @@
"editorTab": "general"
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/web-endpoint",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
}
export async function deactivate() {
stopWebEndpointService();
}
@@ -1,22 +1,22 @@
import express from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../src/backend/utils/logger.js";
import { parseWebUiConfig } from "../../../src/backend/database/routes/host-web-endpoints.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../src/backend/hosts/tunnel/utils.js";
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import {
registerWebEndpointRouter,
unregisterWebEndpointRouter,
} from "../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../src/types/index.js";
} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
@@ -151,7 +151,7 @@ export async function handleWebEndpointOpen(
// a web deployment.
const { resolveHostById } =
await import("../../../src/backend/hosts/host-resolver.js");
await import("../../../../src/backend/hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
if (!host) {
return res.status(403).json({ error: "Host not found or access denied" });
@@ -0,0 +1,106 @@
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/shell/tabUtils.tsx",
];
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");
});
});
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import {
MAX_WEB_ENDPOINTS,
normalizeWebEndpoints,
parseWebUiConfig,
serializeWebUiConfig,
} from "../../../../src/backend/database/routes/host-web-endpoints.js";
function valid(overrides: Record<string, unknown> = {}) {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
describe("normalizeWebEndpoints", () => {
it("keeps a valid endpoint", () => {
expect(normalizeWebEndpoints([valid()])).toEqual([
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
ignoreCert: false,
},
]);
});
it("drops an unknown or dangerous scheme", () => {
expect(normalizeWebEndpoints([valid({ scheme: "ftp" })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ scheme: "javascript" })])).toEqual(
[],
);
});
it("drops out-of-range ports", () => {
expect(normalizeWebEndpoints([valid({ port: 0 })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ port: 65536 })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ port: 8.5 })])).toEqual([]);
// A numeric string is not a number; accepting it would let "8006abc"
// through some other coercion path later.
expect(normalizeWebEndpoints([valid({ port: "8006" })])).toEqual([]);
});
it("coerces a path that does not start with a slash", () => {
expect(normalizeWebEndpoints([valid({ path: "admin" })])[0].path).toBe(
"/admin",
);
});
it("defaults a missing path to /", () => {
expect(normalizeWebEndpoints([valid({ path: undefined })])[0].path).toBe(
"/",
);
});
it("drops a path carrying a scheme or authority", () => {
expect(normalizeWebEndpoints([valid({ path: "//evil.example" })])).toEqual(
[],
);
expect(
normalizeWebEndpoints([valid({ path: "http://evil.example/x" })]),
).toEqual([]);
expect(normalizeWebEndpoints([valid({ path: "/x" })])).toHaveLength(1);
});
it("drops a path whose protocol-relative prefix hides behind a control character", () => {
// "\t//evil.example" slips a naive startsWith("//") guard: the browser
// strips the control character and follows the authority.
for (const prefix of ["\t", "\n", "\r"]) {
expect(
normalizeWebEndpoints([valid({ path: `${prefix}//evil.example` })]),
).toEqual([]);
}
});
it("drops a path containing a control character anywhere", () => {
expect(normalizeWebEndpoints([valid({ path: "/a\tb" })])).toEqual([]);
expect(
normalizeWebEndpoints([
valid({ path: `/a${String.fromCharCode(0x7f)}b` }),
]),
).toEqual([]);
});
it("keeps a path containing a plain space", () => {
expect(normalizeWebEndpoints([valid({ path: "/a b" })])[0].path).toBe(
"/a b",
);
});
it("truncates an over-long label", () => {
expect(
normalizeWebEndpoints([valid({ label: "x".repeat(200) })])[0].label,
).toHaveLength(64);
});
it("drops an endpoint with a blank label or id", () => {
expect(normalizeWebEndpoints([valid({ label: " " })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ id: "" })])).toEqual([]);
});
it("keeps only the first of a duplicate id", () => {
const result = normalizeWebEndpoints([
valid({ id: "dup", label: "first" }),
valid({ id: "dup", label: "second" }),
]);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("first");
});
it("caps the list length", () => {
const many = Array.from({ length: MAX_WEB_ENDPOINTS + 5 }, (_, i) =>
valid({ id: `e${i}` }),
);
expect(normalizeWebEndpoints(many)).toHaveLength(MAX_WEB_ENDPOINTS);
});
it("returns empty for non-array input", () => {
expect(normalizeWebEndpoints(undefined)).toEqual([]);
expect(normalizeWebEndpoints(null)).toEqual([]);
expect(normalizeWebEndpoints("nope")).toEqual([]);
expect(normalizeWebEndpoints({ endpoints: [] })).toEqual([]);
});
it("drops an unknown access or render value", () => {
expect(normalizeWebEndpoints([valid({ access: "magic" })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ render: "magic" })])).toEqual([]);
});
it("clears ignoreCert for tunnel access and keeps it for direct", () => {
expect(
normalizeWebEndpoints([valid({ access: "tunnel", ignoreCert: true })])[0]
.ignoreCert,
).toBe(false);
expect(
normalizeWebEndpoints([valid({ access: "direct", ignoreCert: true })])[0]
.ignoreCert,
).toBe(true);
});
});
describe("bindHost", () => {
const tunnelRow = (bindHost?: unknown) => ({
...valid({ access: "tunnel" }),
...(bindHost === undefined ? {} : { bindHost }),
});
it("keeps a loopback bind", () => {
expect(normalizeWebEndpoints([tunnelRow("127.0.0.1")])[0].bindHost).toBe(
"127.0.0.1",
);
});
it("keeps an all-interfaces bind, which is the exposed opt-in", () => {
expect(normalizeWebEndpoints([tunnelRow("0.0.0.0")])[0].bindHost).toBe(
"0.0.0.0",
);
});
it("omits it entirely when blank, so the route falls back to loopback", () => {
expect(normalizeWebEndpoints([tunnelRow("")])[0].bindHost).toBeUndefined();
expect(normalizeWebEndpoints([tunnelRow()])[0].bindHost).toBeUndefined();
});
it("drops the endpoint when the bind address is not a bare host", () => {
// It lands in a listener AND in a URL authority, so nothing carrying a
// scheme, port, path or credentials may survive.
for (const bad of [
"http://evil.example",
"127.0.0.1:8080",
"host/path",
"user@host",
"has space",
"999.1.1.1",
"-bad.example",
42,
]) {
expect(normalizeWebEndpoints([tunnelRow(bad)])).toEqual([]);
}
});
it("accepts a bracketed IPv6 literal and a hostname", () => {
expect(normalizeWebEndpoints([tunnelRow("[::1]")])[0].bindHost).toBe(
"[::1]",
);
expect(normalizeWebEndpoints([tunnelRow("nas.local")])[0].bindHost).toBe(
"nas.local",
);
});
it("ignores it on a direct endpoint, which never binds anything", () => {
expect(
normalizeWebEndpoints([
valid({ access: "direct", bindHost: "0.0.0.0" }),
])[0].bindHost,
).toBeUndefined();
});
});
describe("localPort", () => {
const tunnelRow = (localPort?: unknown) => ({
...valid({ access: "tunnel" }),
...(localPort === undefined ? {} : { localPort }),
});
it("keeps a fixed port so a container can publish it", () => {
expect(normalizeWebEndpoints([tunnelRow(38080)])[0].localPort).toBe(38080);
});
it("omits it when unset, leaving the kernel to pick", () => {
expect(normalizeWebEndpoints([tunnelRow()])[0].localPort).toBeUndefined();
});
it("drops the endpoint for a port outside the valid range", () => {
for (const bad of [0, -1, 65536, 8.5, "38080"]) {
expect(normalizeWebEndpoints([tunnelRow(bad)])).toEqual([]);
}
});
it("ignores it on a direct endpoint, which binds nothing", () => {
expect(
normalizeWebEndpoints([valid({ access: "direct", localPort: 38080 })])[0]
.localPort,
).toBeUndefined();
});
});
describe("parseWebUiConfig", () => {
it("parses a stored JSON string", () => {
expect(
parseWebUiConfig(JSON.stringify({ endpoints: [valid()] })).endpoints,
).toHaveLength(1);
});
it("accepts an already-parsed object", () => {
expect(parseWebUiConfig({ endpoints: [valid()] }).endpoints).toHaveLength(
1,
);
});
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.
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
});
it("returns empty endpoints for null and undefined", () => {
expect(parseWebUiConfig(null)).toEqual({ endpoints: [] });
expect(parseWebUiConfig(undefined)).toEqual({ endpoints: [] });
});
});
describe("serializeWebUiConfig", () => {
it("round-trips through normalization", () => {
expect(
parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] }))
.endpoints[0].id,
).toBe("e1");
});
it("returns null when there are no valid endpoints", () => {
expect(serializeWebUiConfig({ endpoints: [] })).toBeNull();
expect(serializeWebUiConfig(null)).toBeNull();
});
});
@@ -0,0 +1,353 @@
import type express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById,
}));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
connectionStatus: new Map<string, unknown>(),
tunnelConnecting: new Set<string>(),
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../../../src/backend/hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../../../src/backend/hosts/tunnel/ssh-primitives.js", () => ({
forwardOut,
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => 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 request(body: unknown, userId: string | undefined = "u1") {
return { body, userId } 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",
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "pw",
enableWebUi: true,
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
...overrides,
};
}
/** Makes connectSSHTunnel behave like a successful open. */
function tunnelConnectsOn(port: number) {
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.activeTunnelRuntimes.set(config.name, {
bindPort: port,
sourceClient: {},
close: vi.fn(),
});
},
);
}
beforeEach(() => {
manager.activeTunnelRuntimes.clear();
manager.connectionStatus.clear();
manager.tunnelConnecting.clear();
manager.connectSSHTunnel.mockReset();
manager.cleanupTunnelResources.mockClear();
forwardOut.mockClear();
forwardOut.mockResolvedValue({ end: vi.fn() });
resolveHostById.mockReset();
resolveHostById.mockResolvedValue(host());
});
afterEach(() => {
vi.resetModules();
});
describe("POST /tunnel/web-endpoint/open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(403);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
// Reachable via a bulk update that sends only { webUiConfig } while the
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(400);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
res,
);
expect(res.statusCode).toBe(400);
});
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(200);
expect(res.body.port).toBe(41234);
});
it("selects the local-forward path via endpointHost, not targetHost", async () => {
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
// endpointHost === "127.0.0.1". Setting only targetHost would silently
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[0][0];
expect(config.endpointHost).toBe("127.0.0.1");
expect(config.mode).toBe("local");
expect(config.endpointPort).toBe(8006);
// Carries the source SSH identity -- connectSSHTunnel builds its client
// from these, and reads sourceUserId specifically.
expect(config.sourceIP).toBe("10.0.0.5");
expect(config.sourceUserId).toBe("u1");
});
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
"127.0.0.1",
);
manager.activeTunnelRuntimes.clear();
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
}),
}),
);
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[1][0];
expect(config.bindHost).toBe("0.0.0.0");
expect(config.sourcePort).toBe(38080);
});
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
first,
);
const second = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
second,
);
expect(second.body.port).toBe(41234);
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
});
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
// Same id, different port: the live forward now points at the wrong place.
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
}),
);
tunnelConnectsOn(51234);
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
expect(res.body.port).toBe(51234);
});
it("reports the target port being closed rather than returning a dead 200", async () => {
// The likeliest real error: the listener binds fine, but nothing is
// serving on the endpoint's port. Without the probe this returns 200 and
// the user gets a blank frame with no message.
tunnelConnectsOn(41234);
forwardOut.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/connect failed/);
// Nothing half-built is left registered.
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
});
it("surfaces a connection failure reported only over the status map", async () => {
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
// without waitForTunnelSettled this path would read as success.
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.connectionStatus.set(config.name, {
connected: false,
reason: "All authentication methods failed",
});
},
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/authentication methods failed/);
});
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
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);
}
expect(resolveHostById).not.toHaveBeenCalled();
});
});
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
*
* This was caught only by opening a real tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
*/
describe("route registration", () => {
it("registers /open on the router the dispatcher forwards to", async () => {
const routes = await import("../../src/backend/routes.js");
const dispatch =
await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
);
routes.startWebEndpointService();
expect(registered).toHaveLength(1);
const paths = (
routes.router 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 mounted so the full client path resolves", async () => {
const source = await readFile(
new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
});
});
@@ -0,0 +1,136 @@
import net from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
* The two behaviours here are the ones a mocked test cannot show:
*
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
* record what it actually bound rather than the 0 it was asked for; and
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
* keeps handing out a port whose listener is gone.
*
* The SSH half is stubbed -- these behaviours live entirely in the listener
* and the runtime bookkeeping.
*/
const sshPrimitives = vi.hoisted(() => ({
forwardOut: vi.fn(async () => {
throw new Error("not used in these tests");
}),
pipeTunnelStreams: vi.fn(),
handleSocks5Connect: vi.fn(),
}));
vi.mock(
"../../../../src/backend/hosts/tunnel/ssh-primitives.js",
() => sshPrimitives,
);
function tunnelConfig(overrides: Record<string, unknown> = {}) {
return {
name: "web:7:e1",
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.1",
sourcePort: 0,
endpointHost: "127.0.0.1",
endpointPort: 9,
...overrides,
} as never;
}
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
afterEach(() => {
vi.resetModules();
});
describe("establishDirectTunnel records the port it actually bound", () => {
it("reports the kernel-assigned port, never the requested 0", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
expect(runtime).toBeDefined();
// The whole point: 0 means "pick one for me", so a runtime advertising 0
// would make every caller that reuses the port fail.
expect(runtime?.bindPort).toBeGreaterThan(0);
// And it is genuinely listening there.
const port = runtime!.bindPort as number;
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.destroy();
resolve();
});
socket.once("error", reject);
});
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:e1");
});
it("honours a fixed source port when one is asked for", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
// Borrow a free port, release it, then ask for it explicitly.
const probe = net.createServer();
const wanted = await new Promise<number>((resolve) => {
probe.listen(0, "127.0.0.1", () =>
resolve((probe.address() as { port: number }).port),
);
});
await new Promise<void>((resolve) => probe.close(() => resolve()));
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
);
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
expect(runtime?.bindPort).toBe(wanted);
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:fixed");
});
});
describe("idle close", () => {
it("removes the runtime entry, not just the listener", async () => {
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
// and mixing fake timers with that is a known route to a hanging test.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
);
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
await vi.waitFor(
() => {
// Deleting the entry is the assertion. close() alone would stop the
// listener but leave the entry behind, so the open route would keep
// returning a port nothing is listening on.
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
},
{ timeout: 4000, interval: 25 },
);
});
it("leaves a tunnel with no idle timeout running", async () => {
// Without this, an idle-close bug that fires unconditionally would still
// pass the test above.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:forever" }),
);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
manager.activeTunnelRuntimes.delete("web:7:forever");
});
});
@@ -0,0 +1,165 @@
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();
});
});
@@ -13,11 +13,14 @@ import type { Host } from "@/types/ui-types";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
vi.mock("./web-endpoint-api", async (importOriginal) => {
vi.mock("../../src/frontend/web-endpoint-api", async (importOriginal) => {
// requireNumericHostId is left as the real implementation -- the "no saved
// host" case exercises the actual validation, not a re-implementation of it
// that could drift.
const actual = await importOriginal<typeof import("./web-endpoint-api")>();
const actual =
await importOriginal<
typeof import("../../src/frontend/web-endpoint-api")
>();
return {
...actual,
openWebEndpointTunnel: (...args: unknown[]) =>
@@ -94,7 +97,8 @@ describe("WebEndpointTab", () => {
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -106,7 +110,8 @@ describe("WebEndpointTab", () => {
expect(document.querySelector("iframe")).toBeNull();
});
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
expect(
@@ -118,7 +123,8 @@ describe("WebEndpointTab", () => {
});
it("renders an iframe at the direct URL", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
@@ -130,7 +136,8 @@ describe("WebEndpointTab", () => {
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -147,7 +154,8 @@ describe("WebEndpointTab", () => {
});
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ ignoreCert: true })])}
@@ -165,7 +173,8 @@ describe("WebEndpointTab", () => {
openWebEndpointTunnel
.mockResolvedValueOnce(41234)
.mockResolvedValueOnce(51234);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -201,7 +210,8 @@ describe("WebEndpointTab", () => {
*/
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -226,7 +236,8 @@ describe("WebEndpointTab", () => {
openWebEndpointTunnel.mockRejectedValue(
new Error("Timed out reaching the endpoint port"),
);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -241,7 +252,8 @@ describe("WebEndpointTab", () => {
});
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
await waitFor(() =>
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
@@ -251,7 +263,8 @@ describe("WebEndpointTab", () => {
});
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
@@ -267,7 +280,8 @@ describe("WebEndpointTab", () => {
it("resolves a direct endpoint normally on the same quick-connect host", async () => {
// The positive case: requireNumericHostId must be consulted only on the
// tunnel branch, or direct endpoints would break on unsaved hosts too.
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
@@ -290,7 +304,8 @@ describe("WebEndpointTab", () => {
// session cookie.
electron.isElectron.mockReturnValue(false);
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
@@ -314,7 +329,8 @@ describe("WebEndpointTab", () => {
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
electron.isElectron.mockReturnValue(false);
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
@@ -333,7 +349,8 @@ describe("WebEndpointTab", () => {
it("refuses when the page host has no separate loopback spelling", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
@@ -360,7 +377,8 @@ describe("WebEndpointTab", () => {
it("refuses a direct endpoint on the same host that serves Termix", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example";
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
@@ -381,7 +399,8 @@ describe("WebEndpointTab", () => {
it("refuses a direct endpoint on a sub domain of the page host", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "ui.termix.example.com" }, [
@@ -401,7 +420,8 @@ describe("WebEndpointTab", () => {
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
electron.isElectron.mockReturnValue(true);
pageHostname = "termix.example";
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
@@ -421,7 +441,8 @@ describe("WebEndpointTab", () => {
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
@@ -59,7 +59,8 @@ describe("openWebEndpointTunnel", () => {
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it.
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
@@ -70,7 +71,8 @@ describe("openWebEndpointTunnel", () => {
});
it("returns the port", async () => {
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
});
@@ -87,7 +89,7 @@ describe("openWebEndpointTunnel", () => {
});
tunnelPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("./web-endpoint-api");
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Timed out reaching the endpoint port/,
@@ -106,20 +108,23 @@ describe("openWebEndpointTunnel", () => {
response: { status: 500, data: { error: { nested: true } } },
}),
);
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/);
});
it("treats a missing port as a failure rather than returning undefined", async () => {
tunnelPost.mockResolvedValue({ data: {} });
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
});
});
describe("requireNumericHostId", () => {
it("accepts a saved host id and rejects a quick-connect one", async () => {
const { requireNumericHostId } = await import("./web-endpoint-api");
const { requireNumericHostId } =
await import("../../src/frontend/web-endpoint-api");
expect(requireNumericHostId("7")).toBe(7);
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
@@ -134,7 +139,8 @@ describe("requireNumericHostId", () => {
*/
describe("openWebEndpointExternally", () => {
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
const { openWebEndpointExternally } = await import("./web-endpoint-api");
const { openWebEndpointExternally } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/,
);
@@ -148,7 +154,8 @@ describe("openWebEndpointExternally", () => {
configurable: true,
value: { invoke },
});
const { openWebEndpointExternally } = await import("./web-endpoint-api");
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/",
@@ -5,7 +5,7 @@ import {
separatedTunnelHost,
sharesCookieSiteWithPage,
webEndpointRefusalReason,
} from "./web-endpoint-url";
} from "../../src/frontend/web-endpoint-url";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
@@ -5,8 +5,8 @@ import {
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "./web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../src/backend/database/routes/host-web-endpoints.js";
} from "../../src/frontend/web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);