Files
Netcatty/application/state/useHostConnectionTest.test.ts
T
Yohann 75c155b748 feat(vault): add host connection test to the host editor (#2984)
* feat(vault): add host connection test to the host editor

Add a 'Test' button next to Save in the host editor that opens a dialog reusing the terminal's connection progress/auth/host-key UI, and runs a headless connection probe:

- Main process: netcatty:test-connection reuses startSSHSession in a testMode that authenticates then tears down without opening a shell, reports via netcatty:test:result, and supports cancellation (netcatty:test-connection:cancel).
- Renderer: useHostConnectionTest hook + HostConnectionTestDialog component; single-credential auth (no agent/default-key fallback) with short test timeouts (8s TCP / 15s auth).
- Reuses terminal chain progress, host-key verification, and auth prompts; success keeps the same dialog with retry/close.

* fix(ssh): cancel test connections during chain/proxy dial

Register the headless test-connection transport before connectThroughChain / createProxySocket runs, and update its pendingConn / chainConnections as hops and proxy sockets are established. This lets cancelTestConnection abort a slow/unreachable bastion or proxy dial instead of leaving it running until timeout.

* fix(ssh): address host connection test review feedback

- Route jump-host keyboard-interactive prompts through the external scope in test mode
- Always offer keyboard-interactive for requiresMfa hosts in test mode, including key/certificate-only auth
- Derive the renderer test deadline from the probe options and cancel the attempt on timeout
- Run the macOS Local Network preflight before connection tests
- Add regression tests for jump-host MFA, MFA auth order, test deadline, and macOS preflight

Author: yangheng
Model: claude-opus-5

---------

Co-authored-by: yangheng <492238647@qq.com>
2026-10-08 14:42:18 +08:00

109 lines
3.6 KiB
TypeScript

import assert from "node:assert/strict";
import test, { mock } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { Host } from "../../domain/models.ts";
import { useHostConnectionTest } from "./useHostConnectionTest.ts";
type HookApi = ReturnType<typeof useHostConnectionTest>;
const targetHost: Host = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
port: 22,
username: "alice",
password: "secret",
tags: [],
os: "linux",
protocol: "ssh",
// Shorter than the fixed 15s test auth timeout sent to the bridge.
sshAuthReadyTimeoutSeconds: 5,
};
test("connection test countdown follows the probe timeouts and invalidates the attempt on timeout", async (t) => {
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
let resolveProbe: (value: unknown) => void = () => {};
const cancelled: string[] = [];
const startedSessionIds: string[] = [];
const bridge = {
testConnection: (options: { sessionId: string }) => {
startedSessionIds.push(options.sessionId);
return new Promise((resolve) => {
resolveProbe = resolve;
});
},
cancelTestConnection: async (sessionId: string) => {
cancelled.push(sessionId);
return { success: true };
},
onChainProgress: () => () => {},
onHostKeyVerification: () => () => {},
respondHostKeyVerification: async () => ({ success: true }),
};
Object.defineProperty(globalThis, "window", {
configurable: true,
writable: true,
value: { netcatty: bridge },
});
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
let api: HookApi | null = null;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
api = useHostConnectionTest({ host: targetHost, hosts: [targetHost], keys: [] });
return null;
};
const current = () => {
assert.ok(api);
return api;
};
t.after(async () => {
await act(async () => {
renderer?.unmount();
});
mock.timers.reset();
if (previousWindowDescriptor) Object.defineProperty(globalThis, "window", previousWindowDescriptor);
else delete (globalThis as { window?: unknown }).window;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
void current().startTest();
});
assert.equal(current().state.status, "connecting");
// 8s TCP + 15s auth sent to the bridge, plus a 2s grace.
assert.equal(current().state.timeLeft, 25);
// The host's own 5s auth timeout must not end the test early.
await act(async () => {
mock.timers.tick(6000);
});
assert.equal(current().state.status, "connecting");
assert.deepEqual(cancelled, []);
await act(async () => {
mock.timers.tick(19_000);
});
assert.equal(current().state.status, "disconnected");
assert.equal(current().state.error, "Connection timed out. Please try again.");
assert.deepEqual(cancelled, startedSessionIds, "timeout must cancel the in-flight probe");
// A late success from the cancelled probe must not overwrite the timeout.
await act(async () => {
resolveProbe({ sessionId: startedSessionIds[0], testResult: "connected" });
await Promise.resolve();
});
assert.equal(current().state.status, "disconnected");
assert.equal(current().state.error, "Connection timed out. Please try again.");
});