summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/integration.test.ts
blob: 7b05be25bb96462bb92724c7d8bc4f3707a5fa6a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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);
	});
});