mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
305 lines
9.4 KiB
TypeScript
305 lines
9.4 KiB
TypeScript
import http from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import express from "express";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
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 manifest = manifestJson as unknown as PluginManifest;
|
|
|
|
const forward = vi.fn();
|
|
const openIsolatedWindow = vi.fn();
|
|
|
|
function endpoint(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
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",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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: "u1",
|
|
capabilities: options.capabilities ?? [
|
|
"hosts:read",
|
|
"network:serve",
|
|
"ui:surface",
|
|
"desktop:window",
|
|
],
|
|
hosts: options.noHost ? [] : [host(options.hostOverrides)],
|
|
});
|
|
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()],
|
|
});
|
|
}
|
|
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 });
|
|
openIsolatedWindow.mockReset();
|
|
openIsolatedWindow.mockResolvedValue({ success: true });
|
|
});
|
|
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
await start();
|
|
await fetch(`${baseUrl}/open`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
|
});
|
|
expect(forward).toHaveBeenCalledWith(
|
|
7,
|
|
{
|
|
targetHost: "127.0.0.1",
|
|
targetPort: 8006,
|
|
bindHost: "127.0.0.1",
|
|
bindPort: undefined,
|
|
},
|
|
{ name: "web:7:e1", idleTimeoutMs: 10 * 60 * 1000 },
|
|
);
|
|
});
|
|
|
|
it("passes the endpoint's own bind address and fixed port through", async () => {
|
|
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 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 () => {
|
|
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 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 = await fetch(`${baseUrl}/open`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
}
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 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).toEqual(expect.arrayContaining(["/open", "/open-window"]));
|
|
});
|
|
});
|