mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
221 lines
6.5 KiB
TypeScript
221 lines
6.5 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { host, startServer, type TestServer } from "./helpers";
|
|
|
|
let server: TestServer | null = null;
|
|
|
|
afterEach(async () => {
|
|
await server?.close();
|
|
server = null;
|
|
});
|
|
|
|
const clientTunnel = (overrides: Record<string, unknown> = {}) => ({
|
|
scope: "c2s",
|
|
mode: "local",
|
|
sourcePort: 8080,
|
|
endpointPort: 5432,
|
|
maxRetries: 3,
|
|
retryInterval: 10,
|
|
autoStart: false,
|
|
...overrides,
|
|
});
|
|
|
|
describe("client tunnel presets", () => {
|
|
it("creates, lists by name, updates and deletes the caller's presets", async () => {
|
|
server = await startServer();
|
|
|
|
const zulu = await server.request("POST", "/presets", {
|
|
body: {
|
|
name: " Zulu ",
|
|
config: [clientTunnel()],
|
|
platform: "linux",
|
|
computerName: "workstation",
|
|
},
|
|
});
|
|
expect(zulu.status).toBe(201);
|
|
expect(zulu.body).toMatchObject({
|
|
name: "Zulu",
|
|
config: [clientTunnel()],
|
|
platform: "linux",
|
|
computerName: "workstation",
|
|
});
|
|
await server.request("POST", "/presets", {
|
|
body: { name: "Alpha", config: [] },
|
|
});
|
|
await server.request("POST", "/presets", {
|
|
user: "user-2",
|
|
body: { name: "Other", config: [] },
|
|
});
|
|
|
|
const listed = await server.request("GET", "/presets");
|
|
expect(listed.body.map((p: { name: string }) => p.name)).toEqual([
|
|
"Alpha",
|
|
"Zulu",
|
|
]);
|
|
|
|
const updated = await server.request("PUT", `/presets/${zulu.body.id}`, {
|
|
body: { name: "Yankee", config: [clientTunnel({ mode: "dynamic" })] },
|
|
});
|
|
expect(updated.status).toBe(200);
|
|
expect(updated.body.name).toBe("Yankee");
|
|
expect(updated.body.config[0].mode).toBe("dynamic");
|
|
|
|
const deleted = await server.request("DELETE", `/presets/${zulu.body.id}`);
|
|
expect(deleted.body).toEqual({ success: true });
|
|
expect(
|
|
(await server.request("GET", "/presets")).body.map(
|
|
(p: { name: string }) => p.name,
|
|
),
|
|
).toEqual(["Alpha"]);
|
|
expect(server.db.persisted).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects a duplicate name and a config that is not a client tunnel", async () => {
|
|
server = await startServer();
|
|
await server.request("POST", "/presets", {
|
|
body: { name: "Home", config: [] },
|
|
});
|
|
|
|
expect(
|
|
(
|
|
await server.request("POST", "/presets", {
|
|
body: { name: "Home", config: [] },
|
|
})
|
|
).status,
|
|
).toBe(409);
|
|
|
|
for (const config of [
|
|
[clientTunnel({ scope: "s2s" })],
|
|
[clientTunnel({ mode: "sideways" })],
|
|
[clientTunnel({ sourcePort: 70000 })],
|
|
[clientTunnel({ endpointPort: 0 })],
|
|
"nope",
|
|
]) {
|
|
const res = await server.request("POST", "/presets", {
|
|
body: { name: "Bad", config },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
}
|
|
|
|
// Dynamic mode has no destination port to check.
|
|
expect(
|
|
(
|
|
await server.request("POST", "/presets", {
|
|
body: {
|
|
name: "Socks",
|
|
config: [clientTunnel({ mode: "dynamic", endpointPort: 0 })],
|
|
},
|
|
})
|
|
).status,
|
|
).toBe(201);
|
|
});
|
|
|
|
it("never lets one user touch another user's preset", async () => {
|
|
server = await startServer();
|
|
const mine = await server.request("POST", "/presets", {
|
|
body: { name: "Mine", config: [] },
|
|
});
|
|
|
|
for (const [method, body] of [
|
|
["PUT", { name: "Stolen" }],
|
|
["DELETE", undefined],
|
|
] as const) {
|
|
const res = await server.request(method, `/presets/${mine.body.id}`, {
|
|
user: "user-2",
|
|
body,
|
|
});
|
|
expect(res.status).toBe(404);
|
|
}
|
|
expect((await server.request("GET", "/presets")).body[0].name).toBe("Mine");
|
|
});
|
|
});
|
|
|
|
describe("tunnel control", () => {
|
|
it("refuses reserved names and a name that does not match its config", async () => {
|
|
server = await startServer();
|
|
|
|
const reserved = await server.request("POST", "/connect", {
|
|
body: { name: "web:7:e1", sourceHostId: 7, tunnelIndex: 0 },
|
|
});
|
|
expect(reserved.status).toBe(400);
|
|
expect(reserved.body.error).toMatch(/reserved/);
|
|
|
|
const mismatched = await server.request("POST", "/connect", {
|
|
body: {
|
|
name: "7::0::web::8080::db::5432",
|
|
sourceHostId: 7,
|
|
tunnelIndex: 0,
|
|
sourcePort: 9999,
|
|
endpointHost: "db",
|
|
endpointPort: 5432,
|
|
},
|
|
});
|
|
expect(mismatched.status).toBe(400);
|
|
});
|
|
|
|
it("refuses to connect through a host the caller cannot reach", async () => {
|
|
server = await startServer({ hosts: { "user-1": [host()] } });
|
|
|
|
const res = await server.request("POST", "/connect", {
|
|
user: "user-2",
|
|
body: {
|
|
name: "7::0::web::8080::db::5432",
|
|
sourceHostId: 7,
|
|
tunnelIndex: 0,
|
|
sourcePort: 8080,
|
|
endpointHost: "db",
|
|
endpointPort: 5432,
|
|
},
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(server.mock.sshConnections).toEqual([]);
|
|
});
|
|
|
|
it("denies stopping someone else's on-demand tunnel by guessing its name", async () => {
|
|
server = await startServer({
|
|
hosts: { "user-1": [host()], "user-2": [] },
|
|
});
|
|
|
|
for (const path of ["/disconnect", "/cancel"]) {
|
|
const res = await server.request("POST", path, {
|
|
user: "user-2",
|
|
body: { tunnelName: "web:7:e1" },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
}
|
|
|
|
const own = await server.request("POST", "/disconnect", {
|
|
body: { tunnelName: "web:7:e1" },
|
|
});
|
|
expect(own.status).toBe(200);
|
|
});
|
|
|
|
it("answers an empty status map and a 404 for a tunnel nobody can see", async () => {
|
|
server = await startServer();
|
|
expect((await server.request("GET", "/status")).body).toEqual({});
|
|
expect((await server.request("GET", "/status/nope")).status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("permissions", () => {
|
|
it("answers 403 on every route without tunnels.use", async () => {
|
|
server = await startServer({ permissions: [] });
|
|
|
|
const routes: Array<[string, string, unknown?]> = [
|
|
["GET", "/status"],
|
|
["GET", "/status/stream"],
|
|
["GET", "/status/some-tunnel"],
|
|
["POST", "/connect", { name: "x", sourceHostId: 7, tunnelIndex: 0 }],
|
|
["POST", "/disconnect", { tunnelName: "x" }],
|
|
["POST", "/cancel", { tunnelName: "x" }],
|
|
["GET", "/presets"],
|
|
["POST", "/presets", { name: "x", config: [] }],
|
|
["PUT", "/presets/1", { name: "x" }],
|
|
["DELETE", "/presets/1"],
|
|
];
|
|
for (const [method, path, body] of routes) {
|
|
const res = await server.request(method, path, { body });
|
|
expect(res.status, `${method} ${path}`).toBe(403);
|
|
}
|
|
});
|
|
});
|