mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
||||
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
|
||||
|
||||
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("../../src/frontend/web-endpoint-api")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
openWebEndpointTunnel: (...args: unknown[]) =>
|
||||
openWebEndpointTunnel(...args),
|
||||
allowInvalidCertificateForOrigin: (...args: unknown[]) =>
|
||||
allowInvalidCertificateForOrigin(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) }));
|
||||
vi.mock("@/lib/electron", () => electron);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function host(
|
||||
overrides: Partial<Host> = {},
|
||||
endpoints: WebEndpoint[] = [],
|
||||
): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "192.168.1.10",
|
||||
webUiConfig: { endpoints },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: false,
|
||||
});
|
||||
pageHostname = "localhost";
|
||||
electron.isElectron.mockReturnValue(true);
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
|
||||
.credentialless;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
openWebEndpointTunnel.mockReset();
|
||||
allowInvalidCertificateForOrigin.mockReset();
|
||||
});
|
||||
|
||||
describe("WebEndpointTab", () => {
|
||||
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
|
||||
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
|
||||
.credentialless;
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("webEndpoint.isolationUnavailable");
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
|
||||
expect(
|
||||
(frame as HTMLIFrameElement & { credentialless: boolean }).credentialless,
|
||||
).toBe(true);
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
|
||||
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
expect(screen.queryByText("webEndpoint.openExternally")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an iframe at the direct URL", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1");
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ ignoreCert: true })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith(
|
||||
"https://192.168.1.10:8006",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reframes at the new port when a reopened tunnel binds a different one", async () => {
|
||||
openWebEndpointTunnel
|
||||
.mockResolvedValueOnce(41234)
|
||||
.mockResolvedValueOnce(51234);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:51234/",
|
||||
),
|
||||
);
|
||||
expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* The case the port-changed test above CANNOT exercise, and the one that
|
||||
* matters most: a live tunnel returns the SAME port on every open, and a
|
||||
* direct endpoint's URL never changes at all. If reload just did
|
||||
* setUrl(resolved), React would bail out of the same-value setState and the
|
||||
* frame would never remount -- so the button would do nothing in the two
|
||||
* most common cases. Capturing the DOM node before and after is what catches
|
||||
* that: a same-value setState never touches the DOM.
|
||||
*/
|
||||
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
const firstFrame = await screen.findByTitle("Proxmox");
|
||||
expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/");
|
||||
|
||||
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
|
||||
await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Polled rather than asserted once: against the defective implementation
|
||||
// this never becomes true and the test times out -- a real failure, not a
|
||||
// race.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the failure reason when the tunnel cannot open", async () => {
|
||||
openWebEndpointTunnel.mockRejectedValue(
|
||||
new Error("Timed out reaching the endpoint port"),
|
||||
);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Timed out reaching the endpoint port/),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => {
|
||||
// jsdom serves the page at "localhost". The forward binds where the
|
||||
// backend runs, so the browser must reach it on this same machine -- but
|
||||
// NOT at the same host string, or the tunnelled service receives Termix's
|
||||
// session cookie.
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
),
|
||||
);
|
||||
// The assertion carrying the security property: whatever host the frame
|
||||
// lands on, it must not be the one serving Termix.
|
||||
const framed = new URL(
|
||||
screen.getByTitle("Proxmox").getAttribute("src") as string,
|
||||
).hostname;
|
||||
expect(framed).not.toBe(window.location.hostname);
|
||||
});
|
||||
|
||||
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when the page host has no separate loopback spelling", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example.com";
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.tunnelSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Refusing after opening would still have bound the port and still have
|
||||
// leaked on the first frame load.
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The leak the tunnel guard did not cover: a DIRECT endpoint points at the
|
||||
* host's own address, so when that host is the one serving Termix the frame
|
||||
* is same-site with Termix and the browser attaches the `jwt` cookie. A
|
||||
* different port is not a different cookie key.
|
||||
*/
|
||||
it("refuses a direct endpoint on the same host that serves Termix", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example";
|
||||
const { WebEndpointTab } =
|
||||
await import("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "termix.example" }, [
|
||||
endpoint({ access: "direct", port: 8443 }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.directSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Refusing after framing would have leaked on the very first request.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
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("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "ui.termix.example.com" }, [
|
||||
endpoint({ access: "direct" }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.directSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
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("../../src/frontend/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "termix.example" }, [
|
||||
endpoint({ access: "direct", port: 8443 }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://termix.example:8443/",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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("../../src/frontend/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/main-axios", () => ({
|
||||
tunnelApi: { post: tunnelPost },
|
||||
handleApiError: (error: unknown) => {
|
||||
throw new Error(`generic: ${String(error)}`);
|
||||
},
|
||||
}));
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "external",
|
||||
bindHost: "0.0.0.0",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const host = { id: "7", ip: "192.168.1.10" };
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
const windowOpen = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
pageHostname = "localhost";
|
||||
isElectron.mockReturnValue(false);
|
||||
tunnelPost.mockReset();
|
||||
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
vi.stubGlobal("open", windowOpen);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
windowOpen.mockReset();
|
||||
});
|
||||
|
||||
describe("openWebEndpointTunnel", () => {
|
||||
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
|
||||
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
|
||||
// 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("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointTunnel(7, "e1");
|
||||
|
||||
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the port", async () => {
|
||||
const { openWebEndpointTunnel } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
|
||||
});
|
||||
|
||||
it("preserves the backend's reason instead of the generic message", async () => {
|
||||
// 502 is this route's likeliest real failure and carries the actionable
|
||||
// cause. Collapsing it into "Server error occurred" defeats the
|
||||
// per-failure-mode messaging entirely.
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 502,
|
||||
data: { error: "Timed out reaching the endpoint port" },
|
||||
},
|
||||
});
|
||||
tunnelPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
|
||||
/Timed out reaching the endpoint port/,
|
||||
);
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toBeInstanceOf(
|
||||
WebEndpointTunnelError,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
Object.assign(new Error("boom"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { nested: true } } },
|
||||
}),
|
||||
);
|
||||
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("../../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("../../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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "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.
|
||||
*/
|
||||
describe("openWebEndpointExternally", () => {
|
||||
it("refuses shared-cookie browser windows before opening a tunnel", 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();
|
||||
});
|
||||
it("opens desktop endpoints through the isolated-window bridge", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const invoke = vi.fn().mockResolvedValue({ success: true });
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
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/",
|
||||
ignoreCert: true,
|
||||
});
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentTunnelHost,
|
||||
resolveWebEndpointUrl,
|
||||
separatedTunnelHost,
|
||||
sharesCookieSiteWithPage,
|
||||
webEndpointRefusalReason,
|
||||
} from "../../src/frontend/web-endpoint-url";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
access: "direct",
|
||||
render: "external",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveWebEndpointUrl", () => {
|
||||
it("builds a direct URL at the host address", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "192.168.1.10",
|
||||
endpoint: endpoint(),
|
||||
}),
|
||||
).toBe("https://192.168.1.10:8006/");
|
||||
});
|
||||
|
||||
it("builds a direct URL for a hostname", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "nas.local",
|
||||
endpoint: endpoint({ port: 443 }),
|
||||
}),
|
||||
).toBe("https://nas.local:443/");
|
||||
});
|
||||
|
||||
it("uses the local port for tunnel access, dropping the endpoint's own port", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "192.168.1.10",
|
||||
endpoint: endpoint({ access: "tunnel", scheme: "http", port: 8006 }),
|
||||
localPort: 41234,
|
||||
}),
|
||||
).toBe("http://127.0.0.1:41234/");
|
||||
});
|
||||
|
||||
it("defaults a missing path to /", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "10.0.0.5",
|
||||
endpoint: endpoint({ scheme: "http", port: 80, path: undefined }),
|
||||
}),
|
||||
).toBe("http://10.0.0.5:80/");
|
||||
});
|
||||
|
||||
it("keeps an explicit path and its query", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "10.0.0.5",
|
||||
endpoint: endpoint({
|
||||
scheme: "http",
|
||||
port: 9000,
|
||||
path: "/admin?tab=1",
|
||||
}),
|
||||
}),
|
||||
).toBe("http://10.0.0.5:9000/admin?tab=1");
|
||||
});
|
||||
|
||||
it("throws when tunnel access has no local port", () => {
|
||||
expect(() =>
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "10.0.0.5",
|
||||
endpoint: endpoint({ access: "tunnel" }),
|
||||
}),
|
||||
).toThrow(/local port/i);
|
||||
});
|
||||
|
||||
it("brackets a bare IPv6 host address", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "fd00::1",
|
||||
endpoint: endpoint({ scheme: "http", port: 8080 }),
|
||||
}),
|
||||
).toBe("http://[fd00::1]:8080/");
|
||||
});
|
||||
|
||||
it("brackets a bare IPv6 tunnel host", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "10.0.0.5",
|
||||
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
|
||||
localPort: 41234,
|
||||
tunnelHost: "fd00::1",
|
||||
}),
|
||||
).toBe("http://[fd00::1]:41234/");
|
||||
});
|
||||
|
||||
it("uses an explicit tunnel host when given", () => {
|
||||
expect(
|
||||
resolveWebEndpointUrl({
|
||||
hostAddress: "10.0.0.5",
|
||||
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
|
||||
localPort: 41234,
|
||||
tunnelHost: "termix.lan",
|
||||
}),
|
||||
).toBe("http://termix.lan:41234/");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cookies are keyed by host and IGNORE the port, and SameSite computes "site"
|
||||
* as scheme + registrable domain -- also ignoring the port. So a forward
|
||||
* reached at the very host string serving Termix is same-site with Termix:
|
||||
* the browser attaches the jwt cookie to every request the framed
|
||||
* third-party server receives, and a Set-Cookie from that server lands in the
|
||||
* jar Termix's own API calls read from (both auth middlewares prefer the
|
||||
* cookie over the Authorization header).
|
||||
*
|
||||
* Both directions were reproduced against a real SSH forward. separatedTunnelHost
|
||||
* is the whole mitigation: a host string that reaches the same machine but is
|
||||
* a DIFFERENT cookie-jar key.
|
||||
*/
|
||||
describe("separatedTunnelHost", () => {
|
||||
it("maps each loopback spelling to a different one", () => {
|
||||
expect(separatedTunnelHost("localhost")).toBe("127.0.0.1");
|
||||
expect(separatedTunnelHost("127.0.0.1")).toBe("localhost");
|
||||
expect(separatedTunnelHost("::1")).toBe("127.0.0.1");
|
||||
expect(separatedTunnelHost("[::1]")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("never returns the host it was given, which would be no separation", () => {
|
||||
for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
|
||||
expect(separatedTunnelHost(host)).not.toBe(host);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case- and whitespace-insensitive", () => {
|
||||
expect(separatedTunnelHost(" LOCALHOST ")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("returns null for a host with no alias, rather than guessing", () => {
|
||||
// A same-registrable-domain alias would NOT be safe: the target could
|
||||
// answer Set-Cookie with Domain=example.com and reach Termix anyway.
|
||||
// Only loopback literals qualify -- they cannot carry a Domain.
|
||||
expect(separatedTunnelHost("termix.example.com")).toBeNull();
|
||||
expect(separatedTunnelHost("192.168.1.50")).toBeNull();
|
||||
expect(separatedTunnelHost("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentTunnelHost", () => {
|
||||
it("is loopback on the desktop, whose API base is localhost", () => {
|
||||
// Two different cookie-jar keys. Do not "simplify" either to match.
|
||||
expect(currentTunnelHost(true)).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("swaps to the other loopback spelling when Termix is served at localhost", () => {
|
||||
expect(currentTunnelHost(false, "localhost")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("swaps the other way when Termix is served at 127.0.0.1", () => {
|
||||
expect(currentTunnelHost(false, "127.0.0.1")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("has no answer for a page host with no loopback alias", () => {
|
||||
expect(currentTunnelHost(false, "termix.example.com")).toBeNull();
|
||||
expect(currentTunnelHost(false, "192.168.1.50")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cookie leak is not tunnel-only. A DIRECT endpoint points at the host's
|
||||
* own address, and when that host is the same one serving Termix in a browser
|
||||
* (a different port is not a different cookie key), the framed third-party
|
||||
* service receives Termix's `jwt` -- and can set one back. So the refusal has
|
||||
* to cover direct access too, using the endpoint's target host rather than a
|
||||
* bind address.
|
||||
*/
|
||||
describe("sharesCookieSiteWithPage", () => {
|
||||
it("is true for the exact same host, because cookies ignore the port", () => {
|
||||
expect(sharesCookieSiteWithPage("termix.example", "termix.example")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is true for a parent or sub domain, which a Domain-scoped cookie crosses", () => {
|
||||
expect(
|
||||
sharesCookieSiteWithPage("app.termix.example", "termix.example"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sharesCookieSiteWithPage("termix.example", "app.termix.example"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a genuinely different host", () => {
|
||||
expect(sharesCookieSiteWithPage("192.168.1.10", "termix.example")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(sharesCookieSiteWithPage("nas.local", "termix.example")).toBe(false);
|
||||
});
|
||||
|
||||
it("is case- and whitespace-insensitive and ignores a trailing dot", () => {
|
||||
expect(
|
||||
sharesCookieSiteWithPage(" TERMIX.example. ", "termix.example"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a shared suffix off a label boundary as same-site", () => {
|
||||
// "eviltermix.example" ends with "termix.example" as a string but not on a
|
||||
// dot boundary -- treating it as same-site would let an attacker pick such
|
||||
// a name to dodge the guard, so it must be false.
|
||||
expect(
|
||||
sharesCookieSiteWithPage("eviltermix.example", "termix.example"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for blank input rather than guessing", () => {
|
||||
expect(sharesCookieSiteWithPage("", "termix.example")).toBe(false);
|
||||
expect(sharesCookieSiteWithPage("termix.example", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webEndpointRefusalReason", () => {
|
||||
const tunnel = (bindHost?: string) =>
|
||||
endpoint({ access: "tunnel", bindHost }) as Pick<
|
||||
WebEndpoint,
|
||||
"access" | "bindHost"
|
||||
>;
|
||||
const direct = () =>
|
||||
endpoint({ access: "direct" }) as Pick<WebEndpoint, "access" | "bindHost">;
|
||||
|
||||
it("refuses a direct endpoint on the same host that serves Termix", () => {
|
||||
// https://termix.example:8443 receives Termix's jwt: same host, and the
|
||||
// port is not part of the cookie key.
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
direct(),
|
||||
false,
|
||||
"termix.example",
|
||||
"termix.example",
|
||||
),
|
||||
).toBe("direct-shares-session-cookie");
|
||||
});
|
||||
|
||||
it("refuses a direct endpoint on a parent or sub domain of the page host", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
direct(),
|
||||
false,
|
||||
"termix.example.com",
|
||||
"app.termix.example.com",
|
||||
),
|
||||
).toBe("direct-shares-session-cookie");
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
direct(),
|
||||
false,
|
||||
"app.termix.example.com",
|
||||
"termix.example.com",
|
||||
),
|
||||
).toBe("direct-shares-session-cookie");
|
||||
});
|
||||
|
||||
it("allows a direct endpoint on a genuinely different host", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
direct(),
|
||||
false,
|
||||
"192.168.1.10",
|
||||
"termix.example",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
webEndpointRefusalReason(direct(), false, "nas.local", "termix.example"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a direct same-host endpoint on the desktop, whose jar has no jwt", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
direct(),
|
||||
true,
|
||||
"termix.example",
|
||||
"termix.example",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("passes anything on the desktop", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(tunnel(), true, undefined, "localhost"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
webEndpointRefusalReason(tunnel("0.0.0.0"), true, undefined, "localhost"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a loopback bind a remote backend would strand", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(tunnel(), false, undefined, "localhost"),
|
||||
).toBe("loopback-bind-on-remote-backend");
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel("127.0.0.1"),
|
||||
false,
|
||||
undefined,
|
||||
"localhost",
|
||||
),
|
||||
).toBe("loopback-bind-on-remote-backend");
|
||||
});
|
||||
|
||||
it("allows an exposed bind when the page host has a loopback alias", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel("0.0.0.0"),
|
||||
false,
|
||||
undefined,
|
||||
"localhost",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel("0.0.0.0"),
|
||||
false,
|
||||
undefined,
|
||||
"127.0.0.1",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when the tunnel URL would share Termix's cookie jar", () => {
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel("0.0.0.0"),
|
||||
false,
|
||||
undefined,
|
||||
"termix.example.com",
|
||||
),
|
||||
).toBe("shares-session-cookie-with-termix");
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel("0.0.0.0"),
|
||||
false,
|
||||
undefined,
|
||||
"192.168.1.50",
|
||||
),
|
||||
).toBe("shares-session-cookie-with-termix");
|
||||
});
|
||||
|
||||
it("reports the reachability problem first when both apply", () => {
|
||||
// The bind address is what the user has to change either way, and it is
|
||||
// the more specific instruction.
|
||||
expect(
|
||||
webEndpointRefusalReason(
|
||||
tunnel(),
|
||||
false,
|
||||
undefined,
|
||||
"termix.example.com",
|
||||
),
|
||||
).toBe("loopback-bind-on-remote-backend");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findInvalidWebEndpoint,
|
||||
isWebEndpointPathValid,
|
||||
isWebEndpointPortValid,
|
||||
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";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "external",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isWebEndpointPortValid", () => {
|
||||
it("accepts the whole legal range and nothing else", () => {
|
||||
expect(isWebEndpointPortValid(1)).toBe(true);
|
||||
expect(isWebEndpointPortValid(65535)).toBe(true);
|
||||
for (const bad of [0, -1, 65536, 8.5, "8006", null, undefined, NaN]) {
|
||||
expect(isWebEndpointPortValid(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("webEndpointRowError", () => {
|
||||
it("returns null for a row the normalizer would keep", () => {
|
||||
expect(webEndpointRowError(endpoint())).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a blank label before anything else", () => {
|
||||
// Both label and port are wrong; the label is the likelier oversight.
|
||||
expect(webEndpointRowError(endpoint({ label: " ", port: 0 }))).toBe(
|
||||
"label",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a bad port and a bad path", () => {
|
||||
expect(webEndpointRowError(endpoint({ port: 0 }))).toBe("port");
|
||||
expect(webEndpointRowError(endpoint({ path: "//evil.example" }))).toBe(
|
||||
"path",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInvalidWebEndpoint", () => {
|
||||
it("returns null when every row would survive", () => {
|
||||
expect(
|
||||
findInvalidWebEndpoint([
|
||||
endpoint(),
|
||||
endpoint({ id: "e2", label: "NAS" }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("names the offending row by index", () => {
|
||||
expect(
|
||||
findInvalidWebEndpoint([endpoint(), endpoint({ id: "e2", label: "" })]),
|
||||
).toEqual({ index: 1, error: "label" });
|
||||
});
|
||||
|
||||
it("reports duplicate labels, which the picker cannot tell apart", () => {
|
||||
expect(
|
||||
findInvalidWebEndpoint([
|
||||
endpoint(),
|
||||
endpoint({ id: "e2", label: "proxmox" }),
|
||||
]),
|
||||
).toEqual({ index: 1, error: "duplicateLabel" });
|
||||
});
|
||||
|
||||
it("tolerates a missing list", () => {
|
||||
expect(findInvalidWebEndpoint(undefined)).toBeNull();
|
||||
expect(findInvalidWebEndpoint(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("webEndpointErrorKey", () => {
|
||||
it("maps every error to a distinct i18n key", () => {
|
||||
const keys = (["label", "duplicateLabel", "port", "path"] as const).map(
|
||||
webEndpointErrorKey,
|
||||
);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
for (const key of keys) expect(key).toMatch(/^hosts\./);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The editor validator and the backend normalizer are two implementations of
|
||||
* overlapping rules, in two layers that cannot share code (the renderer does
|
||||
* not import backend route modules). Nothing but this describe block stops
|
||||
* them drifting -- and drift here is invisible in the worst way: the editor
|
||||
* accepts a row, the normalizer silently drops it, and the user's endpoint
|
||||
* disappears on reload with no error anywhere.
|
||||
*/
|
||||
describe("the editor agrees with the normalizer", () => {
|
||||
const PATHS = [
|
||||
"/",
|
||||
"",
|
||||
"/admin",
|
||||
"admin",
|
||||
"/admin?tab=1",
|
||||
"/a b",
|
||||
"//evil.example",
|
||||
"http://evil.example/x",
|
||||
"/x:y",
|
||||
"\t//evil.example",
|
||||
"\n//evil.example",
|
||||
"/a\tb",
|
||||
`/a${String.fromCharCode(0x7f)}b`,
|
||||
"/back\\slash",
|
||||
];
|
||||
|
||||
it.each(PATHS)("agrees on path %j", (path) => {
|
||||
const editorAccepts = isWebEndpointPathValid(path);
|
||||
const normalizerKeeps =
|
||||
normalizeWebEndpoints([{ ...endpoint(), path }]).length === 1;
|
||||
expect(editorAccepts).toBe(normalizerKeeps);
|
||||
});
|
||||
|
||||
const PORTS = [1, 80, 8006, 65535, 0, -1, 65536, 8.5];
|
||||
|
||||
it.each(PORTS)("agrees on port %j", (port) => {
|
||||
const editorAccepts = isWebEndpointPortValid(port);
|
||||
const normalizerKeeps =
|
||||
normalizeWebEndpoints([{ ...endpoint(), port }]).length === 1;
|
||||
expect(editorAccepts).toBe(normalizerKeeps);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user