Files
NetMesh/application/AppHandlers.connect.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

401 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
id: 'host-1',
label: '10.2.0.32',
hostname: '10.2.0.32',
username: 'root',
tags: [],
os: 'linux',
protocol: 'ssh',
};
test('connect host handler returns the created terminal tab id', () => {
const logs: unknown[] = [];
const connectedHosts: Host[] = [];
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: (entry: unknown) => logs.push(entry),
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'session-from-connect';
},
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
baseHost,
);
assert.equal(result, 'session-from-connect');
assert.equal(connectedHosts.length, 1);
assert.equal(logs.length, 1);
});
test('connect logs use the same Mosh-before-ET protocol precedence as the launcher', () => {
const logs: Array<{ protocol?: string }> = [];
handleConnectToHostImpl(
() => ({
addConnectionLog: (entry: { protocol?: string }) => logs.push(entry),
connectToHost: () => 'session-both-transports',
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
{ ...baseHost, moshEnabled: true, etEnabled: true },
);
assert.equal(logs[0]?.protocol, 'mosh');
});
test('connect serial host handler returns the created terminal tab id', () => {
const serialHost: Host = {
...baseHost,
id: 'serial-1',
label: '',
hostname: '/dev/tty.usbserial',
protocol: 'serial',
};
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: () => 'serial-session',
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
serialHost,
);
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
test('keyboard-interactive submit can save login password for the session host', async () => {
let hosts: Host[] = [{
...baseHost,
password: 'old-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-1',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
const bridgeResponses: unknown[] = [];
const hostUpdates: Host[][] = [];
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: (...args: unknown[]) => {
bridgeResponses.push(args);
return { success: true };
},
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hostUpdates.push(nextHosts);
hosts = nextHosts;
},
}),
'ki-1',
['login-password', 'otp-code'],
'new-login-password',
);
assert.equal(hostUpdates.length, 1);
assert.deepEqual(hosts[0], {
...baseHost,
password: 'new-login-password',
savePassword: true,
});
assert.deepEqual(queue, []);
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
password: 'login-password',
}];
let queue = [{
requestId: 'ki-external',
sessionId: 'sftp-connection-1',
hostId: baseHost.id,
scope: 'external',
hostname: baseHost.hostname,
allowSavePassword: false,
}];
let hostUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hostUpdates += 1;
hosts = nextHosts;
},
}),
'ki-external',
['secondary-password'],
'should-not-save',
);
assert.equal(hostUpdates, 0);
assert.equal(hosts[0].password, 'login-password');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit uses explicit hostId when saving password', async () => {
const jumpHost: Host = {
...baseHost,
id: 'jump-1',
label: 'Jump',
hostname: 'jump.example.com',
password: 'old-jump-password',
};
let hosts: Host[] = [{
...baseHost,
password: 'target-password',
}, jumpHost];
let queue = [{
requestId: 'ki-jump',
sessionId: 'terminal-session-1',
hostId: jumpHost.id,
scope: 'terminal',
hostname: jumpHost.hostname,
allowSavePassword: true,
}];
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'terminal-session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
}),
'ki-jump',
['jump-login-password'],
'new-jump-password',
);
assert.equal(hosts.find((host) => host.id === baseHost.id)?.password, 'target-password');
assert.equal(hosts.find((host) => host.id === jumpHost.id)?.password, 'new-jump-password');
});
test('keyboard-interactive submit preserves host changes made while delivery is pending', async () => {
let hosts: Host[] = [{
...baseHost,
label: 'Original label',
password: 'old-password',
}];
const hostsRef = { current: hosts };
let queue = [{
requestId: 'ki-delayed',
sessionId: 'session-delayed',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let resolveDelivery: (result: { success: boolean }) => void = () => {};
const delivery = new Promise<{ success: boolean }>((resolve) => {
resolveDelivery = resolve;
});
const submitPromise = handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
hostsRef,
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({ respondKeyboardInteractive: () => delivery }),
},
sessions: [{
id: 'session-delayed',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
hostsRef.current = nextHosts;
},
}),
'ki-delayed',
['new-password'],
'new-password',
);
hosts = [{ ...hosts[0], label: 'Synced label', tags: ['synced'] }];
hostsRef.current = hosts;
resolveDelivery({ success: true });
await submitPromise;
assert.deepEqual(hosts[0], {
...baseHost,
label: 'Synced label',
tags: ['synced'],
password: 'new-password',
savePassword: true,
});
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit keeps the prompt and password unchanged when delivery fails', async () => {
let hosts: Host[] = [{
...baseHost,
password: 'old-password',
}];
let queue = [{
requestId: 'ki-failed',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
const errors: string[] = [];
const submitted = await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: false, error: 'Request not found' }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: (message: string) => errors.push(message) },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
}),
'ki-failed',
['new-password'],
'new-password',
);
assert.equal(submitted, false);
assert.deepEqual(queue.map((request) => request.requestId), ['ki-failed']);
assert.equal(hosts[0].password, 'old-password');
assert.deepEqual(errors, ['Request not found']);
});