mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let mock: MockPluginContext | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
function activateWith(capabilities: string[]) {
|
||||
mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
return activate(mock.ctx);
|
||||
}
|
||||
|
||||
describe("web-endpoint activate", () => {
|
||||
it("mounts its router at /plugin-api/web-endpoint", async () => {
|
||||
await activateWith(manifest.capabilities);
|
||||
expect(mock?.httpRouters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers a hostImportNormalizer and revokes it on deactivate", async () => {
|
||||
await activateWith(manifest.capabilities);
|
||||
expect(
|
||||
typeof mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
|
||||
).toBe("function");
|
||||
|
||||
for (const dispose of [...(mock?.disposals ?? [])].reverse())
|
||||
await dispose();
|
||||
expect(
|
||||
mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
await expect(
|
||||
activateWith(manifest.capabilities.filter((c) => c !== "network:serve")),
|
||||
).rejects.toThrow(/network:serve/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hostImportNormalizer } from "../../src/backend/host-import.js";
|
||||
|
||||
describe("hostImportNormalizer", () => {
|
||||
it("returns null when the row carries neither field", () => {
|
||||
expect(hostImportNormalizer({})).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes and validates webUiConfig, dropping invalid endpoints", () => {
|
||||
const result = hostImportNormalizer({
|
||||
enableWebUi: true,
|
||||
webUiConfig: {
|
||||
endpoints: [
|
||||
{
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
label: "",
|
||||
scheme: "https",
|
||||
port: 1,
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result?.enableWebUi).toBe(true);
|
||||
expect(result?.webUiConfig).toEqual({
|
||||
endpoints: [
|
||||
{
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
ignoreCert: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears webUiConfig to null when enabling with no endpoints", () => {
|
||||
const result = hostImportNormalizer({ enableWebUi: true });
|
||||
expect(result).toEqual({ enableWebUi: true, webUiConfig: null });
|
||||
});
|
||||
|
||||
it("normalizes enableWebUi to a boolean", () => {
|
||||
expect(hostImportNormalizer({ enableWebUi: "yes" })).toEqual({
|
||||
enableWebUi: true,
|
||||
webUiConfig: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* This codebase enumerates host columns by hand in a lot of places, and
|
||||
* "added a column, missed one update path" is its most repeated bug -- during
|
||||
* the first attempt at this feature a field was missed at one of these points
|
||||
* six separate times, each surfacing as a different mystery (endpoints that
|
||||
* saved but never appeared, config that vanished on reload).
|
||||
*
|
||||
* Every one of these files already enumerates the Docker pair, which is the
|
||||
* exact shape the web endpoint pair copies. So "mentions enableDocker but not
|
||||
* enableWebUi" is a reliable proxy for "was not updated", and it fails loudly
|
||||
* the moment someone adds the tenth enumeration point without this one.
|
||||
*
|
||||
* This is a coarse guard on purpose: it cannot tell whether the field is used
|
||||
* CORRECTLY, only that the file knows it exists. Behavioural coverage lives in
|
||||
* the route tests.
|
||||
*/
|
||||
// Repo-root relative, resolved from this file rather than cwd: the plugin
|
||||
// suite runs with the plugin directory as cwd.
|
||||
const REPO_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../..",
|
||||
);
|
||||
|
||||
function source(relative: string): string {
|
||||
return readFileSync(path.resolve(REPO_ROOT, relative), "utf8");
|
||||
}
|
||||
|
||||
const ENUMERATION_POINTS = [
|
||||
"src/backend/database/database.ts",
|
||||
"src/backend/database/db/index.ts",
|
||||
"src/backend/database/routes/host-normalizers.ts",
|
||||
"src/backend/database/routes/host.ts",
|
||||
"src/backend/database/routes/host-bulk-routes.ts",
|
||||
"src/ui/sidebar/host-export-payload.ts",
|
||||
"src/ui/sidebar/HostManagerData.ts",
|
||||
"src/ui/sidebar/HostEditorData.ts",
|
||||
"src/ui/lib/host-to-ssh-host.ts",
|
||||
];
|
||||
|
||||
describe("web endpoint column enumeration", () => {
|
||||
it.each(ENUMERATION_POINTS)(
|
||||
"%s enumerates the enable flag alongside enableDocker",
|
||||
(file) => {
|
||||
const text = source(file);
|
||||
// Guards the guard: if the Docker anchor ever disappears from a file,
|
||||
// this test would otherwise pass vacuously forever.
|
||||
expect(text).toMatch(/enableDocker|enable_docker/);
|
||||
expect(text).toMatch(/enableWebUi|enable_web_ui/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ENUMERATION_POINTS)(
|
||||
"%s enumerates the config column alongside dockerConfig",
|
||||
(file) => {
|
||||
const text = source(file);
|
||||
expect(text).toMatch(/dockerConfig|docker_config/);
|
||||
expect(text).toMatch(/webUiConfig|web_ui_config/);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses webUiConfig through the guarded parser, not a bare JSON.parse", () => {
|
||||
// dockerConfig uses a bare JSON.parse, so one malformed value takes out
|
||||
// the whole host listing. Do not copy that.
|
||||
const text = source("src/backend/database/routes/host-normalizers.ts");
|
||||
expect(text).toContain("parseWebUiConfig");
|
||||
expect(text).not.toMatch(/JSON\.parse\(\s*host\.webUiConfig/);
|
||||
});
|
||||
|
||||
it("clears webUiConfig on disable in both host.ts write paths", () => {
|
||||
// Three write paths disagreed on this during the first attempt. The export
|
||||
// payload ships webUiConfig unconditionally, so a host disabled without
|
||||
// clearing still exports its endpoint list -- internal hostnames, ports and
|
||||
// paths -- while the UI reads as off everywhere, and re-enabling silently
|
||||
// resurrects stale endpoints.
|
||||
const text = source("src/backend/database/routes/host.ts");
|
||||
// Create and update. Anchored on the exact conditional, not a loose
|
||||
// pattern: a looser regex here passed even with the guard deleted.
|
||||
const occurrences = text.split("webUiConfig: enableWebUi").length - 1;
|
||||
expect(occurrences).toBe(2);
|
||||
});
|
||||
|
||||
it("clears webUiConfig on a bulk disable", () => {
|
||||
const text = source("src/backend/database/routes/host-bulk-routes.ts");
|
||||
expect(text).toContain(
|
||||
"if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;",
|
||||
);
|
||||
});
|
||||
|
||||
it("shares webUiConfig with connect-level recipients", () => {
|
||||
// A connect-level recipient is ALREADY authorized to open these tunnels
|
||||
// (the open route gates on canAccessHost(..., "connect")), so withholding
|
||||
// the config only breaks discovery while the flag advertises the feature.
|
||||
// The dockerConfig precedent does not transfer: Docker's tab works without
|
||||
// its config, whereas a web endpoint IS its config.
|
||||
const text = source("src/backend/database/routes/host-normalizers.ts");
|
||||
const block = text.slice(text.indexOf("CONNECT_LEVEL_FIELDS"));
|
||||
const list = block.slice(0, block.indexOf("]"));
|
||||
expect(list).toContain("enableWebUi");
|
||||
expect(list).toContain("webUiConfig");
|
||||
});
|
||||
});
|
||||
@@ -1,135 +1,160 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { createWebEndpointRoutes } from "../../src/backend/routes.js";
|
||||
|
||||
const resolveHostById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById,
|
||||
}));
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const forward = vi.fn();
|
||||
const openIsolatedWindow = vi.fn();
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res as unknown as express.Response & {
|
||||
statusCode: number;
|
||||
body: { port?: number; error?: string };
|
||||
function endpoint(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "http",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: unknown) {
|
||||
return { body } as unknown as express.Request;
|
||||
}
|
||||
|
||||
const endpoint = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "http",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function host(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "u1",
|
||||
name: "nas",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
enableWebUi: true,
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Activates the routes against a fake ctx, with or without the tunnels service. */
|
||||
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
|
||||
const fake = createFakeContext({
|
||||
let mock: MockPluginContext;
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
async function start(
|
||||
options: {
|
||||
hostOverrides?: Record<string, unknown>;
|
||||
noHost?: boolean;
|
||||
enableWebUi?: boolean;
|
||||
endpoints?: unknown[];
|
||||
capabilities?: string[];
|
||||
tunnelsAvailable?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
mock = createMockCtx({
|
||||
manifest,
|
||||
pluginId: "web-endpoint",
|
||||
actor: options.actor ?? "u1",
|
||||
actor: "u1",
|
||||
capabilities: options.capabilities ?? [
|
||||
"hosts:read",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"desktop:window",
|
||||
],
|
||||
hosts: options.noHost ? [] : [host(options.hostOverrides)],
|
||||
});
|
||||
if (options.tunnels !== false) {
|
||||
fake.ctx.services.provide("tunnels.access", { forward });
|
||||
const hostId = (options.hostOverrides?.id as number) ?? 7;
|
||||
if (options.enableWebUi !== false) {
|
||||
await mock.ctx.settings.setHost(hostId, "enableWebUi", true);
|
||||
await mock.ctx.settings.setHost(hostId, "webUiConfig", {
|
||||
endpoints: options.endpoints ?? [endpoint()],
|
||||
});
|
||||
}
|
||||
const routes = await import("../../src/backend/routes.js");
|
||||
routes.startWebEndpointService(express.Router(), fake.ctx);
|
||||
return routes;
|
||||
if (options.tunnelsAvailable !== false) {
|
||||
mock.ctx.services.provide("tunnels.access", { forward });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createWebEndpointRoutes(mock.ctx));
|
||||
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
forward.mockReset();
|
||||
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
|
||||
resolveHostById.mockReset();
|
||||
resolveHostById.mockResolvedValue(host());
|
||||
openIsolatedWindow.mockReset();
|
||||
openIsolatedWindow.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe("POST /open", () => {
|
||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
await start({ noHost: true });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
await start({ enableWebUi: false });
|
||||
await mock.ctx.settings.setHost(7, "webUiConfig", {
|
||||
endpoints: [endpoint()],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an endpoint id that matches nothing", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "nope" }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "nope" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns the port the tunnels service bound", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.port).toBe(41234);
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).port).toBe(41234);
|
||||
});
|
||||
|
||||
it("asks for a loopback forward under the reserved web name", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
await start();
|
||||
await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(forward).toHaveBeenCalledWith(
|
||||
7,
|
||||
{
|
||||
@@ -143,85 +168,137 @@ describe("POST /open", () => {
|
||||
});
|
||||
|
||||
it("passes the endpoint's own bind address and fixed port through", async () => {
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({
|
||||
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
await start({
|
||||
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||
});
|
||||
await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
const target = forward.mock.calls[0][1];
|
||||
expect(target.bindHost).toBe("0.0.0.0");
|
||||
expect(target.bindPort).toBe(38080);
|
||||
});
|
||||
|
||||
it("reports the service's reason as a 502 instead of a dead 200", async () => {
|
||||
await start();
|
||||
forward.mockRejectedValue(
|
||||
new Error("Channel open failure: connect failed"),
|
||||
);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/connect failed/);
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(502);
|
||||
expect(String((await res.json()).error)).toMatch(/connect failed/);
|
||||
});
|
||||
|
||||
it("answers 503 while the tunnels plugin is not available", async () => {
|
||||
const { handleWebEndpointOpen } = await load({ tunnels: false });
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(503);
|
||||
await start({ tunnelsAvailable: false });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects a malformed request before touching the database", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
it("rejects a malformed request before touching settings", async () => {
|
||||
await start();
|
||||
const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost");
|
||||
for (const body of [
|
||||
{},
|
||||
{ hostId: "7", endpointId: "e1" },
|
||||
{ hostId: 0, endpointId: "e1" },
|
||||
]) {
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request(body), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(resolveHostById).not.toHaveBeenCalled();
|
||||
expect(getHostSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a direct-access endpoint", async () => {
|
||||
await start({ endpoints: [endpoint({ access: "direct" })] });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The client path and the server path have to agree. Core mounts every
|
||||
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
|
||||
* and the only half this plugin owns is "/open".
|
||||
*/
|
||||
describe("route registration", () => {
|
||||
it("registers /open on the router core hands it", async () => {
|
||||
const routes = await load();
|
||||
describe("POST /open-window", () => {
|
||||
it("refuses without desktop:window", async () => {
|
||||
await start({
|
||||
capabilities: ["hosts:read", "network:serve", "ui:surface"],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("opens a tunnel first, then the window at the resolved port", async () => {
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(forward).toHaveBeenCalledOnce();
|
||||
expect(mock.desktopWindows).toEqual([
|
||||
{ url: "http://127.0.0.1:41234/", title: "Proxmox", ignoreCert: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a direct endpoint at the host's own address without a tunnel", async () => {
|
||||
await start({
|
||||
endpoints: [endpoint({ access: "direct", scheme: "https", port: 443 })],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
expect(mock.desktopWindows).toEqual([
|
||||
{ url: "https://10.0.0.5:443/", title: "Proxmox", ignoreCert: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores ignoreCert for a tunnel endpoint, whose host component is already loopback", async () => {
|
||||
await start();
|
||||
await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
|
||||
});
|
||||
expect(mock.desktopWindows[0].ignoreCert).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route registration", () => {
|
||||
it("registers /open and /open-window", async () => {
|
||||
await start();
|
||||
const routes = createWebEndpointRoutes(mock.ctx);
|
||||
const paths = (
|
||||
routes.router as unknown as {
|
||||
routes as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods.post)
|
||||
.map((layer) => layer.route?.path);
|
||||
|
||||
expect(paths).toContain("/open");
|
||||
});
|
||||
|
||||
it("is reached through the plugin mount rather than a port of its own", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../../src/backend/index.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("ctx.http.router()");
|
||||
expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"]));
|
||||
});
|
||||
});
|
||||
+3
-5
@@ -4,7 +4,7 @@ import {
|
||||
normalizeWebEndpoints,
|
||||
parseWebUiConfig,
|
||||
serializeWebUiConfig,
|
||||
} from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
} from "../../src/shared/web-endpoint-config.js";
|
||||
|
||||
function valid(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -249,8 +249,7 @@ describe("parseWebUiConfig", () => {
|
||||
});
|
||||
|
||||
it("returns empty endpoints for malformed JSON instead of throwing", () => {
|
||||
// A half-written config must not take out the whole host listing, which
|
||||
// is what dockerConfig's bare JSON.parse does today.
|
||||
// A half-written config must not take out the whole host listing.
|
||||
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
|
||||
});
|
||||
|
||||
@@ -263,8 +262,7 @@ describe("parseWebUiConfig", () => {
|
||||
describe("serializeWebUiConfig", () => {
|
||||
it("round-trips through normalization", () => {
|
||||
expect(
|
||||
parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] }))
|
||||
.endpoints[0].id,
|
||||
serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id,
|
||||
).toBe("e1");
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { createWebEndpointWindows } = createRequire(import.meta.url)(
|
||||
"../../../../electron/web-endpoint-window.cjs",
|
||||
);
|
||||
const created: FakeWindow[] = [];
|
||||
class FakeWindow extends EventEmitter {
|
||||
options: Record<string, unknown>;
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
mainFrame: {},
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
setMenu = vi.fn();
|
||||
loadURL = vi.fn().mockResolvedValue(undefined);
|
||||
constructor(options: Record<string, unknown>) {
|
||||
super();
|
||||
this.options = options;
|
||||
created.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
let main: FakeWindow;
|
||||
type TestSession = EventEmitter & {
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
|
||||
clearStorageData: ReturnType<typeof vi.fn>;
|
||||
clearCache: ReturnType<typeof vi.fn>;
|
||||
closeAllConnections: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let sessions: TestSession[];
|
||||
let fromPartition: ReturnType<typeof vi.fn>;
|
||||
let manager: ReturnType<typeof createWebEndpointWindows>;
|
||||
function event() {
|
||||
return { sender: main.webContents, senderFrame: main.webContents.mainFrame };
|
||||
}
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
sessions = [];
|
||||
main = new FakeWindow({});
|
||||
created.length = 0;
|
||||
fromPartition = vi.fn(() => {
|
||||
const session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
manager = createWebEndpointWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => main,
|
||||
});
|
||||
});
|
||||
describe("isolated endpoint windows", () => {
|
||||
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
||||
fromPartition.mock.calls[1][0],
|
||||
);
|
||||
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
|
||||
expect(created[0].options.webPreferences).toEqual({
|
||||
session: sessions[0],
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
});
|
||||
});
|
||||
it("rejects subframes, other windows and non-web destinations before creating a session", async () => {
|
||||
await expect(
|
||||
manager.open(
|
||||
{ ...event(), senderFrame: {} },
|
||||
{ url: "https://service.test" },
|
||||
),
|
||||
).rejects.toThrow(/main Termix/);
|
||||
await expect(
|
||||
manager.open({ ...event(), sender: {} }, { url: "https://service.test" }),
|
||||
).rejects.toThrow(/main Termix/);
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@service.test",
|
||||
]) {
|
||||
await expect(manager.open(event(), { url })).rejects.toThrow();
|
||||
}
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
const win = created[0],
|
||||
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||
expect(
|
||||
open({ url: "https://login.test" }).overrideBrowserWindowOptions
|
||||
.webPreferences.session,
|
||||
).toBe(sessions[0]);
|
||||
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
|
||||
const navigation = { preventDefault: vi.fn() };
|
||||
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
|
||||
expect(navigation.preventDefault).toHaveBeenCalled();
|
||||
const callback = vi.fn();
|
||||
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
|
||||
{ url: "file:///tmp/secret" },
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
||||
});
|
||||
it("limits invalid certificates to the opted-in origin, including its port", async () => {
|
||||
await manager.open(event(), {
|
||||
url: "https://service.test:8443",
|
||||
ignoreCert: true,
|
||||
});
|
||||
const e = { preventDefault: vi.fn() },
|
||||
callback = vi.fn();
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:8443/path",
|
||||
callback,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(callback).toHaveBeenLastCalledWith(true);
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:9443",
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenLastCalledWith(false);
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
main.webContents,
|
||||
"https://service.test:8443",
|
||||
callback,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("closes login popups and clears session data when the endpoint closes", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
const root = created[0],
|
||||
popup = new FakeWindow({});
|
||||
root.webContents.emit("did-create-window", popup);
|
||||
root.destroy();
|
||||
expect(popup.isDestroyed()).toBe(true);
|
||||
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
|
||||
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user