diff options
Diffstat (limited to 'packages/ssh/src/integration.test.ts')
| -rw-r--r-- | packages/ssh/src/integration.test.ts | 184 |
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); + }); +}); |
