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
401 lines
11 KiB
TypeScript
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']);
|
|
});
|