mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, sshHost } from "./server";
|
||||
import { FakeClient, FakeStream } from "./fake-ssh";
|
||||
|
||||
/** A ws WebSocket as far as the console uses one. */
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState = 1;
|
||||
sent: Array<Record<string, unknown>> = [];
|
||||
closedWith: [number, string] | null = null;
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
ping() {}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
if (this.readyState !== 1) return;
|
||||
this.readyState = 3;
|
||||
this.closedWith = [code, reason];
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
message(payload: unknown) {
|
||||
this.emit("message", Buffer.from(JSON.stringify(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
function consoleClient() {
|
||||
const shells: FakeStream[] = [];
|
||||
const client = new FakeClient([
|
||||
[/which bash/, { stdout: "/bin/bash\n" }],
|
||||
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
|
||||
]);
|
||||
return { client, shells };
|
||||
}
|
||||
|
||||
async function activated(client: FakeClient): Promise<MockPluginContext> {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => express.Router(),
|
||||
permissions: ["docker.use"],
|
||||
sshHosts: [sshHost(7)],
|
||||
sshClient: client,
|
||||
});
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
// Like the runtime, disposing a connection ends its client.
|
||||
const connect = mock.ctx.ssh.connect;
|
||||
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
|
||||
...(await connect(...args)),
|
||||
dispose: () => client.end(),
|
||||
})) as typeof connect;
|
||||
await activate(mock.ctx);
|
||||
return mock;
|
||||
}
|
||||
|
||||
async function openConsole(mock: MockPluginContext, userId = "user-1") {
|
||||
const route = mock.wsRoutes.find((r) => r.path === "/console");
|
||||
expect(route?.options).toEqual({ public: true, optionalAuth: true });
|
||||
const socket = new FakeSocket();
|
||||
mock.setActor(userId || undefined);
|
||||
await route!.handler!({
|
||||
userId,
|
||||
request: {},
|
||||
socket,
|
||||
clientIp: "127.0.0.1",
|
||||
requestOrigin: "http://localhost",
|
||||
isDataUnlocked: () => true,
|
||||
} as PluginWebSocketConnection);
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function until(check: () => boolean) {
|
||||
for (let i = 0; i < 50 && !check(); i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(check()).toBe(true);
|
||||
}
|
||||
|
||||
let disposals: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposals.reverse()) await dispose();
|
||||
disposals = [];
|
||||
});
|
||||
|
||||
describe("docker console", () => {
|
||||
it("refuses a socket without a user", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock, "");
|
||||
expect(socket.closedWith?.[0]).toBe(1008);
|
||||
});
|
||||
|
||||
it("closes an open console on disable, and the next enable works", async () => {
|
||||
const first = consoleClient();
|
||||
const mock = await activated(first.client);
|
||||
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig: { id: 7, ip: "10.0.0.7" },
|
||||
containerId: "abc123",
|
||||
shell: "bash",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
},
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "connected"));
|
||||
socket.message({ type: "input", data: "ls\n" });
|
||||
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
|
||||
|
||||
// Disable: the runtime empties the disposable bag.
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
expect(first.shells[0].ended).toBe(true);
|
||||
expect(first.client.ended).toBe(true);
|
||||
expect(socket.closedWith?.[0]).toBe(1001);
|
||||
|
||||
// Enable again: a fresh activate on the same module serves a new console.
|
||||
const second = consoleClient();
|
||||
const again = await activated(second.client);
|
||||
disposals.push(...again.disposals);
|
||||
const next = await openConsole(again);
|
||||
next.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => next.sent.some((m) => m.type === "connected"));
|
||||
second.shells[0].emit("data", Buffer.from("hello"));
|
||||
await until(() =>
|
||||
next.sent.some((m) => m.type === "output" && m.data === "hello"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a host id that resolves to another machine", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", false);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
|
||||
export class FakeStream extends EventEmitter {
|
||||
stderr = new EventEmitter();
|
||||
written: string[] = [];
|
||||
ended = false;
|
||||
window: [number, number] | null = null;
|
||||
|
||||
write(data: string) {
|
||||
this.written.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
setWindow(rows: number, cols: number) {
|
||||
this.window = [rows, cols];
|
||||
}
|
||||
|
||||
/** Emits output and exits, on the next tick like a real channel. */
|
||||
finish(stdout: string, code = 0, stderr = "") {
|
||||
setImmediate(() => {
|
||||
if (stdout) this.emit("data", Buffer.from(stdout));
|
||||
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
|
||||
this.emit("close", code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecReply =
|
||||
| { stdout?: string; stderr?: string; code?: number }
|
||||
| ((stream: FakeStream) => void);
|
||||
|
||||
/**
|
||||
* An ssh2 Client that answers exec by the first matching pattern. A reply
|
||||
* may be a function for a long-lived channel such as a console.
|
||||
*/
|
||||
export class FakeClient extends EventEmitter {
|
||||
commands: string[] = [];
|
||||
streams: FakeStream[] = [];
|
||||
ended = false;
|
||||
|
||||
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
reply(pattern: RegExp, reply: ExecReply) {
|
||||
this.replies.unshift([pattern, reply]);
|
||||
}
|
||||
|
||||
exec(
|
||||
command: string,
|
||||
optionsOrCallback: unknown,
|
||||
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
|
||||
) {
|
||||
const callback = (
|
||||
typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback
|
||||
: maybeCallback
|
||||
) as (err: Error | undefined, stream: FakeStream) => void;
|
||||
this.commands.push(command);
|
||||
const stream = new FakeStream();
|
||||
this.streams.push(stream);
|
||||
const match = this.replies.find(([pattern]) => pattern.test(command));
|
||||
callback(undefined, stream);
|
||||
if (!match) {
|
||||
stream.finish("");
|
||||
return this;
|
||||
}
|
||||
const reply = match[1];
|
||||
if (typeof reply === "function") reply(stream);
|
||||
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
normalizeImportedHost,
|
||||
readDockerHostSettings,
|
||||
} from "../../src/backend/host-settings.js";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../src/backend/container-state.js";
|
||||
import { manifest, startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("docker activate", () => {
|
||||
it("fails closed without network:serve", async () => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "network:serve",
|
||||
),
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot reach a host without ssh:connect", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "ssh:connect",
|
||||
),
|
||||
},
|
||||
});
|
||||
const response = await server.request("POST", "/ssh/connect", {
|
||||
body: { sessionId: "s1", hostId: 7 },
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("registers its import normalizer", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
|
||||
).toBe(normalizeImportedHost);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker host settings", () => {
|
||||
it("reads the switch and runtime with defaults", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: false,
|
||||
runtime: "docker",
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: true,
|
||||
runtime: "podman",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes an export's nested settings over the old flat fields", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: false,
|
||||
pluginSettings: {
|
||||
docker: { enableDocker: true, containerRuntime: "podman" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
});
|
||||
|
||||
it("reads 2.8 flat fields and proxmox-created rows", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: 1,
|
||||
dockerConfig: '{"runtime":"podman"}',
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
|
||||
enableDocker: true,
|
||||
});
|
||||
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part:
|
||||
* turning `ps` output into states, and two snapshots into events.
|
||||
*/
|
||||
describe("container state", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const states = parseContainerStates(
|
||||
[
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n"),
|
||||
);
|
||||
expect([...states.keys()]).toEqual(["web", "api"]);
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("reports transitions and skips containers seen for the first time", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
const restarting = { state: "restarting", unhealthy: false };
|
||||
expect(
|
||||
diffContainerStates(
|
||||
new Map([
|
||||
["web", running],
|
||||
["db", exited],
|
||||
["cache", running],
|
||||
]),
|
||||
new Map([
|
||||
["web", exited],
|
||||
["db", running],
|
||||
["cache", restarting],
|
||||
["new", running],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{ container: "web", event: "exited" },
|
||||
{ container: "db", event: "started" },
|
||||
{ container: "cache", event: "restarting" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
|
||||
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
|
||||
}
|
||||
|
||||
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
|
||||
function promptingConnect(
|
||||
s: TestServer,
|
||||
flow: (
|
||||
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
|
||||
) => Promise<void>,
|
||||
) {
|
||||
s.mock.ctx.ssh.connect = (async (
|
||||
host: PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
) => {
|
||||
await flow(options!.prompt!.ask);
|
||||
return {
|
||||
client: s.client as never,
|
||||
jumpClient: null,
|
||||
host,
|
||||
dispose: () => s.client.end(),
|
||||
};
|
||||
}) as never;
|
||||
}
|
||||
|
||||
describe("docker routes", () => {
|
||||
it("refuses every route without docker.use", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const calls: Array<[string, string]> = [
|
||||
["POST", "/ssh/connect"],
|
||||
["POST", "/ssh/connect-totp"],
|
||||
["POST", "/ssh/connect-warpgate"],
|
||||
["POST", "/ssh/disconnect"],
|
||||
["POST", "/ssh/keepalive"],
|
||||
["GET", "/ssh/status?sessionId=s1"],
|
||||
["GET", "/validate/s1"],
|
||||
["GET", "/containers/s1"],
|
||||
["GET", "/containers/s1/abc"],
|
||||
["POST", "/containers/s1/abc/start"],
|
||||
["DELETE", "/containers/s1/abc/remove"],
|
||||
["GET", "/containers/s1/abc/logs"],
|
||||
["GET", "/containers/s1/abc/stats"],
|
||||
];
|
||||
for (const [method, path] of calls) {
|
||||
const response = await server.request(method, path, {
|
||||
body: method === "GET" ? undefined : {},
|
||||
});
|
||||
expect(response.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("DOCKER_DISABLED");
|
||||
});
|
||||
|
||||
it("answers 404 for a host the user cannot reach", async () => {
|
||||
server = await startServer();
|
||||
expect((await connect(server, "s1", 99)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("connects, validates and manages containers on the session", async () => {
|
||||
server = await startServer();
|
||||
const connected = await connect(server);
|
||||
expect(connected.status).toBe(200);
|
||||
expect(connected.body.success).toBe(true);
|
||||
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
|
||||
|
||||
const validation = await server.request("GET", "/validate/s1");
|
||||
expect(validation.body).toEqual({
|
||||
available: true,
|
||||
version: "27.1.1",
|
||||
runtime: "docker",
|
||||
});
|
||||
|
||||
const list = await server.request("GET", "/containers/s1");
|
||||
expect(list.body).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
|
||||
]);
|
||||
|
||||
server.client.reply(/ start abc123$/, { stdout: "abc123" });
|
||||
const started = await server.request("POST", "/containers/s1/abc123/start");
|
||||
expect(started.status).toBe(200);
|
||||
expect(
|
||||
server.client.commands.some((c) => c.endsWith("docker start abc123")),
|
||||
).toBe(true);
|
||||
|
||||
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
|
||||
const logs = await server.request(
|
||||
"GET",
|
||||
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
|
||||
);
|
||||
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
|
||||
expect(server.client.commands.at(-1)).toContain(
|
||||
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
|
||||
);
|
||||
|
||||
server.client.reply(/ stats abc123/, {
|
||||
stdout:
|
||||
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||
});
|
||||
const stats = await server.request("GET", "/containers/s1/abc123/stats");
|
||||
expect(stats.body).toMatchObject({
|
||||
cpu: "1.5%",
|
||||
memoryUsed: "10MiB",
|
||||
memoryLimit: "1GiB",
|
||||
netInput: "1kB",
|
||||
netOutput: "2kB",
|
||||
});
|
||||
|
||||
server.client.reply(/ rm abc123/, {
|
||||
code: 1,
|
||||
stderr: "Error: No such container: abc123",
|
||||
});
|
||||
const removed = await server.request(
|
||||
"DELETE",
|
||||
"/containers/s1/abc123/remove",
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
|
||||
expect(
|
||||
(await server.request("POST", "/containers/s1/abc123/explode")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
|
||||
).toBe(400);
|
||||
|
||||
await server.request("POST", "/ssh/disconnect", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1");
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Podman when the host says so", async () => {
|
||||
server = await startServer();
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
await connect(server);
|
||||
await server.request("GET", "/containers/s1");
|
||||
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
|
||||
});
|
||||
|
||||
it("keeps another user's session to its owner", async () => {
|
||||
server = await startServer();
|
||||
await connect(server);
|
||||
const response = await server.request("GET", "/containers/s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
|
||||
while (code !== "123456") {
|
||||
if (code === null) throw new Error("Cancelled");
|
||||
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
|
||||
}
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
|
||||
|
||||
const pending = await server.request("GET", "/containers/s1");
|
||||
expect(pending.body.code).toBe("AUTH_PENDING");
|
||||
|
||||
const wrong = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "000000" },
|
||||
});
|
||||
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
|
||||
|
||||
const stranger = await server.request("POST", "/ssh/connect-totp", {
|
||||
user: "user-2",
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(stranger.status).toBe(404);
|
||||
|
||||
const right = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(right.body).toMatchObject({ success: true, status: "success" });
|
||||
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("continues a Warpgate sign-in and refuses a code for it", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
await ask({
|
||||
kind: "warpgate",
|
||||
url: "https://warpgate.example/login",
|
||||
securityKey: "KEY",
|
||||
instructions: "",
|
||||
});
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({
|
||||
requires_warpgate: true,
|
||||
url: "https://warpgate.example/login",
|
||||
securityKey: "KEY",
|
||||
});
|
||||
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "1" },
|
||||
});
|
||||
expect(wrongKind.status).toBe(400);
|
||||
|
||||
const done = await server.request("POST", "/ssh/connect-warpgate", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
expect(done.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("asks for credentials when a host with no secret gets a password prompt", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
sshHosts: [
|
||||
{
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
promptingConnect(server, async (ask) => {
|
||||
const answer = await ask({
|
||||
kind: "input",
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
isPush: false,
|
||||
});
|
||||
if (answer === null)
|
||||
throw new Error("All configured authentication methods failed");
|
||||
});
|
||||
|
||||
const response = await connect(server);
|
||||
expect(response.body).toEqual({
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failed login to core", async () => {
|
||||
const client = new FakeClient();
|
||||
server = await startServer({ client });
|
||||
server.mock.ctx.ssh.connect = (async () => {
|
||||
throw new Error("All configured authentication methods failed");
|
||||
}) as never;
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(500);
|
||||
expect(server.mock.statusReports).toContainEqual({
|
||||
hostId: 7,
|
||||
ok: false,
|
||||
hostKeyChanged: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockContextOptions,
|
||||
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";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const PS_LINE =
|
||||
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
|
||||
|
||||
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
|
||||
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A client that looks like a Linux host with Docker running. */
|
||||
export function dockerClient(): FakeClient {
|
||||
return new FakeClient([
|
||||
[/^ver$/, { stdout: "" }],
|
||||
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
|
||||
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
|
||||
[/ ps$/, { stdout: "" }],
|
||||
]);
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
mock: MockPluginContext;
|
||||
client: FakeClient;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The plugin activated with its router served the way core mounts it. */
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
dockerOn?: boolean;
|
||||
client?: FakeClient;
|
||||
mock?: Partial<MockContextOptions>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const client = options.client ?? dockerClient();
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["docker.use"],
|
||||
sshHosts: [sshHost(7), sshHost(8)],
|
||||
sshClient: client,
|
||||
...options.mock,
|
||||
});
|
||||
if (options.dockerOn !== false) {
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
}
|
||||
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
mock,
|
||||
client,
|
||||
async request(method, path, { user = "user-1", body } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
for (const dispose of mock.disposals.reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "../../src/backend/index.js";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const psState = (state: string, status = "Up") =>
|
||||
`{"name":"web","state":"${state}","status":"${status}"}\n`;
|
||||
|
||||
describe("docker.containers service", () => {
|
||||
it("lists containers and runs actions as the caller", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
|
||||
const containers = await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.listContainers(7),
|
||||
);
|
||||
expect(containers).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web" }),
|
||||
]);
|
||||
|
||||
await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web", "restart"),
|
||||
);
|
||||
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
|
||||
expect(server.mock.sshConnections.at(-1)).toMatchObject({
|
||||
host: 7,
|
||||
pool: "docker",
|
||||
});
|
||||
|
||||
await expect(
|
||||
server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web; rm -rf /", "stop"),
|
||||
),
|
||||
).rejects.toThrow(/Invalid container name/);
|
||||
});
|
||||
|
||||
it("needs a calling user", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker.events service", () => {
|
||||
it("reports state changes between polls to every subscriber", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
const seen: DockerEvent[] = [];
|
||||
const listener = (event: DockerEvent) => seen.push(event);
|
||||
|
||||
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
|
||||
events.subscribe(7, listener),
|
||||
);
|
||||
// Subscribing the same listener again is a no-op.
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
|
||||
|
||||
const poll = async (output: string) => {
|
||||
server!.client.reply(/ ps -a --format/, { stdout: output });
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await server!.mock.runScheduled();
|
||||
};
|
||||
|
||||
await poll(psState("running"));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
await poll(psState("exited", "Exited (1)"));
|
||||
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
|
||||
|
||||
await poll(psState("running", "Up 1 second (unhealthy)"));
|
||||
expect(seen.slice(1)).toEqual([
|
||||
{ hostId: 7, container: "web", event: "started" },
|
||||
{ hostId: 7, container: "web", event: "unhealthy" },
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
const before = server.client.commands.length;
|
||||
await poll(psState("exited"));
|
||||
expect(server.client.commands.length).toBe(before);
|
||||
expect(seen).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops polling once the plugin is disposed", async () => {
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
|
||||
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
|
||||
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function host(enableDocker: boolean) {
|
||||
return {
|
||||
id: "7",
|
||||
name: "box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
enableSsh: true,
|
||||
pluginSettings: { docker: { enableDocker } },
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(connectResult: Record<string, unknown>) {
|
||||
const post = vi.fn(async (url: string) => {
|
||||
if (url === "/ssh/connect") return { data: connectResult };
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url.startsWith("/validate/")) {
|
||||
return {
|
||||
data: { available: true, version: "27.1.1", runtime: "docker" },
|
||||
};
|
||||
}
|
||||
if (url.startsWith("/containers/")) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "abc123",
|
||||
name: "web",
|
||||
image: "nginx",
|
||||
status: "Up",
|
||||
state: "running",
|
||||
ports: "",
|
||||
created: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: {} };
|
||||
});
|
||||
return {
|
||||
post,
|
||||
get,
|
||||
delete: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
} as unknown as {
|
||||
post: typeof post;
|
||||
get: typeof get;
|
||||
} & PluginApiClient;
|
||||
}
|
||||
|
||||
describe("DockerManager", () => {
|
||||
it("says Docker is off for a host without the switch", async () => {
|
||||
const api = stubApi({ success: true });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(false)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(false),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("Docker is not enabled for this host"),
|
||||
).toBeTruthy();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connects through the plugin api and lists containers", async () => {
|
||||
const api = stubApi({ success: true, status: "success" });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/ssh/connect",
|
||||
expect.objectContaining({ hostId: 7 }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/validate\//),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the code prompt when the host asks for one", async () => {
|
||||
const api = stubApi({
|
||||
requires_totp: true,
|
||||
sessionId: "s1",
|
||||
prompt: "Verification code:",
|
||||
});
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(await screen.findByText("Verification code:")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,11 @@ describe(`${manifest.id} activate`, () => {
|
||||
for (const id of rendered.registered.dashboardCards()) {
|
||||
expect(cards).toContain(id);
|
||||
}
|
||||
// Host settings render from the manifest; there is no editor tab.
|
||||
expect(rendered.registered.hostEditorSections()).toEqual([]);
|
||||
expect(rendered.registered.hostActions()).toEqual([
|
||||
expect.objectContaining({ id: "docker", tabType: "docker" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
|
||||
Reference in new issue
Block a user