Files
Termix/plugins/tunnels/tests/backend/service.test.ts
T

176 lines
5.5 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import { createTunnelManager } from "../../src/backend/manager.js";
import {
createTunnelsService,
type TunnelsAccess,
} from "../../src/backend/service.js";
import { host, manifest, withHosts } from "./helpers";
class FakeClient extends EventEmitter {
failForward = false;
forwardOut = vi.fn(
(
_srcIP: string,
_srcPort: number,
_host: string,
_port: number,
cb: (err: Error | undefined, stream?: PassThrough) => void,
) => {
if (this.failForward)
cb(new Error("Channel open failure: connect failed"));
else cb(undefined, new PassThrough());
},
);
forwardIn = vi.fn();
unforwardIn = vi.fn();
end = vi.fn(() => this.emit("close"));
}
let manager: ReturnType<typeof createTunnelManager> | null = null;
afterEach(() => {
manager?.dispose();
manager = null;
});
function setup(): {
mock: MockPluginContext;
client: FakeClient;
service: TunnelsAccess;
} {
const client = new FakeClient();
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
actor: "user-1",
sshClient: client,
});
withHosts(mock, { "user-1": [host()], "user-2": [] });
manager = createTunnelManager(mock.ctx);
return { mock, client, service: createTunnelsService(mock.ctx, manager) };
}
const target = { targetHost: "127.0.0.1", targetPort: 8006 };
describe("tunnels.access forward()", () => {
it("resolves with the bound port once the target answered a probe", async () => {
const { service, client } = setup();
const handle = await service.forward(7, target, { name: "web:7:e1" });
expect(handle.bindHost).toBe("127.0.0.1");
expect(handle.bindPort).toBeGreaterThan(0);
expect(client.forwardOut).toHaveBeenCalledWith(
"127.0.0.1",
0,
"127.0.0.1",
8006,
expect.any(Function),
);
await handle.close();
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
});
it("reuses a live forward and reopens one whose target changed", async () => {
const { service, mock } = setup();
const first = await service.forward(7, target, { name: "web:7:e1" });
const again = await service.forward(7, target, { name: "web:7:e1" });
expect(again.bindPort).toBe(first.bindPort);
expect(mock.sshConnections).toHaveLength(1);
await service.forward(
7,
{ ...target, targetPort: 9000 },
{ name: "web:7:e1" },
);
expect(mock.sshConnections).toHaveLength(2);
});
it("rejects with the real reason and leaves nothing behind when the target is closed", async () => {
const { service, client } = setup();
client.failForward = true;
await expect(
service.forward(7, target, { name: "web:7:e1" }),
).rejects.toThrow(/connect failed/);
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
});
it("refuses a host the caller cannot reach and a name that is not reserved", async () => {
const { service, mock } = setup();
await mock.ctx.asUser("user-2", async () => {
await expect(service.forward(7, target)).rejects.toThrow(/access denied/);
});
await expect(
service.forward(7, target, { name: "my-tunnel" }),
).rejects.toThrow(/Forward names/);
await expect(
service.forward(7, target, { name: "web:8:e1" }),
).rejects.toThrow(/Forward names/);
expect(mock.sshConnections).toEqual([]);
});
});
describe("tunnels.access start/stop/status", () => {
const saved = {
scope: "s2s",
mode: "local",
sourcePort: 0,
endpointHost: "127.0.0.1",
endpointPort: 9,
maxRetries: 3,
retryInterval: 5,
autoStart: false,
};
const name = "7::0::web::0::127.0.0.1::9";
it("starts a host's saved tunnel by name and stops it again", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", true);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await service.start(name);
await vi.waitFor(async () =>
expect((await service.status(name))?.status).toBe("connected"),
);
expect(Object.keys(await service.list())).toEqual([name]);
await service.stop(name);
expect((await service.status(name))?.status).toBe("disconnected");
});
it("refuses a name that matches no saved tunnel", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", false);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await expect(service.start(name)).rejects.toThrow(/not configured/);
await expect(service.start("nope")).rejects.toThrow(/not configured/);
});
it("hides a tunnel from a user who cannot reach its host", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", true);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await service.start(name);
// The fake context tracks one actor globally, so let the connect settle
// before switching users.
await vi.waitFor(async () =>
expect((await service.status(name))?.status).toBe("connected"),
);
await mock.ctx.asUser("user-2", async () => {
expect(await service.status(name)).toBeNull();
expect(await service.list()).toEqual({});
await expect(service.stop(name)).rejects.toThrow(/Access denied/);
});
});
});