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
774 lines
23 KiB
TypeScript
774 lines
23 KiB
TypeScript
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,
|
|
);
|
|
});
|