mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
75c155b748
* 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>
109 lines
3.6 KiB
TypeScript
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.");
|
|
});
|