























1-// Covers SSH target parsing.
2-import { describe, expect, it } from "vitest";
3-import { parseSshTarget } from "./ssh-tunnel.js";
1+// Covers SSH target parsing and tunnel startup preflight behavior.
2+import { EventEmitter } from "node:events";
3+import net from "node:net";
4+import { afterEach, describe, expect, it, vi } from "vitest";
5+6+const mocks = vi.hoisted(() => ({
7+ensurePortAvailable: vi.fn<(port: number, host?: string) => Promise<void>>(),
8+spawn: vi.fn(),
9+}));
10+11+vi.mock("./ports.js", async (importOriginal) => ({
12+ ...(await importOriginal<typeof import("./ports.js")>()),
13+ensurePortAvailable: mocks.ensurePortAvailable,
14+}));
15+16+vi.mock("node:child_process", async (importOriginal) => ({
17+ ...(await importOriginal<typeof import("node:child_process")>()),
18+spawn: mocks.spawn,
19+}));
20+21+import { PortInUseError } from "./ports.js";
22+import { parseSshTarget, startSshPortForward } from "./ssh-tunnel.js";
423524describe("parseSshTarget", () => {
625it("parses user@host:port targets", () => {
@@ -30,3 +49,107 @@ describe("parseSshTarget", () => {
3049expect(parseSshTarget("-oProxyCommand=echo")).toBeNull();
3150});
3251});
52+53+describe("startSshPortForward", () => {
54+const openServers: net.Server[] = [];
55+56+afterEach(async () => {
57+while (openServers.length > 0) {
58+const server = openServers.pop();
59+await new Promise<void>((resolve) => {
60+server?.close(() => resolve());
61+});
62+}
63+mocks.ensurePortAvailable.mockReset();
64+mocks.spawn.mockReset();
65+});
66+67+// Fake ssh child that, when spawned, parses the -L forward spec and starts a
68+// real IPv4-loopback listener on the chosen local port so waitForLocalListener
69+// resolves without launching a real ssh process.
70+function spawnFakeSshListening() {
71+mocks.spawn.mockImplementation((_cmd: string, args: string[]) => {
72+const forwardSpec = args[args.indexOf("-L") + 1] ?? "";
73+const localPort = Number(forwardSpec.split(":")[1]);
74+const server = net.createServer();
75+server.on("error", () => {});
76+openServers.push(server);
77+server.listen(localPort, "127.0.0.1");
78+79+const child = new EventEmitter() as EventEmitter & {
80+killed: boolean;
81+pid: number;
82+stderr: EventEmitter & { setEncoding: (enc: string) => void };
83+kill: (signal?: string) => boolean;
84+};
85+child.killed = false;
86+child.pid = 4242;
87+const stderr = new EventEmitter() as EventEmitter & { setEncoding: (enc: string) => void };
88+stderr.setEncoding = () => {};
89+child.stderr = stderr;
90+child.kill = (signal?: string) => {
91+child.killed = true;
92+queueMicrotask(() => child.emit("exit", 0, signal ?? null));
93+return true;
94+};
95+return child;
96+});
97+}
98+99+it("scopes the preferred-port preflight to the IPv4 loopback interface", async () => {
100+const sentinel = new Error("stop before spawning ssh");
101+mocks.ensurePortAvailable.mockRejectedValueOnce(sentinel);
102+103+await expect(
104+startSshPortForward({
105+target: "me@example.com:2222",
106+localPortPreferred: 43210,
107+remotePort: 18789,
108+timeoutMs: 250,
109+}),
110+).rejects.toBe(sentinel);
111+112+expect(mocks.ensurePortAvailable).toHaveBeenCalledWith(43210, "127.0.0.1");
113+});
114+115+it("falls back to an ephemeral port when the preferred port is in use", async () => {
116+// ensurePortAvailable raises the domain PortInUseError (no errno `code`),
117+// which the catch must treat as "busy" and route to pickEphemeralPort.
118+// Reserve a real port so pickEphemeralPort (listen(0)) cannot hand the same
119+// number back and make the assertion flaky.
120+const occupied = net.createServer();
121+await new Promise<void>((resolve, reject) => {
122+occupied.once("error", reject);
123+occupied.listen(0, "127.0.0.1", () => {
124+occupied.off("error", reject);
125+resolve();
126+});
127+});
128+openServers.push(occupied);
129+const addr = occupied.address();
130+if (!addr || typeof addr === "string") {
131+throw new Error("failed to reserve preferred port");
132+}
133+const preferredPort = addr.port;
134+135+mocks.ensurePortAvailable.mockRejectedValueOnce(new PortInUseError(preferredPort));
136+spawnFakeSshListening();
137+138+const tunnel = await startSshPortForward({
139+target: "me@example.com:2222",
140+localPortPreferred: preferredPort,
141+remotePort: 18789,
142+timeoutMs: 1000,
143+});
144+145+expect(tunnel.localPort).not.toBe(preferredPort);
146+expect(tunnel.localPort).toBeGreaterThan(0);
147+expect(mocks.spawn).toHaveBeenCalledWith(
148+"/usr/bin/ssh",
149+expect.arrayContaining(["-L", `127.0.0.1:${tunnel.localPort}:127.0.0.1:18789`]),
150+expect.anything(),
151+);
152+153+await tunnel.stop();
154+});
155+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。