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
1907 lines
61 KiB
TypeScript
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);
|
|
});
|