Files
NetMesh/domain/vaultHostCreate.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

1907 lines
61 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import type { Host, Identity, ManagedSource, ProxyProfile } from './models.ts';
import { applyGroupDefaults } from './groupConfig.ts';
import {
applyVaultHostDelete,
applyVaultHostCreates,
applyVaultHostUpdate,
buildVaultHostEndpointKey,
buildVaultHostFromDraft,
buildVaultHostMergeKey,
buildVaultHostsFromDrafts,
parseVaultHostDraftsInput,
type VaultHostDraft,
type VaultHostUpdatePatch,
} from './vaultHostCreate.ts';
test('buildVaultHostMergeKey treats group as part of session identity', () => {
const base = {
hostname: '10.10.10.10',
port: 22,
username: 'root',
protocol: 'ssh' as const,
};
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan-proxy' }),
);
assert.equal(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan' }),
);
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'Prod' }),
buildVaultHostMergeKey({ ...base, group: 'prod' }),
);
assert.equal(
buildVaultHostEndpointKey({ ...base, group: 'lan' }),
buildVaultHostEndpointKey({ ...base, group: 'lan-proxy' }),
);
});
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
const built = buildVaultHostFromDraft({
hostname: '192.168.1.10',
username: 'ubuntu',
label: 'prod web',
group: 'infra/prod',
tags: 'web, nginx',
});
assert.equal(built.ok, true);
if (!built.ok) return;
assert.equal(built.host.hostname, '192.168.1.10');
assert.equal(built.host.username, 'ubuntu');
assert.equal(built.host.group, 'infra/prod');
assert.deepEqual(built.host.tags, ['web', 'nginx']);
});
test('buildVaultHostFromDraft accepts host aliases and a referenced key path', () => {
const built = buildVaultHostFromDraft({
name: 'prod api',
ip: '10.0.0.10',
username: 'deploy',
keyPath: '~/.ssh/id_ed25519',
});
assert.equal(built.ok, true);
if (!built.ok) return;
assert.equal(built.host.label, 'prod api');
assert.equal(built.host.hostname, '10.0.0.10');
assert.equal(built.host.authMethod, 'key');
assert.deepEqual(built.host.identityFilePaths, ['~/.ssh/id_ed25519']);
assert.equal(built.host.password, undefined);
});
test('buildVaultHostFromDraft accepts an explicit host operating system', () => {
const cases: Array<[string, Host['os']]> = [
['windows', 'windows'],
['win', 'windows'],
[' Windows ', 'windows'],
['macos', 'macos'],
['darwin', 'macos'],
['linux', 'linux'],
];
for (const [input, expected] of cases) {
const built = buildVaultHostFromDraft({ hostname: '10.0.0.20', os: input });
assert.equal(built.ok, true);
if (!built.ok) continue;
assert.equal(built.host.os, expected);
}
});
test('buildVaultHostFromDraft defaults the operating system to linux', () => {
const built = buildVaultHostFromDraft({ hostname: '10.0.0.20' });
assert.equal(built.ok, true);
if (!built.ok) return;
assert.equal(built.host.os, 'linux');
});
test('buildVaultHostFromDraft rejects an unknown operating system', () => {
const built = buildVaultHostFromDraft({ hostname: '10.0.0.20', os: 'solaris' });
assert.equal(built.ok, false);
if (built.ok) return;
assert.match(built.error, /os must be/i);
});
test('buildVaultHostFromDraft does not retain a password when saving is disabled', () => {
const built = buildVaultHostFromDraft({
hostname: '10.0.0.20',
username: 'deploy',
password: 'do-not-save',
savePassword: 'false',
});
assert.equal(built.ok, true);
if (!built.ok) return;
assert.equal(built.host.password, undefined);
assert.equal(built.host.savePassword, false);
});
test('buildVaultHostFromDraft rejects an invalid password-saving option', () => {
const built = buildVaultHostFromDraft({
hostname: '10.0.0.20',
savePassword: 'sometimes',
});
assert.equal(built.ok, false);
if (built.ok) return;
assert.match(built.error, /true or false/i);
});
test('buildVaultHostFromDraft rejects non-integer ports instead of changing them', () => {
for (const port of [22.9, '22.9', '22oops']) {
const built = buildVaultHostFromDraft({ hostname: '10.0.0.20', port });
assert.equal(built.ok, false);
if (built.ok) continue;
assert.match(built.error, /integer between 1 and 65535/i);
}
});
test('buildVaultHostFromDraft rejects an unsupported protocol instead of using SSH', () => {
const built = buildVaultHostFromDraft({ hostname: '10.0.0.20', protocol: 'ftp' });
assert.equal(built.ok, false);
if (built.ok) return;
assert.match(built.error, /ssh, telnet, or local/i);
});
test('buildVaultHostFromDraft rejects hostnames containing internal whitespace', () => {
for (const hostname of ['db prod.example.com', 'db\tprod.example.com']) {
const built = buildVaultHostFromDraft({ hostname });
assert.equal(built.ok, false);
if (built.ok) continue;
assert.match(built.error, /must not contain whitespace/i);
}
});
test('buildVaultHostFromDraft rejects invalid host objects and optional field types', () => {
const invalidDraft = buildVaultHostFromDraft(null as unknown as VaultHostDraft);
const invalidKeyPath = buildVaultHostFromDraft({ hostname: 'host.example.com', keyPath: 123 });
const invalidPassphrase = buildVaultHostFromDraft({
hostname: 'host.example.com',
keyPath: '~/.ssh/id_ed25519',
passphrase: 123,
});
const missingKeyPath = buildVaultHostFromDraft({
hostname: 'host.example.com',
passphrase: 'secret',
});
assert.equal(invalidDraft.ok, false);
assert.equal(invalidKeyPath.ok, false);
assert.equal(invalidPassphrase.ok, false);
assert.equal(missingKeyPath.ok, false);
if (!invalidDraft.ok) assert.match(invalidDraft.error, /must be an object/i);
if (!invalidKeyPath.ok) assert.match(invalidKeyPath.error, /keyPath must be a string/i);
if (!invalidPassphrase.ok) assert.match(invalidPassphrase.error, /passphrase must be a string/i);
if (!missingKeyPath.ok) assert.match(missingKeyPath.error, /keyPath is required/i);
});
test('buildVaultHostFromDraft rejects SSH config line injection', () => {
for (const draft of [
{ hostname: 'host.example.com', username: 'root\nProxyCommand /tmp/run' },
{ hostname: 'host.example.com', keyPath: '~/.ssh/id\rProxyCommand /tmp/run' },
{ hostname: 'host.example.com\0ProxyCommand /tmp/run' },
]) {
const built = buildVaultHostFromDraft(draft);
assert.equal(built.ok, false);
if (built.ok) continue;
assert.match(built.error, /line breaks or null bytes/i);
}
});
test('buildVaultHostFromDraft allows direct and Telnet usernames containing at signs', () => {
const direct = buildVaultHostFromDraft({
hostname: 'host.example.com',
username: 'alice@example.com',
});
const telnet = buildVaultHostFromDraft({
hostname: 'telnet.example.com',
username: 'alice@example.com',
protocol: 'telnet',
});
assert.equal(direct.ok, true);
assert.equal(telnet.ok, true);
});
test('parseVaultHostDraftsInput accepts JSON array strings', () => {
const parsed = parseVaultHostDraftsInput(JSON.stringify([
{ hostname: '10.0.0.1', username: 'root' },
{ hostname: '10.0.0.2', username: 'deploy', port: 2222 },
]));
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
assert.equal(parsed.drafts.length, 2);
});
test('applyVaultHostCreates writes sanitized hosts into the vault list', () => {
const existing: Host = {
id: 'host-1',
label: 'db',
hostname: '10.0.0.99',
username: 'root',
port: 22,
tags: [],
os: 'linux',
};
const { hosts: built } = buildVaultHostsFromDrafts([
{ hostname: '10.0.0.10', username: 'deploy', group: 'prod' },
]);
const merged = applyVaultHostCreates([existing], ['legacy'], built);
assert.equal(merged.addedCount, 1);
assert.equal(merged.hosts.length, 2);
assert.ok(merged.customGroups.includes('prod'));
});
test('applyVaultHostUpdate changes only provided fields and adds a new group', () => {
const existing: Host = {
id: 'host-1',
label: 'old label',
hostname: '10.0.0.1',
username: 'root',
port: 22,
tags: ['keep'],
os: 'linux',
notes: 'old notes',
};
const result = applyVaultHostUpdate([existing], ['legacy'], 'host-1', {
name: 'new label',
host: 'server.example.com',
port: 2222,
group: 'prod/api',
notes: '',
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.label, 'new label');
assert.equal(result.updatedHost.hostname, 'server.example.com');
assert.equal(result.updatedHost.port, 2222);
assert.equal(result.updatedHost.username, 'root');
assert.deepEqual(result.updatedHost.tags, ['keep']);
assert.equal(result.updatedHost.notes, undefined);
assert.ok(result.customGroups.includes('prod/api'));
});
test('applyVaultHostUpdate rejects non-integer ports instead of changing them', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
port: 22,
tags: [],
os: 'linux',
};
for (const port of [22.9, '22.9', '22oops']) {
const result = applyVaultHostUpdate([existing], [], existing.id, { port });
assert.equal(result.ok, false);
if (result.ok) continue;
assert.match(result.error, /integer between 1 and 65535/i);
}
});
test('applyVaultHostUpdate rejects hostnames containing internal whitespace', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'old.example.com',
username: 'root',
port: 22,
tags: [],
os: 'linux',
};
for (const hostname of ['db prod.example.com', 'db\tprod.example.com']) {
const result = applyVaultHostUpdate([existing], [], existing.id, { hostname });
assert.equal(result.ok, false);
if (result.ok) continue;
assert.match(result.error, /must not contain whitespace/i);
}
});
test('applyVaultHostUpdate changes default ports when switching protocols', () => {
const ssh: Host = {
id: 'ssh',
label: 'ssh',
hostname: 'ssh.example.com',
username: 'root',
port: 22,
protocol: 'ssh',
tags: [],
os: 'linux',
};
const telnet: Host = {
...ssh,
id: 'telnet',
label: 'telnet',
port: 23,
protocol: 'telnet',
};
const custom: Host = { ...ssh, id: 'custom', label: 'custom', port: 2222 };
const serial: Host = {
...ssh,
id: 'serial',
label: 'serial',
hostname: '/dev/ttyUSB0',
port: 115200,
protocol: 'serial',
serialConfig: { path: '/dev/ttyUSB0', baudRate: 115200 },
};
const toTelnet = applyVaultHostUpdate([ssh], [], ssh.id, { protocol: 'telnet' });
const toSsh = applyVaultHostUpdate([telnet], [], telnet.id, { protocol: 'ssh' });
const keepCustom = applyVaultHostUpdate([custom], [], custom.id, { protocol: 'telnet' });
const explicitPort = applyVaultHostUpdate([ssh], [], ssh.id, { protocol: 'telnet', port: 2323 });
const missingNetworkHostname = applyVaultHostUpdate([serial], [], serial.id, { protocol: 'ssh' });
const serialToSsh = applyVaultHostUpdate(
[serial], [], serial.id, { protocol: 'ssh', hostname: 'ssh.example.com' },
);
const serialToTelnet = applyVaultHostUpdate(
[serial], [], serial.id, { protocol: 'telnet', hostname: 'telnet.example.com' },
);
assert.equal(toTelnet.ok, true);
assert.equal(toSsh.ok, true);
assert.equal(keepCustom.ok, true);
assert.equal(explicitPort.ok, true);
assert.equal(missingNetworkHostname.ok, false);
assert.equal(serialToSsh.ok, true);
assert.equal(serialToTelnet.ok, true);
if (!toTelnet.ok || !toSsh.ok || !keepCustom.ok || !explicitPort.ok || !serialToSsh.ok || !serialToTelnet.ok) return;
assert.equal(toTelnet.updatedHost.port, 23);
assert.equal(toSsh.updatedHost.port, 22);
assert.equal(keepCustom.updatedHost.port, 2222);
assert.equal(explicitPort.updatedHost.port, 2323);
assert.equal(serialToSsh.updatedHost.port, 22);
assert.equal(serialToTelnet.updatedHost.port, 23);
assert.equal(serialToSsh.updatedHost.hostname, 'ssh.example.com');
assert.equal(serialToTelnet.updatedHost.hostname, 'telnet.example.com');
const backToSerial = applyVaultHostUpdate(
[serialToSsh.updatedHost], [], serial.id, { protocol: 'serial' },
);
assert.equal(backToSerial.ok, true);
if (backToSerial.ok) assert.equal(backToSerial.updatedHost.port, 115200);
});
test('applyVaultHostUpdate requires serial settings when switching to serial', () => {
const ssh: Host = {
id: 'ssh', label: 'ssh', hostname: 'ssh.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const missingConfig = applyVaultHostUpdate([ssh], [], ssh.id, { protocol: 'serial' });
assert.equal(missingConfig.ok, false);
if (!missingConfig.ok) assert.match(missingConfig.error, /serialConfig is required/i);
const configured = applyVaultHostUpdate([ssh], [], ssh.id, {
protocol: 'serial',
serialConfig: { path: '/dev/ttyUSB0', baudRate: 115200 },
});
assert.equal(configured.ok, true);
if (configured.ok) {
assert.equal(configured.updatedHost.protocol, 'serial');
assert.equal(configured.updatedHost.port, 115200);
assert.equal(configured.updatedHost.serialConfig?.baudRate, 115200);
}
});
test('applyVaultHostUpdate only accepts SSH-capable jump hosts', () => {
const target: Host = {
id: 'target', label: 'target', hostname: 'target.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const localJump: Host = {
id: 'local', label: 'local', hostname: 'localhost', username: '',
port: 22, protocol: 'local', tags: [], os: 'linux',
};
const inheritedJump: Host = {
id: 'inherited', label: 'inherited', hostname: 'inherited.example.com', username: 'root',
port: 22, tags: [], os: 'linux',
};
const localResult = applyVaultHostUpdate(
[target, localJump], [], target.id, { jumpHostIds: [localJump.id] },
);
const inheritedResult = applyVaultHostUpdate(
[target, inheritedJump], [], target.id, { jumpHostIds: [inheritedJump.id] },
{ resolveEffectiveHost: (host) => host.id === inheritedJump.id ? { ...host, protocol: 'telnet' } : host },
);
assert.equal(localResult.ok, false);
if (!localResult.ok) assert.match(localResult.error, /does not support SSH jump/i);
assert.equal(inheritedResult.ok, false);
if (!inheritedResult.ok) assert.match(inheritedResult.error, /does not support SSH jump/i);
});
test('applyVaultHostUpdate keeps referenced jump hosts SSH-capable', () => {
const jump: Host = {
id: 'jump', label: 'jump', hostname: 'jump.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const target: Host = {
id: 'target', label: 'target', hostname: 'target.example.com', username: 'root',
port: 22, protocol: 'ssh', hostChain: { hostIds: [jump.id] }, tags: [], os: 'linux',
};
const directResult = applyVaultHostUpdate(
[jump, target], [], jump.id, { protocol: 'telnet' },
);
const inheritedResult = applyVaultHostUpdate(
[jump, target], [], jump.id, { group: 'telnet-hosts' },
{ resolveEffectiveHost: (host) => host.group === 'telnet-hosts' ? { ...host, protocol: 'telnet' } : host },
);
const groupInheritedResult = applyVaultHostUpdate(
[jump, { ...target, hostChain: undefined, group: 'prod' }],
[],
jump.id,
{ protocol: 'telnet' },
{
resolveEffectiveHost: (host) => host.group === 'prod'
? { ...host, hostChain: { hostIds: [jump.id] } }
: host,
},
);
assert.equal(directResult.ok, false);
if (!directResult.ok) assert.match(directResult.error, /used as a jump host must keep an SSH/i);
assert.equal(inheritedResult.ok, false);
if (!inheritedResult.ok) assert.match(inheritedResult.error, /used as a jump host must keep an SSH/i);
assert.equal(groupInheritedResult.ok, false);
if (!groupInheritedResult.ok) assert.match(groupInheritedResult.error, /used as a jump host must keep an SSH/i);
});
test('applyVaultHostUpdate allows reciprocal independent jump settings', () => {
const first: Host = {
id: 'first', label: 'first', hostname: 'first.example.com', username: 'root',
port: 22, protocol: 'ssh', hostChain: { hostIds: ['second'] }, tags: [], os: 'linux',
};
const second: Host = {
id: 'second', label: 'second', hostname: 'second.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const result = applyVaultHostUpdate(
[first, second], [], second.id, { jumpHostIds: [first.id] },
);
assert.equal(result.ok, true);
});
test('applyVaultHostUpdate validates jump hosts inherited from the destination group', () => {
const jump: Host = {
id: 'jump', label: 'jump', hostname: 'jump.example.com', username: 'root',
port: 23, protocol: 'telnet', tags: [], os: 'linux',
};
const target: Host = {
id: 'target', label: 'target', hostname: 'target.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const unsupported = applyVaultHostUpdate(
[jump, target],
[],
target.id,
{ group: 'unsupported-jump' },
{
resolveEffectiveHost: (host) => host.group === 'unsupported-jump'
? { ...host, hostChain: { hostIds: [jump.id] } }
: host,
},
);
const selfReference = applyVaultHostUpdate(
[jump, target],
[],
target.id,
{ group: 'self-jump' },
{
resolveEffectiveHost: (host) => host.group === 'self-jump'
? { ...host, hostChain: { hostIds: [target.id] } }
: host,
},
);
assert.equal(unsupported.ok, false);
if (!unsupported.ok) assert.match(unsupported.error, /does not support SSH jump/i);
assert.equal(selfReference.ok, false);
if (!selfReference.ok) assert.match(selfReference.error, /cannot use itself/i);
});
test('applyVaultHostUpdate keeps jump hosts referenced only by empty group defaults SSH-capable', () => {
const jump: Host = {
id: 'jump', label: 'jump', hostname: 'jump.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const result = applyVaultHostUpdate(
[jump],
[],
jump.id,
{ protocol: 'telnet' },
{ groupConfigs: [{ path: 'empty-group', hostChain: { hostIds: [jump.id] } }] },
);
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /group jump host must keep an SSH/i);
});
test('applyVaultHostUpdate keeps legacy serial hosts editable without serialConfig', () => {
const legacySerial: Host = {
id: 'serial', label: 'Old serial', hostname: '/dev/ttyUSB0', username: '',
port: 115200, protocol: 'serial', tags: [], os: 'linux',
};
const result = applyVaultHostUpdate([legacySerial], [], legacySerial.id, { label: 'Renamed serial' });
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.updatedHost.label, 'Renamed serial');
});
test('applyVaultHostUpdate applies and clears advanced connection settings', () => {
const host: Host = {
id: 'host-1', label: 'host', hostname: 'host.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const jump: Host = {
id: 'jump-1', label: 'jump', hostname: 'jump.example.com', username: 'root', tags: [], os: 'linux',
};
const identity: Identity = {
id: 'identity-1', label: 'deploy', username: 'deploy', authMethod: 'key', keyId: 'key-1', created: 1,
};
const proxyProfile: ProxyProfile = {
id: 'proxy-1', label: 'proxy', config: { type: 'socks5', host: '127.0.0.1', port: 1080 }, createdAt: 1,
};
const patch: VaultHostUpdatePatch = {
protocol: 'serial',
identityId: identity.id,
jumpHostIds: [jump.id],
proxyProfileId: proxyProfile.id,
startupCommand: 'tmux attach || tmux',
startupCommandRunMode: 'lineDelay',
environmentVariables: [{ name: 'APP_ENV', value: 'production' }],
moshEnabled: false,
moshServerPath: '/usr/local/bin/mosh-server',
etEnabled: false,
etPort: 2022,
serialConfig: {
path: '/dev/ttyUSB0', baudRate: 115200, dataBits: 8, stopBits: 1,
parity: 'none', flowControl: 'rts/cts', localEcho: true, lineMode: false,
},
};
const result = applyVaultHostUpdate(
[host, jump], [], host.id, patch,
{ identities: [identity], proxyProfiles: [proxyProfile] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.protocol, 'serial');
assert.equal(result.updatedHost.hostname, '/dev/ttyUSB0');
assert.equal(result.updatedHost.identityId, identity.id);
assert.equal(result.updatedHost.username, 'deploy');
assert.deepEqual(result.updatedHost.hostChain, { hostIds: [jump.id] });
assert.equal(result.updatedHost.proxyProfileId, proxyProfile.id);
assert.equal(result.updatedHost.startupCommand, 'tmux attach || tmux');
assert.equal(result.updatedHost.startupCommandRunMode, 'lineDelay');
assert.deepEqual(result.updatedHost.environmentVariables, [{ name: 'APP_ENV', value: 'production' }]);
assert.equal(result.updatedHost.moshEnabled, false);
assert.equal(result.updatedHost.moshServerPath, '/usr/local/bin/mosh-server');
assert.equal(result.updatedHost.etEnabled, false);
assert.equal(result.updatedHost.etPort, 2022);
assert.deepEqual(result.updatedHost.serialConfig, {
path: '/dev/ttyUSB0', baudRate: 115200, dataBits: 8, stopBits: 1,
parity: 'none', flowControl: 'rts/cts', localEcho: true, lineMode: false,
});
const cleared = applyVaultHostUpdate([result.updatedHost, jump], [], host.id, {
jumpHostIds: [],
proxyProfileId: '',
startupCommand: '',
startupCommandRunMode: '',
environmentVariables: {},
moshEnabled: false,
moshServerPath: '',
etEnabled: false,
}, { identities: [identity], proxyProfiles: [proxyProfile] });
assert.equal(cleared.ok, true);
if (!cleared.ok) return;
assert.deepEqual(cleared.updatedHost.hostChain, { hostIds: [] });
assert.equal(cleared.updatedHost.proxyProfileId, '');
assert.equal(cleared.updatedHost.startupCommand, '');
assert.equal(cleared.updatedHost.startupCommandRunMode, undefined);
assert.deepEqual(cleared.updatedHost.environmentVariables, []);
assert.equal(cleared.updatedHost.moshEnabled, false);
assert.equal(cleared.updatedHost.moshServerPath, undefined);
assert.equal(cleared.updatedHost.etEnabled, false);
});
test('applyVaultHostUpdate rejects malformed advanced connection settings', () => {
const host: Host = {
id: 'host-1', label: 'host', hostname: 'host.example.com', username: 'root', tags: [], os: 'linux',
};
const jump: Host = {
id: 'jump-1', label: 'jump', hostname: 'jump.example.com', username: 'root', tags: [], os: 'linux',
};
const cases: Array<{ patch: VaultHostUpdatePatch; error: RegExp }> = [
{ patch: { protocol: 'unknown' }, error: /protocol/i },
{ patch: { identityId: 42 }, error: /identityId must be a string/i },
{ patch: { identityId: 'missing' }, error: /Identity .* was not found/i },
{ patch: { jumpHostIds: '["jump-1"' }, error: /valid JSON array/i },
{ patch: { jumpHostIds: [host.id] }, error: /cannot use itself/i },
{ patch: { jumpHostIds: [jump.id, jump.id] }, error: /duplicates/i },
{ patch: { jumpHostIds: ['missing'] }, error: /Jump host .* was not found/i },
{ patch: { proxyProfileId: 'missing' }, error: /Proxy profile .* was not found/i },
{ patch: { startupCommand: 42 }, error: /startupCommand must be a string/i },
{ patch: { startupCommandRunMode: 'fast' }, error: /paste or lineDelay/i },
{ patch: { environmentVariables: '{' }, error: /valid JSON/i },
{ patch: { environmentVariables: [{ value: 'missing-name' }] }, error: /require name and value/i },
{ patch: { moshEnabled: 'maybe' }, error: /moshEnabled must be true or false/i },
{ patch: { etEnabled: 'maybe' }, error: /etEnabled must be true or false/i },
{ patch: { moshEnabled: true, etEnabled: true }, error: /cannot both be enabled/i },
{ patch: { protocol: 'serial', moshEnabled: true, serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600 } }, error: /require the SSH protocol/i },
{ patch: { etPort: 0 }, error: /between 1 and 65535/i },
{ patch: { serialConfig: '{' }, error: /valid JSON/i },
{ patch: { serialConfig: {} }, error: /requires path and a positive baudRate/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0\nspoof', baudRate: 9600 } }, error: /line breaks or null bytes/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, dataBits: 9 } }, error: /dataBits/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, stopBits: 3 } }, error: /stopBits/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, parity: 'bad' } }, error: /parity/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, flowControl: 'bad' } }, error: /flowControl/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, localEcho: 'maybe' } }, error: /localEcho/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, lineMode: 'maybe' } }, error: /lineMode/i },
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, backspaceBehavior: 'bad' } }, error: /backspaceBehavior/i },
];
for (const entry of cases) {
const result = applyVaultHostUpdate([host, jump], [], host.id, entry.patch, {
identities: [], proxyProfiles: [],
});
assert.equal(result.ok, false, JSON.stringify(entry.patch));
if (!result.ok) assert.match(result.error, entry.error);
}
});
test('applyVaultHostUpdate keeps Mosh and ET mutually exclusive and clears them for other protocols', () => {
const host: Host = {
id: 'host-1', label: 'host', hostname: 'host.example.com', username: 'root',
protocol: 'ssh', moshEnabled: true, tags: [], os: 'linux',
};
const switchedToEt = applyVaultHostUpdate([host], [], host.id, { etEnabled: true });
assert.equal(switchedToEt.ok, true);
if (!switchedToEt.ok) return;
assert.equal(switchedToEt.updatedHost.moshEnabled, false);
assert.equal(switchedToEt.updatedHost.etEnabled, true);
const switchedToTelnet = applyVaultHostUpdate(
[switchedToEt.updatedHost], [], host.id, { protocol: 'telnet' },
);
assert.equal(switchedToTelnet.ok, true);
if (!switchedToTelnet.ok) return;
assert.equal(switchedToTelnet.updatedHost.moshEnabled, false);
assert.equal(switchedToTelnet.updatedHost.etEnabled, false);
});
test('applyVaultHostUpdate preserves repeated SSH mode and synchronizes serial paths both ways', () => {
const moshHost: Host = {
id: 'mosh-1', label: 'mosh', hostname: 'host.example.com', username: 'root',
protocol: 'ssh', moshEnabled: true, tags: [], os: 'linux',
};
const repeatedSsh = applyVaultHostUpdate([moshHost], [], moshHost.id, {
protocol: 'ssh', label: 'renamed',
});
assert.equal(repeatedSsh.ok, true);
if (!repeatedSsh.ok) return;
assert.equal(repeatedSsh.updatedHost.moshEnabled, true);
const serialHost: Host = {
id: 'serial-1', label: 'serial', hostname: '/dev/ttyUSB0', username: '',
protocol: 'serial', port: 9600, serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600 },
tags: [], os: 'linux',
};
const updatedPath = applyVaultHostUpdate([serialHost], [], serialHost.id, {
hostname: '/dev/ttyUSB1',
});
assert.equal(updatedPath.ok, true);
if (!updatedPath.ok) return;
assert.equal(updatedPath.updatedHost.hostname, '/dev/ttyUSB1');
assert.equal(updatedPath.updatedHost.serialConfig?.path, '/dev/ttyUSB1');
const conflictingPaths = applyVaultHostUpdate([serialHost], [], serialHost.id, {
hostname: '/dev/ttyUSB1',
serialConfig: { path: '/dev/ttyUSB2', baudRate: 9600 },
});
assert.equal(conflictingPaths.ok, false);
if (!conflictingPaths.ok) assert.match(conflictingPaths.error, /must match/i);
const retainedSerialSettings: Host = {
...serialHost,
protocol: 'ssh',
hostname: 'server.example.com',
};
const switchedBackToSerial = applyVaultHostUpdate(
[retainedSerialSettings], [], retainedSerialSettings.id, { protocol: 'serial' },
);
assert.equal(switchedBackToSerial.ok, true);
if (!switchedBackToSerial.ok) return;
assert.equal(switchedBackToSerial.updatedHost.hostname, '/dev/ttyUSB0');
const whitespacePath = applyVaultHostUpdate([serialHost], [], serialHost.id, {
serialConfig: { path: '/tmp/serial link', baudRate: 9600 },
});
assert.equal(whitespacePath.ok, true);
if (!whitespacePath.ok) return;
assert.equal(whitespacePath.updatedHost.hostname, '/tmp/serial link');
assert.equal(whitespacePath.updatedHost.serialConfig?.path, '/tmp/serial link');
const hostnameOnlyWhitespacePath = applyVaultHostUpdate([serialHost], [], serialHost.id, {
hostname: '/tmp/serial link',
});
assert.equal(hostnameOnlyWhitespacePath.ok, true);
if (!hostnameOnlyWhitespacePath.ok) return;
assert.equal(hostnameOnlyWhitespacePath.updatedHost.hostname, '/tmp/serial link');
assert.equal(hostnameOnlyWhitespacePath.updatedHost.serialConfig?.path, '/tmp/serial link');
const matchingWhitespacePaths = applyVaultHostUpdate([serialHost], [], serialHost.id, {
hostname: '/tmp/serial link',
serialConfig: { path: '/tmp/serial link', baudRate: 9600 },
});
assert.equal(matchingWhitespacePaths.ok, true);
if (!matchingWhitespacePaths.ok) return;
assert.equal(matchingWhitespacePaths.updatedHost.hostname, '/tmp/serial link');
});
test('applyVaultHostUpdate validates Mosh and ET against inherited connection settings', () => {
const host: Host = {
id: 'host-1', label: 'host', hostname: 'host.example.com', username: 'root',
group: 'inherited', tags: [], os: 'linux',
};
const inheritedTelnet = applyVaultHostUpdate([host], [], host.id, { moshEnabled: true }, {
resolveEffectiveHost: (candidate) => ({ ...candidate, protocol: 'telnet' }),
});
assert.equal(inheritedTelnet.ok, false);
if (!inheritedTelnet.ok) assert.match(inheritedTelnet.error, /require the SSH protocol/i);
const inheritedEt = applyVaultHostUpdate([host], [], host.id, { moshEnabled: true }, {
resolveEffectiveHost: (candidate) => ({ ...candidate, etEnabled: true }),
});
assert.equal(inheritedEt.ok, false);
if (!inheritedEt.ok) assert.match(inheritedEt.error, /cannot both be enabled/i);
});
test('applyVaultHostUpdate preserves serial Backspace override semantics', () => {
for (const backspaceBehavior of ['ctrl-h', 'default', undefined] as const) {
const host: Host = {
id: `serial-${backspaceBehavior ?? 'inherited'}`,
label: 'serial',
hostname: 'serial',
username: '',
protocol: 'serial',
tags: [],
os: 'linux',
serialConfig: {
path: '/dev/ttyUSB0',
baudRate: 9600,
...(backspaceBehavior !== undefined ? { backspaceBehavior } : {}),
},
};
const updated = applyVaultHostUpdate([host], [], host.id, {
serialConfig: { path: '/dev/ttyUSB0', baudRate: 115200 },
});
assert.equal(updated.ok, true);
if (!updated.ok) continue;
assert.equal(updated.updatedHost.serialConfig?.backspaceBehavior, backspaceBehavior);
}
});
test('applyVaultHostUpdate can switch a host to a referenced key path', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
port: 22,
tags: [],
os: 'linux',
password: 'secret',
authMethod: 'password',
};
const result = applyVaultHostUpdate([existing], [], 'host-1', {
keyPath: '/Users/alice/.ssh/id_ed25519',
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.authMethod, 'key');
assert.deepEqual(result.updatedHost.identityFilePaths, ['/Users/alice/.ssh/id_ed25519']);
assert.equal(result.updatedHost.identityId, '');
});
test('applyVaultHostUpdate parses JSON array tag strings', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
};
const result = applyVaultHostUpdate([existing], [], 'host-1', {
tags: '["prod", "api", "prod"]',
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.deepEqual(result.updatedHost.tags, ['prod', 'api']);
});
test('applyVaultHostUpdate sets and normalizes the host operating system', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
};
const windowsResult = applyVaultHostUpdate([existing], [], 'host-1', { os: 'windows' });
assert.equal(windowsResult.ok, true);
if (!windowsResult.ok) return;
assert.equal(windowsResult.updatedHost.os, 'windows');
const aliasResult = applyVaultHostUpdate([existing], [], 'host-1', { os: 'mac' });
assert.equal(aliasResult.ok, true);
if (!aliasResult.ok) return;
assert.equal(aliasResult.updatedHost.os, 'macos');
const untouchedResult = applyVaultHostUpdate([existing], [], 'host-1', { label: 'renamed' });
assert.equal(untouchedResult.ok, true);
if (!untouchedResult.ok) return;
assert.equal(untouchedResult.updatedHost.os, 'linux');
});
test('applyVaultHostUpdate rejects an unknown operating system', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
};
const result = applyVaultHostUpdate([existing], [], 'host-1', { os: 'solaris' });
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /os must be/i);
});
test('applyVaultHostUpdate rejects malformed JSON tag strings', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: ['keep'],
os: 'linux',
};
const result = applyVaultHostUpdate([existing], [], 'host-1', {
tags: '["prod"',
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /valid JSON array/i);
});
test('applyVaultHostUpdate rejects SSH config line injection', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'host.example.com',
username: 'root',
tags: [],
os: 'linux',
};
for (const patch of [
{ username: 'root\nProxyCommand /tmp/run' },
{ keyPath: '~/.ssh/id\rProxyCommand /tmp/run' },
{ hostname: 'host.example.com\0ProxyCommand /tmp/run' },
{ label: 'host\nProxyCommand /tmp/run' },
]) {
const result = applyVaultHostUpdate([existing], [], existing.id, patch);
assert.equal(result.ok, false);
if (result.ok) continue;
assert.match(result.error, /line breaks or null bytes/i);
}
});
test('applyVaultHostUpdate limits SSH syntax checks to managed aliases and active jump hosts', () => {
const managedSource: ManagedSource = {
id: 'source-1',
type: 'ssh_config',
filePath: '~/.ssh/config',
groupName: 'managed',
lastSyncedAt: 1,
};
const jump: Host = {
id: 'jump',
label: 'jump',
hostname: 'jump.example.com',
username: 'root',
tags: [],
os: 'linux',
};
const managedTarget: Host = {
id: 'target',
label: 'target',
hostname: 'target.example.com',
username: 'root',
group: 'managed',
managedSourceId: managedSource.id,
hostChain: { hostIds: [jump.id] },
tags: [],
os: 'linux',
};
const options = { managedSources: [managedSource] };
const directUsername = applyVaultHostUpdate(
[jump],
[],
jump.id,
{ username: 'alice@example.com' },
);
const encodedAlias = applyVaultHostUpdate(
[managedTarget],
[],
managedTarget.id,
{ label: '*' },
options,
);
const badJumpHostname = applyVaultHostUpdate(
[jump, managedTarget],
[],
jump.id,
{ hostname: 'first.example.com,second.example.com' },
options,
);
const badJumpUsername = applyVaultHostUpdate(
[jump, managedTarget],
[],
jump.id,
{ username: 'user,attacker' },
options,
);
const emailJumpUsername = applyVaultHostUpdate(
[jump, managedTarget],
[],
jump.id,
{ username: 'alice@example.com' },
options,
);
const optionLikeJumpHostname = applyVaultHostUpdate(
[jump, managedTarget],
[],
jump.id,
{ hostname: '-oProxyCommand=run' },
options,
);
const unsafeJump: Host = {
...jump,
id: 'unsafe-jump',
hostname: 'first.example.com,second.example.com',
};
const plainTarget: Host = {
...managedTarget,
id: 'plain-target',
group: undefined,
managedSourceId: undefined,
hostChain: { hostIds: [unsafeJump.id] },
};
const movedInWithUnsafeJump = applyVaultHostUpdate(
[unsafeJump, plainTarget],
[],
plainTarget.id,
{ group: 'managed' },
options,
);
assert.equal(directUsername.ok, true);
assert.equal(encodedAlias.ok, true);
assert.equal(badJumpHostname.ok, false);
assert.equal(badJumpUsername.ok, false);
assert.equal(emailJumpUsername.ok, true);
assert.equal(optionLikeJumpHostname.ok, false);
assert.equal(movedInWithUnsafeJump.ok, false);
if (!directUsername.ok) return;
assert.equal(directUsername.updatedHost.username, 'alice@example.com');
if (!encodedAlias.ok) return;
assert.equal(encodedAlias.updatedHost.label, '*');
});
test('applyVaultHostUpdate clears only the local key path when another identity is selected', () => {
const identityHost: Host = {
id: 'identity-host',
label: 'identity host',
hostname: 'identity.example.com',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
identityFilePaths: ['~/.ssh/old'],
authMethod: 'key',
};
const keychainHost: Host = {
id: 'keychain-host',
label: 'keychain host',
hostname: 'keychain.example.com',
username: 'root',
tags: [],
os: 'linux',
identityFileId: 'key-1',
identityFilePaths: ['~/.ssh/old'],
authMethod: 'key',
};
const identityResult = applyVaultHostUpdate([identityHost], [], identityHost.id, { keyPath: '' });
const keychainResult = applyVaultHostUpdate([keychainHost], [], keychainHost.id, { keyPath: '' });
assert.equal(identityResult.ok, true);
assert.equal(keychainResult.ok, true);
if (!identityResult.ok || !keychainResult.ok) return;
assert.equal(identityResult.updatedHost.identityId, 'identity-1');
assert.equal(identityResult.updatedHost.authMethod, 'key');
assert.deepEqual(identityResult.updatedHost.identityFilePaths, []);
assert.equal(keychainResult.updatedHost.identityFileId, 'key-1');
assert.equal(keychainResult.updatedHost.authMethod, 'key');
assert.deepEqual(keychainResult.updatedHost.identityFilePaths, []);
});
test('applyVaultHostUpdate resets identity-derived key auth when detaching an identity', () => {
const host: Host = {
id: 'host', label: 'host', hostname: 'host.example.com', username: 'deploy',
identityId: 'identity-1', authMethod: 'key', authPolicyVersion: 1,
useSshAgent: false, tags: [], os: 'linux',
};
const result = applyVaultHostUpdate([host], [], host.id, { identityId: '' });
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.authMethod, 'auto');
assert.equal(result.updatedHost.useSshAgent, undefined);
});
test('applyVaultHostUpdate rejects saved password changes when password saving is disabled', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
savePassword: false,
};
const result = applyVaultHostUpdate([existing], [], 'host-1', {
password: 'do-not-persist',
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /not to save passwords/i);
});
test('applyVaultHostUpdate respects inherited password saving opt-out', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
};
const result = applyVaultHostUpdate(
[existing],
[],
'host-1',
{ password: 'do-not-persist' },
{ resolveEffectiveHost: (host) => ({ ...host, savePassword: false }) },
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /not to save passwords/i);
});
test('applyVaultHostUpdate switches to password auth when clearing a key path', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
identityFileId: 'key-1',
identityFilePaths: ['~/.ssh/old'],
authMethod: 'key',
};
const result = applyVaultHostUpdate([existing], [], 'host-1', {
password: 'new-secret',
keyPath: '',
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.authMethod, 'password');
assert.equal(result.updatedHost.password, 'new-secret');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.identityFileId, undefined);
assert.deepEqual(result.updatedHost.identityFilePaths, []);
});
test('applyVaultHostUpdate keeps an inherited identity username when switching to password auth', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: '',
group: 'prod',
tags: [],
os: 'linux',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared key',
username: 'deploy',
authMethod: 'key',
keyId: 'key-1',
created: 1,
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
identityId: identity.id,
username: identity.username,
authMethod: identity.authMethod,
identityFileId: identity.keyId,
});
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: 'new-secret', savePassword: true, keyPath: '' },
{ identities: [identity], resolveEffectiveHost },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.username, 'deploy');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate preserves key login when only the password changes', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
identityFileId: 'key-1',
identityFilePaths: ['~/.ssh/key'],
authMethod: 'key',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared key',
username: 'root',
authMethod: 'key',
keyId: 'key-1',
created: 1,
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: 'new-secret' },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.password, 'new-secret');
assert.equal(result.updatedHost.authMethod, 'key');
assert.equal(result.updatedHost.identityId, 'identity-1');
assert.equal(result.updatedHost.identityFileId, 'key-1');
assert.deepEqual(result.updatedHost.identityFilePaths, ['~/.ssh/key']);
});
test('applyVaultHostUpdate preserves an inherited key identity when the password changes', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: '',
group: 'prod',
tags: [],
os: 'linux',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared key',
username: 'deploy',
authMethod: 'key',
keyId: 'key-1',
created: 1,
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
identityId: identity.id,
username: identity.username,
authMethod: 'key',
identityFileId: identity.keyId,
});
const updated = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: 'fallback' },
{ identities: [identity], resolveEffectiveHost },
);
const cleared = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: '' },
{ identities: [identity], resolveEffectiveHost },
);
const disabled = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ savePassword: 'false' },
{ identities: [identity], resolveEffectiveHost },
);
const changedUsername = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ username: 'ops', password: 'fallback' },
{ identities: [identity], resolveEffectiveHost },
);
assert.equal(updated.ok, true);
assert.equal(cleared.ok, true);
assert.equal(disabled.ok, true);
assert.equal(changedUsername.ok, true);
if (!updated.ok || !cleared.ok || !disabled.ok || !changedUsername.ok) return;
assert.equal(updated.updatedHost.identityId, identity.id);
assert.equal(resolveEffectiveHost(updated.updatedHost).authMethod, 'key');
assert.equal(cleared.updatedHost.identityId, identity.id);
assert.equal(resolveEffectiveHost(cleared.updatedHost).authMethod, 'key');
assert.equal(disabled.updatedHost.identityId, identity.id);
assert.equal(resolveEffectiveHost(disabled.updatedHost).authMethod, 'key');
assert.equal(changedUsername.updatedHost.username, 'ops');
assert.equal(changedUsername.updatedHost.identityId, '');
});
test('applyVaultHostUpdate detaches a password identity so the new password takes effect', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'old-secret',
created: 1,
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: 'new-secret' },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.password, 'new-secret');
assert.equal(result.updatedHost.username, 'deploy');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate detaches a password identity when clearing the password', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'old-secret',
created: 1,
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: '' },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.password, undefined);
assert.equal(result.updatedHost.savePassword, false);
assert.equal(result.updatedHost.username, 'deploy');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate detaches a password identity when disabling saved passwords', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
identityId: 'identity-1',
};
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'old-secret',
created: 1,
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ savePassword: 'false' },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.password, undefined);
assert.equal(result.updatedHost.savePassword, false);
assert.equal(result.updatedHost.username, 'deploy');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate can re-enable saved passwords after clearing one', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
tags: [],
os: 'linux',
password: 'old-secret',
};
const cleared = applyVaultHostUpdate([existing], [], existing.id, { password: '' });
assert.equal(cleared.ok, true);
if (!cleared.ok) return;
const restored = applyVaultHostUpdate([cleared.updatedHost], [], existing.id, {
password: 'new-secret',
savePassword: 'true',
});
assert.equal(restored.ok, true);
if (!restored.ok) return;
assert.equal(restored.updatedHost.password, 'new-secret');
assert.equal(restored.updatedHost.savePassword, true);
});
test('applyVaultHostUpdate detaches direct and inherited identities when changing username', () => {
const identity: Identity = {
id: 'identity-1',
label: 'shared key',
username: 'old-user',
authMethod: 'key',
keyId: 'key-1',
created: 1,
};
const directIdentityHost: Host = {
id: 'direct-host',
label: 'direct host',
hostname: 'direct.example.com',
username: 'old-user',
tags: [],
os: 'linux',
identityId: 'identity-1',
};
const inheritedIdentityHost: Host = {
id: 'inherited-host',
label: 'inherited host',
hostname: 'inherited.example.com',
username: 'old-user',
tags: [],
os: 'linux',
};
const directResult = applyVaultHostUpdate(
[directIdentityHost],
[],
directIdentityHost.id,
{ username: 'new-user' },
{ identities: [identity] },
);
const inheritedResult = applyVaultHostUpdate(
[inheritedIdentityHost],
[],
inheritedIdentityHost.id,
{ username: 'new-user' },
{
identities: [identity],
resolveEffectiveHost: (host) => ({ ...host, identityId: identity.id }),
},
);
assert.equal(directResult.ok, true);
assert.equal(inheritedResult.ok, true);
if (!directResult.ok || !inheritedResult.ok) return;
assert.equal(directResult.updatedHost.username, 'new-user');
assert.equal(directResult.updatedHost.identityId, '');
assert.equal(directResult.updatedHost.identityFileId, identity.keyId);
assert.equal(directResult.updatedHost.authMethod, 'key');
assert.equal(inheritedResult.updatedHost.username, 'new-user');
assert.equal(inheritedResult.updatedHost.identityId, '');
assert.equal(inheritedResult.updatedHost.identityFileId, identity.keyId);
assert.equal(inheritedResult.updatedHost.authMethod, 'key');
});
test('applyVaultHostUpdate keeps inherited key auth across sequential password and username updates', () => {
const identity: Identity = {
id: 'identity-1',
label: 'shared key',
username: 'deploy',
authMethod: 'key',
keyId: 'key-1',
created: 1,
};
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'host.example.com',
username: '',
group: 'prod',
tags: [],
os: 'linux',
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
identityId: identity.id,
username: identity.username,
authMethod: identity.authMethod,
identityFileId: identity.keyId,
});
const first = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: 'fallback-one' },
{ identities: [identity], resolveEffectiveHost },
);
assert.equal(first.ok, true);
if (!first.ok) return;
const second = applyVaultHostUpdate(
[first.updatedHost],
[],
existing.id,
{ username: 'ops', password: 'fallback-two' },
{ identities: [identity], resolveEffectiveHost },
);
assert.equal(second.ok, true);
if (!second.ok) return;
assert.equal(second.updatedHost.username, 'ops');
assert.equal(second.updatedHost.identityId, '');
assert.equal(second.updatedHost.identityFileId, identity.keyId);
assert.equal(second.updatedHost.authMethod, 'key');
});
test('applyVaultHostUpdate preserves a password identity credential when changing username', () => {
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'shared-secret',
created: 1,
};
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'host.example.com',
username: 'deploy',
identityId: identity.id,
tags: [],
os: 'linux',
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ username: 'ops' },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.username, 'ops');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.password, identity.password);
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate keeps password prompts when changing username without saving the password', () => {
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'shared-secret',
created: 1,
};
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'host.example.com',
username: 'deploy',
identityId: identity.id,
tags: [],
os: 'linux',
};
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ username: 'ops', savePassword: false },
{ identities: [identity] },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.username, 'ops');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.password, undefined);
assert.equal(result.updatedHost.savePassword, false);
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate does not copy an inherited identity password when saving is disabled', () => {
const identity: Identity = {
id: 'identity-1',
label: 'shared password',
username: 'deploy',
authMethod: 'password',
password: 'shared-secret',
created: 1,
};
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: 'host.example.com',
username: '',
group: 'locked',
tags: [],
os: 'linux',
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
identityId: identity.id,
username: identity.username,
authMethod: identity.authMethod,
savePassword: false,
});
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ username: 'ops' },
{ identities: [identity], resolveEffectiveHost },
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.updatedHost.username, 'ops');
assert.equal(result.updatedHost.identityId, '');
assert.equal(result.updatedHost.password, undefined);
assert.equal(result.updatedHost.authMethod, 'password');
});
test('applyVaultHostUpdate validates passwords against the destination group', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
group: 'open',
tags: [],
os: 'linux',
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(
host,
host.group === 'locked' ? { savePassword: false } : { savePassword: true },
);
const blocked = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ group: 'locked', password: 'must-not-save' },
{ resolveEffectiveHost },
);
const allowed = applyVaultHostUpdate(
[{ ...existing, group: 'locked' }],
[],
existing.id,
{ group: 'open', password: 'allowed' },
{ resolveEffectiveHost },
);
assert.equal(blocked.ok, false);
assert.equal(allowed.ok, true);
if (!allowed.ok) return;
assert.equal(allowed.updatedHost.group, 'open');
assert.equal(allowed.updatedHost.password, 'allowed');
});
test('applyVaultHostUpdate explicitly blocks an inherited password after clearing it', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
group: 'prod',
tags: [],
os: 'linux',
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
password: 'group-secret',
savePassword: true,
authMethod: 'password',
});
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ password: '' },
{ resolveEffectiveHost },
);
assert.equal(result.ok, true);
if (!result.ok) return;
const effectiveAfter = resolveEffectiveHost(result.updatedHost);
assert.equal(result.updatedHost.savePassword, false);
assert.equal(effectiveAfter.password, undefined);
assert.equal(effectiveAfter.savePassword, false);
});
test('applyVaultHostUpdate explicitly blocks an inherited key path after clearing it', () => {
const existing: Host = {
id: 'host-1',
label: 'host',
hostname: '10.0.0.1',
username: 'root',
group: 'prod',
tags: [],
os: 'linux',
};
const resolveEffectiveHost = (host: Host): Host => applyGroupDefaults(host, {
identityFilePaths: ['~/.ssh/group-key'],
authMethod: 'key',
});
const result = applyVaultHostUpdate(
[existing],
[],
existing.id,
{ keyPath: '' },
{ resolveEffectiveHost },
);
assert.equal(result.ok, true);
if (!result.ok) return;
const effectiveAfter = resolveEffectiveHost(result.updatedHost);
assert.deepEqual(result.updatedHost.identityFilePaths, []);
assert.deepEqual(effectiveAfter.identityFilePaths, []);
assert.equal(effectiveAfter.authMethod, 'auto');
});
test('applyVaultHostUpdate keeps managed-source ownership aligned with group and protocol', () => {
const managedSource: ManagedSource = {
id: 'source-1',
type: 'ssh_config',
filePath: '~/.ssh/config',
groupName: 'managed',
lastSyncedAt: 1,
};
const managedHost: Host = {
id: 'managed-host',
label: 'managed host',
hostname: 'managed.example.com',
username: 'root',
group: 'managed',
tags: [],
os: 'linux',
protocol: 'ssh',
managedSourceId: managedSource.id,
};
const regularHost: Host = {
id: 'regular-host',
label: 'regular host',
hostname: 'regular.example.com',
username: 'root',
tags: [],
os: 'linux',
protocol: 'ssh',
};
const options = { managedSources: [managedSource] };
const movedOut = applyVaultHostUpdate(
[managedHost],
[],
managedHost.id,
{ group: '' },
options,
);
const movedIn = applyVaultHostUpdate(
[regularHost],
[],
regularHost.id,
{ group: 'managed/child' },
options,
);
const changedProtocol = applyVaultHostUpdate(
[managedHost],
[],
managedHost.id,
{ protocol: 'telnet' },
options,
);
const changedNotes = applyVaultHostUpdate(
[managedHost],
[],
managedHost.id,
{ notes: 'keep the display name' },
options,
);
const renamed = applyVaultHostUpdate(
[managedHost],
[],
managedHost.id,
{ label: 'new managed name' },
options,
);
assert.equal(movedOut.ok, true);
assert.equal(movedIn.ok, true);
assert.equal(changedProtocol.ok, true);
assert.equal(changedNotes.ok, true);
assert.equal(renamed.ok, true);
if (!movedOut.ok || !movedIn.ok || !changedProtocol.ok || !changedNotes.ok || !renamed.ok) return;
assert.equal(movedOut.updatedHost.managedSourceId, undefined);
assert.equal(movedIn.updatedHost.managedSourceId, managedSource.id);
assert.equal(movedIn.updatedHost.label, 'regularhost');
assert.equal(changedProtocol.updatedHost.managedSourceId, undefined);
assert.equal(changedNotes.updatedHost.label, 'managed host');
assert.equal(renamed.updatedHost.label, 'newmanagedname');
});
test('applyVaultHostDelete removes only the requested host', () => {
const hosts: Host[] = [
{ id: 'host-1', label: 'one', hostname: 'one', username: 'root', tags: [], os: 'linux' },
{ id: 'host-2', label: 'two', hostname: 'two', username: 'root', tags: [], os: 'linux' },
];
const result = applyVaultHostDelete(hosts, 'host-1');
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.deletedHost.id, 'host-1');
assert.deepEqual(result.hosts.map((host) => host.id), ['host-2']);
});
test('applyVaultHostDelete keeps jump hosts that are still referenced', () => {
const jump: Host = {
id: 'jump', label: 'jump', hostname: 'jump.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const directTarget: Host = {
id: 'direct', label: 'direct', hostname: 'direct.example.com', username: 'root',
port: 22, protocol: 'ssh', hostChain: { hostIds: [jump.id] }, tags: [], os: 'linux',
};
const inheritedTarget: Host = {
id: 'inherited', label: 'inherited', hostname: 'inherited.example.com', username: 'root',
port: 22, protocol: 'ssh', group: 'prod', tags: [], os: 'linux',
};
const directResult = applyVaultHostDelete([jump, directTarget], jump.id);
const inheritedResult = applyVaultHostDelete(
[jump, inheritedTarget],
jump.id,
(host) => host.group === 'prod'
? { ...host, hostChain: { hostIds: [jump.id] } }
: host,
);
assert.equal(directResult.ok, false);
if (!directResult.ok) assert.match(directResult.error, /still used as a jump host/i);
assert.equal(inheritedResult.ok, false);
if (!inheritedResult.ok) assert.match(inheritedResult.error, /still used as a jump host/i);
});
test('applyVaultHostDelete keeps jump hosts referenced by empty group defaults', () => {
const jump: Host = {
id: 'jump', label: 'jump', hostname: 'jump.example.com', username: 'root',
port: 22, protocol: 'ssh', tags: [], os: 'linux',
};
const result = applyVaultHostDelete(
[jump],
jump.id,
undefined,
[{ path: 'empty-group', hostChain: { hostIds: [jump.id] } }],
);
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /still used as a group jump host/i);
});