mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user