summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/integration.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ssh/src/integration.test.ts')
-rw-r--r--packages/ssh/src/integration.test.ts184
1 files changed, 184 insertions, 0 deletions
diff --git a/packages/ssh/src/integration.test.ts b/packages/ssh/src/integration.test.ts
new file mode 100644
index 0000000..7b05be2
--- /dev/null
+++ b/packages/ssh/src/integration.test.ts
@@ -0,0 +1,184 @@
+/**
+ * Integration test against a REAL sshd (the outermost edge). NOT mocked:
+ * - no `vi.mock` of `@dispatch/*` (forbidden by the constitution);
+ * - no mock of `ssh2` itself (that would defeat the purpose — the smoke test
+ * from the load-bearing first step IS the real-edge proof).
+ *
+ * Skipped unless `SSH_TEST_HOST` is set, so CI without an sshd stays green. The
+ * orchestrator live-verifies by exporting `SSH_TEST_HOST=localhost` (with the
+ * user's own key + an sshd on :22). The test exercises the full path: config
+ * reader → pool connect (key-only auth + host-key pin) → SshExecBackend spawn +
+ * SFTP fs ops, all over the real ssh2-under-Bun edge.
+ */
+
+import { access, mkdir, mkdtemp, readFile } from "node:fs/promises";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import type { Logger } from "@dispatch/kernel";
+import { Client } from "ssh2";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createSshExecBackend } from "./backend.js";
+import { resolveComputer } from "./config.js";
+import { createSshConnectionPool } from "./pool.js";
+
+const HOST = process.env.SSH_TEST_HOST;
+const PORT = process.env.SSH_TEST_PORT ? Number.parseInt(process.env.SSH_TEST_PORT, 10) : 22;
+const USER = process.env.SSH_TEST_USER ?? process.env.USER ?? "";
+
+const testEnv = HOST === undefined ? null : { host: HOST, port: PORT, user: USER };
+
+// Build a real config env fixture pointing at the test sshd.
+function configText(): string {
+ if (testEnv === null) return "";
+ return `Host testremote\n HostName ${testEnv.host}\n Port ${testEnv.port}\n User ${testEnv.user}\n`;
+}
+
+const sshDir = join(homedir(), ".ssh");
+
+// Build real pool deps (real node:fs + real ssh2) for the test sshd.
+/**
+ * A self-referential `Logger` stub: every method is a no-op, and `child()`
+ * returns itself so the type is complete (the integration test logs nothing).
+ */
+function noopLogger(): Logger {
+ const log: Logger = {
+ debug: () => undefined,
+ info: () => undefined,
+ warn: () => undefined,
+ error: () => undefined,
+ child: () => log,
+ span: (name: string) => ({
+ id: name,
+ log,
+ setAttributes: () => undefined,
+ addLink: () => undefined,
+ child: (n: string) =>
+ ({
+ id: n,
+ log,
+ setAttributes: () => undefined,
+ addLink: () => undefined,
+ child: () => ({ id: n, log }) as never,
+ end: () => undefined,
+ }) as never,
+ end: () => undefined,
+ }),
+ };
+ return log;
+}
+
+function realDeps() {
+ return {
+ logger: noopLogger(),
+ homeDir: homedir(),
+ knownHostsPath: join(sshDir, "known_hosts"),
+ readFileText: (p: string) => readFile(p, "utf8"),
+ appendKnownHosts: async () => undefined, // don't mutate the real known_hosts in a test
+ pathExists: (p: string) =>
+ access(p)
+ .then(() => true)
+ .catch(() => false),
+ newClient: () => new Client(),
+ resolveComputer: async (alias: string) =>
+ resolveComputer(alias, {
+ configText: configText(),
+ knownHostsText: "",
+ defaultUser: USER,
+ homeDir: homedir(),
+ }),
+ };
+}
+
+describe.skipIf(testEnv === null)("SshExecBackend against a real sshd", () => {
+ let pool: ReturnType<typeof createSshConnectionPool>;
+ let tmpRemoteDir: string;
+
+ beforeEach(async () => {
+ pool = createSshConnectionPool(realDeps());
+ // Create a remote temp dir to run cwd-scoped commands in.
+ tmpRemoteDir = await mkdtemp(join(tmpdir(), "ssh-int-"));
+ });
+
+ afterEach(async () => {
+ await pool.closeAll();
+ // best-effort cleanup of the remote temp dir.
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ try {
+ await backend.spawn({
+ command: `rm -rf ${tmpRemoteDir}`,
+ cwd: "/",
+ signal: new AbortController().signal,
+ timeout: 5000,
+ onOutput: () => undefined,
+ });
+ } catch {
+ // ignore
+ }
+ });
+
+ it("connects + execs a command, returning stdout + exit code", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ let stdout = "";
+ const res = await backend.spawn({
+ command: "echo integration_ok; exit 7",
+ cwd: tmpRemoteDir,
+ signal: new AbortController().signal,
+ timeout: 10000,
+ onOutput: (data, stream) => {
+ if (stream === "stdout") stdout += data;
+ },
+ });
+ expect(stdout.trim()).toBe("integration_ok");
+ expect(res.exitCode).toBe(7);
+ expect(res.timedOut).toBe(false);
+ expect(res.aborted).toBe(false);
+ });
+
+ it("writes a file over SFTP then reads it back", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "sftp-probe.txt");
+ await backend.writeFile(path, "hello-sftp");
+ const content = await backend.readFile(path);
+ expect(content).toBe("hello-sftp");
+ });
+
+ it("stat reports isFile/isDirectory correctly", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "stat-probe.txt").replace(/\\/g, "/");
+ await backend.writeFile(path, "x");
+ const s = await backend.stat(path);
+ expect(s.isFile).toBe(true);
+ expect(s.isDirectory).toBe(false);
+ // A directory stat reports the inverse.
+ const dirStat = await backend.stat(tmpRemoteDir);
+ expect(dirStat.isDirectory).toBe(true);
+ expect(dirStat.isFile).toBe(false);
+ });
+
+ it("readdir lists entries with isDirectory flags", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ await backend.writeFile(join(tmpRemoteDir, "a.txt").replace(/\\/g, "/"), "a");
+ await mkdir(join(tmpRemoteDir, "subdir").replace(/\\/g, "/")).catch(() => undefined);
+ const entries = await backend.readdir(tmpRemoteDir);
+ const names = entries.map((e) => e.name);
+ expect(names).toContain("a.txt");
+ expect(entries.find((e) => e.name === "a.txt")?.isDirectory).toBe(false);
+ });
+
+ it("readFile on a missing path throws an ENOENT .code error", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ await expect(
+ backend.readFile(join(tmpRemoteDir, "nope.txt").replace(/\\/g, "/")),
+ ).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ });
+
+ it("exists returns false for a missing path and true for an existing one", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "exists-probe.txt").replace(/\\/g, "/");
+ await backend.writeFile(path, "x");
+ expect(await backend.exists(path)).toBe(true);
+ expect(await backend.exists(join(tmpRemoteDir, "missing.txt").replace(/\\/g, "/"))).toBe(false);
+ });
+});