[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
773
domain/sshAuth.test.ts
Normal file
773
domain/sshAuth.test.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyHostAuthMethodSelection, hasBridgeSshCredentials, hasRequiredHostAuthCredential, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth, resolveHostAuthMethodForPersistence, resolveHostAuthMethodSelection, resolveHostAutofillPassword, resolveSshAgentToggleUpdate } from "./sshAuth.ts";
|
||||
import { applyGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
|
||||
import { sanitizeHost } from "./host.ts";
|
||||
import type { Host, Identity, SSHKey } from "./models.ts";
|
||||
|
||||
const referenceKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Reference key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "reference",
|
||||
category: "key",
|
||||
created: 1,
|
||||
filePath: "/Users/alice/.ssh/id_ed25519",
|
||||
};
|
||||
|
||||
test("resolveBridgeKeyAuth passes reference keys as identity file paths", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeKeyAuth({
|
||||
key: referenceKey,
|
||||
fallbackIdentityFilePaths: ["/legacy/key"],
|
||||
passphrase: "saved-passphrase",
|
||||
}),
|
||||
{
|
||||
privateKey: undefined,
|
||||
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
|
||||
passphrase: "saved-passphrase",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeKeyAuth ignores undecryptable passphrase placeholders", () => {
|
||||
assert.equal(
|
||||
resolveBridgeKeyAuth({
|
||||
key: {
|
||||
...referenceKey,
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
}).passphrase,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeKeyAuth ignores undecryptable private key placeholders", () => {
|
||||
assert.equal(
|
||||
resolveBridgeKeyAuth({
|
||||
key: {
|
||||
...referenceKey,
|
||||
source: "imported",
|
||||
filePath: undefined,
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
}).privateKey,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeKeyAuth preserves imported key material", () => {
|
||||
const importedKey: SSHKey = {
|
||||
...referenceKey,
|
||||
source: "imported",
|
||||
privateKey: "PRIVATE KEY",
|
||||
filePath: undefined,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBridgeKeyAuth({
|
||||
key: importedKey,
|
||||
fallbackIdentityFilePaths: ["/legacy/key"],
|
||||
}),
|
||||
{
|
||||
privateKey: "PRIVATE KEY",
|
||||
identityFilePaths: ["/legacy/key"],
|
||||
passphrase: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth carries system agent settings without private material", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
}),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps certificate authentication independent", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
useSshAgent: true,
|
||||
}, { certificate: "ssh-ed25519-cert-v01@openssh.com AAAATEST" }),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth forwards a selected vault public key", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
useSshAgent: true,
|
||||
identitiesOnly: true,
|
||||
}, { publicKey: "ssh-ed25519 AAAATEST" }),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: undefined,
|
||||
useKeychain: undefined,
|
||||
agentPublicKeys: ["ssh-ed25519 AAAATEST"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps agent login for a selected reference key", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
useSshAgent: true,
|
||||
identitiesOnly: false,
|
||||
}, referenceKey, "key"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: undefined,
|
||||
useKeychain: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps agent login for a selected local key file", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
authMethod: "key",
|
||||
useSshAgent: true,
|
||||
identitiesOnly: false,
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
}, undefined, "key"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: undefined,
|
||||
useKeychain: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth preserves an explicit agent opt-out", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
useSshAgent: false,
|
||||
identityAgent: "none",
|
||||
}),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth treats an unset agent toggle as disabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth(autofillBaseHost),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth leaves the ambient agent available in automatic mode", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth(autofillBaseHost, undefined, "auto"),
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, undefined, "auto"),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps an explicit forwarding agent separate from login", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
agentForwarding: true,
|
||||
identityAgent: "/tmp/forwarding-agent.sock",
|
||||
}, undefined, "password"),
|
||||
{
|
||||
useSshAgent: false,
|
||||
identityAgent: "/tmp/forwarding-agent.sock",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth does not forward the login-only IdentityAgent none directive", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
agentForwarding: true,
|
||||
identityAgent: "none",
|
||||
}, undefined, "password"),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth lets explicit auth override a stale agent toggle", () => {
|
||||
const staleAgentHost = { ...autofillBaseHost, useSshAgent: true };
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth(staleAgentHost, undefined, "password"),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth(staleAgentHost, undefined, "certificate"),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...staleAgentHost, authMethod: "key" }, undefined, "key"),
|
||||
{ useSshAgent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth restricts explicit agent-backed key auth to the selected key", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth(
|
||||
{ ...autofillBaseHost, useSshAgent: true, identitiesOnly: false },
|
||||
{ publicKey: "ssh-ed25519 AAAASELECTED" },
|
||||
"key",
|
||||
),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: undefined,
|
||||
useKeychain: undefined,
|
||||
agentPublicKeys: ["ssh-ed25519 AAAASELECTED"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("hasRequiredHostAuthCredential rejects empty explicit key and certificate selections", () => {
|
||||
assert.equal(hasRequiredHostAuthCredential({
|
||||
host: { ...autofillBaseHost, authMethod: "key" },
|
||||
keys: [],
|
||||
}), false);
|
||||
assert.equal(hasRequiredHostAuthCredential({
|
||||
host: { ...autofillBaseHost, authMethod: "certificate" },
|
||||
keys: [],
|
||||
}), false);
|
||||
assert.equal(hasRequiredHostAuthCredential({
|
||||
host: { ...autofillBaseHost, authMethod: "key", identityFilePaths: ["~/.ssh/id_work"] },
|
||||
keys: [],
|
||||
}), true);
|
||||
assert.equal(hasRequiredHostAuthCredential({
|
||||
host: { ...autofillBaseHost, protocol: "telnet", authMethod: "key" },
|
||||
keys: [],
|
||||
}), true);
|
||||
assert.equal(hasRequiredHostAuthCredential({
|
||||
host: applyGroupDefaults({ ...autofillBaseHost, username: "" }, { identityId: "deleted-identity" }),
|
||||
keys: [],
|
||||
identities: [],
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("hasBridgeSshCredentials accepts an agent-only host", () => {
|
||||
assert.equal(hasBridgeSshCredentials({ useSshAgent: true }), true);
|
||||
assert.equal(hasBridgeSshCredentials({}), false);
|
||||
});
|
||||
|
||||
test("hasBridgeSshCredentials accepts automatic local authentication", () => {
|
||||
assert.equal(hasBridgeSshCredentials({ authMethod: "auto" }), true);
|
||||
});
|
||||
|
||||
test("resolveHostAuth respects password auth over stale key selections", () => {
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
identityFileId: "key-1",
|
||||
};
|
||||
|
||||
const resolved = resolveHostAuth({
|
||||
host,
|
||||
keys: [referenceKey],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(resolved.authMethod, "password");
|
||||
assert.equal(resolved.key, undefined);
|
||||
assert.equal(resolved.keyId, undefined);
|
||||
});
|
||||
|
||||
test("resolveHostAuth infers key auth from imported IdentityFile paths", () => {
|
||||
const resolved = resolveHostAuth({
|
||||
host: {
|
||||
...autofillBaseHost,
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
assert.equal(resolved.authMethod, "key");
|
||||
});
|
||||
|
||||
test("resolveHostAuth treats a legacy host without credentials as automatic", () => {
|
||||
const resolved = resolveHostAuth({
|
||||
host: autofillBaseHost,
|
||||
keys: [],
|
||||
});
|
||||
|
||||
assert.equal(resolved.authMethod, "auto");
|
||||
});
|
||||
|
||||
test("resolveHostAuth keeps a legacy saved password in password-only mode", () => {
|
||||
const resolved = resolveHostAuth({
|
||||
host: { ...autofillBaseHost, password: "saved-secret" },
|
||||
keys: [],
|
||||
});
|
||||
|
||||
assert.equal(resolved.authMethod, "password");
|
||||
});
|
||||
|
||||
test("a new automatic host keeps its saved password as a fallback", () => {
|
||||
const host = {
|
||||
...autofillBaseHost,
|
||||
authPolicyVersion: 1 as const,
|
||||
authMethod: undefined,
|
||||
password: "fallback-secret",
|
||||
} as Host;
|
||||
|
||||
assert.equal(resolveHostAuthMethodSelection(host), "auto");
|
||||
assert.equal(resolveHostAuth({ host, keys: [] }).authMethod, "auto");
|
||||
});
|
||||
|
||||
test("a migrated host keeps an inherited legacy group password password-only", () => {
|
||||
const host = sanitizeHost({
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
authPolicyVersion: undefined,
|
||||
password: undefined,
|
||||
group: "team",
|
||||
});
|
||||
const groupDefaults = sanitizeGroupConfig({
|
||||
path: "team",
|
||||
password: "group-secret",
|
||||
});
|
||||
|
||||
assert.equal(host.authMethod, undefined);
|
||||
assert.equal(groupDefaults.authMethod, "password");
|
||||
assert.equal(resolveHostAuth({
|
||||
host: applyGroupDefaults(host, groupDefaults),
|
||||
keys: [],
|
||||
}).authMethod, "password");
|
||||
});
|
||||
|
||||
test("migrated hosts keep inferred vault key and certificate authentication", () => {
|
||||
const certificateKey = {
|
||||
...referenceKey,
|
||||
id: "certificate-1",
|
||||
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
category: "certificate" as const,
|
||||
};
|
||||
|
||||
for (const [key, expectedMethod] of [
|
||||
[referenceKey, "key"],
|
||||
[certificateKey, "certificate"],
|
||||
] as const) {
|
||||
const host = sanitizeHost({
|
||||
...autofillBaseHost,
|
||||
authMethod: undefined,
|
||||
authPolicyVersion: undefined,
|
||||
identityFileId: key.id,
|
||||
});
|
||||
|
||||
assert.equal(host.authMethod, undefined);
|
||||
assert.equal(host.authPolicyVersion, 1);
|
||||
assert.equal(resolveHostAuth({ host, keys: [key] }).authMethod, expectedMethod);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveHostAuthMethodSelection gives legacy hosts a visible mode", () => {
|
||||
assert.equal(resolveHostAuthMethodSelection(autofillBaseHost), "auto");
|
||||
assert.equal(resolveHostAuthMethodSelection({ ...autofillBaseHost, password: "secret" }), "password");
|
||||
assert.equal(resolveHostAuthMethodSelection({ ...autofillBaseHost, identityFilePaths: ["~/.ssh/id_work"] }), "key");
|
||||
assert.equal(resolveHostAuthMethodSelection({ ...autofillBaseHost, identityFilePaths: ["~/.ssh/id_work"], useSshAgent: true }), "auto");
|
||||
});
|
||||
|
||||
test("resolveHostAuth preserves legacy agent plus identity-file hosts as automatic", () => {
|
||||
const resolved = resolveHostAuth({
|
||||
host: {
|
||||
...autofillBaseHost,
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
useSshAgent: true,
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
assert.equal(resolved.authMethod, "auto");
|
||||
});
|
||||
|
||||
test("applyHostAuthMethodSelection clears incompatible per-host credentials", () => {
|
||||
const keyedHost = {
|
||||
...autofillBaseHost,
|
||||
authMethod: "key",
|
||||
identityId: "identity-1",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
useSshAgent: true,
|
||||
} as Host;
|
||||
|
||||
assert.deepEqual(applyHostAuthMethodSelection(keyedHost, "certificate"), {
|
||||
...keyedHost,
|
||||
authMethod: "certificate",
|
||||
authPolicyVersion: 1,
|
||||
identityId: "",
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
useSshAgent: false,
|
||||
});
|
||||
assert.deepEqual(applyHostAuthMethodSelection(keyedHost, "auto"), {
|
||||
...keyedHost,
|
||||
authMethod: "auto",
|
||||
authPolicyVersion: 1,
|
||||
identityId: "",
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: undefined,
|
||||
useSshAgent: undefined,
|
||||
});
|
||||
assert.deepEqual(applyHostAuthMethodSelection(keyedHost, "key"), {
|
||||
...keyedHost,
|
||||
authPolicyVersion: 1,
|
||||
});
|
||||
|
||||
const passwordIdentityHost = {
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
identityId: "identity-password",
|
||||
useSshAgent: false,
|
||||
} as Host;
|
||||
assert.deepEqual(applyHostAuthMethodSelection(passwordIdentityHost, "password"), {
|
||||
...passwordIdentityHost,
|
||||
authPolicyVersion: 1,
|
||||
});
|
||||
|
||||
const passwordHost = {
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
} as Host;
|
||||
const automaticHost = applyHostAuthMethodSelection(passwordHost, "auto");
|
||||
assert.equal(automaticHost.useSshAgent, undefined);
|
||||
assert.deepEqual(resolveBridgeSshAgentAuth(automaticHost, undefined, "auto"), {});
|
||||
});
|
||||
|
||||
test("resolveSshAgentToggleUpdate keeps the default automatic agent optional", () => {
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate({}, "auto", true), {
|
||||
useSshAgent: undefined,
|
||||
identityAgent: undefined,
|
||||
});
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate({}, "auto", false), {
|
||||
useSshAgent: false,
|
||||
identityAgent: undefined,
|
||||
});
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate({ identityAgent: "none" }, "auto", true), {
|
||||
useSshAgent: undefined,
|
||||
identityAgent: undefined,
|
||||
});
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate({ identityAgent: "/tmp/custom-agent.sock" }, "auto", true), {
|
||||
useSshAgent: true,
|
||||
identityAgent: "/tmp/custom-agent.sock",
|
||||
});
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate({}, "key", true), {
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("per-host auth selection opts out of an inherited group identity", () => {
|
||||
const selected = applyHostAuthMethodSelection({
|
||||
...autofillBaseHost,
|
||||
group: "Production",
|
||||
identityId: "group-identity",
|
||||
} as Host, "password");
|
||||
const effective = applyGroupDefaults(selected, { identityId: "group-identity" });
|
||||
const resolved = resolveHostAuth({
|
||||
host: effective,
|
||||
keys: [referenceKey],
|
||||
identities: [{
|
||||
id: "group-identity",
|
||||
label: "Group key",
|
||||
username: "deploy",
|
||||
authMethod: "key",
|
||||
keyId: referenceKey.id,
|
||||
created: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(effective.identityId, "");
|
||||
assert.equal(resolved.authMethod, "password");
|
||||
assert.equal(resolved.key, undefined);
|
||||
});
|
||||
|
||||
test("saving keeps inherited group authentication inherited", () => {
|
||||
const identity = {
|
||||
id: "group-identity",
|
||||
label: "Group identity",
|
||||
username: "deploy",
|
||||
authMethod: "certificate" as const,
|
||||
keyId: referenceKey.id,
|
||||
created: 1,
|
||||
};
|
||||
const cases = [
|
||||
[{ authMethod: "password" as const, password: "secret" }, "password"],
|
||||
[{ authMethod: "key" as const, identityFileId: referenceKey.id }, "key"],
|
||||
[{ authMethod: "certificate" as const, identityFileId: referenceKey.id }, "certificate"],
|
||||
[{ identityId: identity.id }, "certificate"],
|
||||
] as const;
|
||||
|
||||
for (const [groupDefaults, expectedMethod] of cases) {
|
||||
const host = { ...autofillBaseHost, username: "", authMethod: undefined } as Host;
|
||||
assert.equal(resolveHostAuthMethodForPersistence({
|
||||
host,
|
||||
keys: [referenceKey],
|
||||
identities: [identity],
|
||||
groupDefaults,
|
||||
}), undefined);
|
||||
assert.equal(resolveHostAuth({
|
||||
host: applyGroupDefaults(host, groupDefaults),
|
||||
keys: [referenceKey],
|
||||
identities: [identity],
|
||||
}).authMethod, expectedMethod);
|
||||
}
|
||||
});
|
||||
|
||||
test("saving a legacy password host keeps password-only after discarding the secret", () => {
|
||||
const host = {
|
||||
...autofillBaseHost,
|
||||
authMethod: undefined,
|
||||
password: "temporary-secret",
|
||||
savePassword: false,
|
||||
} as Host;
|
||||
|
||||
assert.equal(resolveHostAuthMethodForPersistence({ host, keys: [] }), "password");
|
||||
});
|
||||
|
||||
test("an untouched host keeps following authentication added to its group later", () => {
|
||||
const host = { ...autofillBaseHost, username: "", authMethod: undefined } as Host;
|
||||
assert.equal(resolveHostAuthMethodForPersistence({ host, keys: [] }), undefined);
|
||||
|
||||
const futureEffectiveHost = applyGroupDefaults(host, {
|
||||
authMethod: "password",
|
||||
password: "future-group-secret",
|
||||
});
|
||||
assert.equal(resolveHostAuth({ host: futureEffectiveHost, keys: [] }).authMethod, "password");
|
||||
});
|
||||
|
||||
test("legacy agent settings do not override inherited strict group authentication", () => {
|
||||
const groupDefaults = { authMethod: "password" as const, password: "group-secret" };
|
||||
for (const useSshAgent of [false, true]) {
|
||||
const host = {
|
||||
...autofillBaseHost,
|
||||
username: "",
|
||||
authMethod: undefined,
|
||||
useSshAgent,
|
||||
identityAgent: "/tmp/legacy-agent.sock",
|
||||
identitiesOnly: true,
|
||||
} as Host;
|
||||
assert.equal(resolveHostAuthMethodForPersistence({ host, keys: [], groupDefaults }), undefined);
|
||||
assert.equal(resolveHostAuth({
|
||||
host: applyGroupDefaults(host, groupDefaults),
|
||||
keys: [],
|
||||
}).authMethod, "password");
|
||||
}
|
||||
});
|
||||
|
||||
test("saving does not replace an effective group method with stale host credentials", () => {
|
||||
const cases = [
|
||||
[{ password: "stale-host-password", savePassword: false }, {
|
||||
authMethod: "key" as const,
|
||||
identityFileId: referenceKey.id,
|
||||
}, "key"],
|
||||
[{ identityFileId: referenceKey.id, identityFilePaths: ["~/.ssh/id_stale"] }, {
|
||||
authMethod: "password" as const,
|
||||
password: "group-password",
|
||||
}, "password"],
|
||||
] as const;
|
||||
|
||||
for (const [hostCredentials, groupDefaults, expectedMethod] of cases) {
|
||||
const host = {
|
||||
...autofillBaseHost,
|
||||
username: "",
|
||||
authMethod: undefined,
|
||||
...hostCredentials,
|
||||
} as Host;
|
||||
const methodBeforeSave = resolveHostAuth({
|
||||
host: applyGroupDefaults(host, groupDefaults),
|
||||
keys: [referenceKey],
|
||||
}).authMethod;
|
||||
const persistedMethod = resolveHostAuthMethodForPersistence({
|
||||
host,
|
||||
keys: [referenceKey],
|
||||
groupDefaults,
|
||||
});
|
||||
const methodAfterSave = resolveHostAuth({
|
||||
host: applyGroupDefaults({ ...host, authMethod: persistedMethod }, groupDefaults),
|
||||
keys: [referenceKey],
|
||||
}).authMethod;
|
||||
|
||||
assert.equal(methodBeforeSave, expectedMethod);
|
||||
assert.equal(persistedMethod, undefined);
|
||||
assert.equal(methodAfterSave, expectedMethod);
|
||||
}
|
||||
});
|
||||
|
||||
test("manual host credentials suppress an inherited group identity", () => {
|
||||
const groupDefaults = { identityId: "group-identity" };
|
||||
const passwordHost = applyGroupDefaults({
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
password: "host-secret",
|
||||
}, groupDefaults);
|
||||
const keyHost = applyGroupDefaults({
|
||||
...autofillBaseHost,
|
||||
authMethod: "key",
|
||||
identityFileId: referenceKey.id,
|
||||
}, groupDefaults);
|
||||
|
||||
assert.equal(passwordHost.identityId, undefined);
|
||||
assert.equal(keyHost.identityId, undefined);
|
||||
assert.equal(resolveHostAuth({ host: passwordHost, keys: [referenceKey] }).authMethod, "password");
|
||||
assert.equal(resolveHostAuth({ host: keyHost, keys: [referenceKey] }).authMethod, "key");
|
||||
});
|
||||
|
||||
test("switching to automatic keeps visible custom agent settings active", () => {
|
||||
const selected = applyHostAuthMethodSelection({
|
||||
...autofillBaseHost,
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
identityAgent: "/tmp/custom-agent.sock",
|
||||
identitiesOnly: true,
|
||||
} as Host, "auto");
|
||||
|
||||
assert.equal(selected.useSshAgent, true);
|
||||
assert.equal(selected.identitiesOnly, undefined);
|
||||
assert.deepEqual(resolveBridgeSshAgentAuth(selected, undefined, "auto"), {
|
||||
useSshAgent: true,
|
||||
identityAgent: "/tmp/custom-agent.sock",
|
||||
identitiesOnly: undefined,
|
||||
addKeysToAgent: undefined,
|
||||
useKeychain: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching to automatic clears stale strict agent settings", () => {
|
||||
const selected = applyHostAuthMethodSelection({
|
||||
...autofillBaseHost,
|
||||
authMethod: "key",
|
||||
identityFileId: referenceKey.id,
|
||||
useSshAgent: true,
|
||||
identityAgent: "none",
|
||||
identitiesOnly: true,
|
||||
} as Host, "auto");
|
||||
|
||||
assert.equal(selected.useSshAgent, undefined);
|
||||
assert.equal(selected.identityAgent, undefined);
|
||||
assert.equal(selected.identitiesOnly, undefined);
|
||||
});
|
||||
|
||||
test("negative SSH directives do not make automatic auth require an agent", () => {
|
||||
const host = {
|
||||
...autofillBaseHost,
|
||||
authMethod: "key",
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
useSshAgent: false,
|
||||
addKeysToAgent: "no",
|
||||
useKeychain: false,
|
||||
} as Host;
|
||||
|
||||
const selected = applyHostAuthMethodSelection(host, "auto");
|
||||
assert.equal(selected.useSshAgent, undefined);
|
||||
assert.deepEqual(resolveSshAgentToggleUpdate(host, "auto", true), {
|
||||
useSshAgent: undefined,
|
||||
identityAgent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("an explicit automatic host remains automatic when its group supplies a key", () => {
|
||||
const effective = applyGroupDefaults({
|
||||
...autofillBaseHost,
|
||||
authMethod: "auto",
|
||||
}, {
|
||||
authMethod: "key",
|
||||
identityFileId: referenceKey.id,
|
||||
});
|
||||
|
||||
assert.equal(resolveHostAuth({ host: effective, keys: [referenceKey] }).authMethod, "auto");
|
||||
});
|
||||
|
||||
const autofillBaseHost = {
|
||||
id: "h1",
|
||||
label: "Host",
|
||||
hostname: "h.example.test",
|
||||
username: "alice",
|
||||
} as Host;
|
||||
|
||||
test("resolveHostAutofillPassword uses the host's own saved password", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "direct-secret" }, keys: [] }),
|
||||
"direct-secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword resolves a referenced keychain identity's password", () => {
|
||||
// host stores no password of its own; the credential lives in a Keychain
|
||||
// identity it references (host.identityId) — the #1284 scenario.
|
||||
const identity = {
|
||||
id: "id-1",
|
||||
label: "alice@prod",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "identity-secret",
|
||||
created: 1,
|
||||
} as Identity;
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({
|
||||
host: { ...autofillBaseHost, password: undefined, identityId: "id-1" },
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
}),
|
||||
"identity-secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword returns undefined when the host opts out of saving", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "x", savePassword: false }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword returns undefined when no password is available", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: undefined }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword ignores undecryptable password placeholders", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user