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
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import type { Host } from "./models";
|
|
import { resolveHostAutofillPassword } from "./sshAuth";
|
|
import {
|
|
buildSshDeepLinkConnectionHost,
|
|
buildSshDeepLinkEphemeralHost,
|
|
buildSshDeepLinkEphemeralHostFromSaved,
|
|
buildSshDeepLinkHostDraft,
|
|
buildSshDeepLinkOpenHost,
|
|
buildSshNoteLinkOpenHost,
|
|
findSshDeepLinkHost,
|
|
parseSshDeepLink,
|
|
shouldHandleSshDeepLink,
|
|
} from "./sshDeepLink";
|
|
|
|
const host = (overrides: Partial<Host>): Host => ({
|
|
id: overrides.id || "host-1",
|
|
label: overrides.label || "Example",
|
|
hostname: overrides.hostname || "example.com",
|
|
username: overrides.username ?? "root",
|
|
port: overrides.port,
|
|
group: "",
|
|
tags: [],
|
|
os: "linux",
|
|
protocol: overrides.protocol ?? "ssh",
|
|
...overrides,
|
|
});
|
|
|
|
test("parseSshDeepLink accepts username host and port", () => {
|
|
assert.deepEqual(parseSshDeepLink("ssh://alice@example.com:2200"), {
|
|
rawUrl: "ssh://alice@example.com:2200",
|
|
username: "alice",
|
|
hostname: "example.com",
|
|
port: 2200,
|
|
});
|
|
});
|
|
|
|
test("parseSshDeepLink accepts IPv6 hosts", () => {
|
|
assert.deepEqual(parseSshDeepLink("ssh://bob@[2001:db8::10]:2222"), {
|
|
rawUrl: "ssh://bob@[2001:db8::10]:2222",
|
|
username: "bob",
|
|
hostname: "2001:db8::10",
|
|
port: 2222,
|
|
});
|
|
});
|
|
|
|
test("parseSshDeepLink extracts and percent-decodes passwords", () => {
|
|
assert.deepEqual(parseSshDeepLink("ssh://alice:secret@example.com"), {
|
|
rawUrl: "ssh://alice:secret@example.com",
|
|
username: "alice",
|
|
password: "secret",
|
|
hostname: "example.com",
|
|
});
|
|
assert.deepEqual(parseSshDeepLink("ssh://alice:p%40ss@example.com:2200"), {
|
|
rawUrl: "ssh://alice:p%40ss@example.com:2200",
|
|
username: "alice",
|
|
password: "p@ss",
|
|
hostname: "example.com",
|
|
port: 2200,
|
|
});
|
|
});
|
|
|
|
test("parseSshDeepLink omits password when not present", () => {
|
|
const target = parseSshDeepLink("ssh://alice@example.com");
|
|
assert.ok(target);
|
|
assert.equal(target.password, undefined);
|
|
});
|
|
|
|
test("buildSshDeepLinkEphemeralHost includes password auth and disables mosh and et", () => {
|
|
const ephemeral = buildSshDeepLinkEphemeralHost(
|
|
parseSshDeepLink("ssh://alice:secret@example.com:2200")!,
|
|
{ id: "ephemeral-id", now: 789 },
|
|
);
|
|
|
|
assert.equal(ephemeral.id, "ephemeral-id");
|
|
assert.equal(ephemeral.password, "secret");
|
|
assert.equal(ephemeral.authMethod, "password");
|
|
assert.equal(ephemeral.savePassword, false);
|
|
assert.equal(resolveHostAutofillPassword({ host: ephemeral, keys: [] }), undefined);
|
|
assert.equal(ephemeral.moshEnabled, false);
|
|
assert.equal(ephemeral.etEnabled, false);
|
|
assert.equal(ephemeral.protocol, "ssh");
|
|
assert.equal(ephemeral.ephemeral, true);
|
|
});
|
|
|
|
test("buildSshDeepLinkEphemeralHost omits password fields when target has no password", () => {
|
|
const ephemeral = buildSshDeepLinkEphemeralHost(
|
|
parseSshDeepLink("ssh://alice@example.com")!,
|
|
{ id: "ephemeral-id", now: 789 },
|
|
);
|
|
|
|
assert.equal(ephemeral.password, undefined);
|
|
assert.equal(ephemeral.authMethod, undefined);
|
|
assert.equal(ephemeral.savePassword, false);
|
|
assert.equal(ephemeral.moshEnabled, false);
|
|
assert.equal(ephemeral.etEnabled, false);
|
|
});
|
|
|
|
test("buildSshDeepLinkEphemeralHostFromSaved keeps saved settings but overrides credentials", () => {
|
|
// Effective host: group defaults already resolved, including
|
|
// group-inherited credentials that must not survive the build.
|
|
const effectiveSavedHost = {
|
|
id: "saved-id",
|
|
label: "Saved Host",
|
|
hostname: "example.com",
|
|
username: "vault-user",
|
|
port: 2200,
|
|
group: "prod",
|
|
tags: ["bastion"],
|
|
os: "linux" as const,
|
|
identityId: "group-identity-1",
|
|
identityFileId: "group-key-1",
|
|
identityFilePaths: ["/home/user/.ssh/id_ed25519"],
|
|
password: "vault-password",
|
|
savePassword: true,
|
|
authMethod: "key" as const,
|
|
proxyProfileId: "proxy-1",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
charset: "utf8",
|
|
moshEnabled: true,
|
|
createdAt: 1,
|
|
};
|
|
|
|
const ephemeral = buildSshDeepLinkEphemeralHostFromSaved(
|
|
effectiveSavedHost,
|
|
parseSshDeepLink("ssh://alice:otp@example.com:2200")!,
|
|
{ id: "ephemeral-id", now: 789 },
|
|
);
|
|
|
|
assert.equal(ephemeral.id, "ephemeral-id");
|
|
assert.equal(ephemeral.ephemeral, true);
|
|
assert.equal(ephemeral.username, "alice");
|
|
assert.equal(ephemeral.password, "otp");
|
|
assert.equal(ephemeral.authMethod, "password");
|
|
assert.equal(ephemeral.identityId, undefined);
|
|
assert.equal(ephemeral.identityFileId, undefined);
|
|
assert.equal(ephemeral.identityFilePaths, undefined);
|
|
assert.equal(ephemeral.savePassword, false);
|
|
assert.equal(resolveHostAutofillPassword({ host: ephemeral, keys: [] }), undefined);
|
|
// Group is cleared so effective-host resolution cannot re-inherit
|
|
// group credentials over the one-time password.
|
|
assert.equal(ephemeral.group, "");
|
|
assert.equal(ephemeral.proxyProfileId, "proxy-1");
|
|
assert.deepEqual(ephemeral.hostChain, { hostIds: ["jump-1"] });
|
|
assert.equal(ephemeral.charset, "utf8");
|
|
assert.equal(ephemeral.protocol, "ssh");
|
|
assert.equal(ephemeral.moshEnabled, false);
|
|
assert.equal(ephemeral.etEnabled, false);
|
|
});
|
|
|
|
test("buildSshDeepLinkHostDraft never includes a password", () => {
|
|
const draft = buildSshDeepLinkHostDraft(
|
|
parseSshDeepLink("ssh://alice:secret@example.com")!,
|
|
{ id: "draft-id", now: 123 },
|
|
);
|
|
|
|
assert.equal(draft.password, undefined);
|
|
assert.equal(draft.authMethod, undefined);
|
|
});
|
|
|
|
test("parseSshDeepLink rejects unsupported or incomplete links", () => {
|
|
assert.equal(parseSshDeepLink("https://example.com"), null);
|
|
assert.equal(parseSshDeepLink("ssh://"), null);
|
|
assert.equal(parseSshDeepLink("ssh://example.com:99999"), null);
|
|
});
|
|
|
|
test("shouldHandleSshDeepLink respects the user setting", () => {
|
|
assert.equal(shouldHandleSshDeepLink("ssh://alice@example.com", true), true);
|
|
assert.equal(shouldHandleSshDeepLink("ssh://alice@example.com", false), false);
|
|
assert.equal(shouldHandleSshDeepLink("https://example.com", true), false);
|
|
});
|
|
|
|
test("findSshDeepLinkHost matches saved ssh hosts by username hostname and port", () => {
|
|
const hosts = [
|
|
host({ id: "wrong-port", hostname: "example.com", username: "alice", port: 22 }),
|
|
host({ id: "match", hostname: "example.com", username: "alice", port: 2200 }),
|
|
host({ id: "telnet", hostname: "example.com", username: "alice", port: 2200, protocol: "telnet" }),
|
|
];
|
|
|
|
const match = findSshDeepLinkHost(hosts, parseSshDeepLink("ssh://alice@example.com:2200")!);
|
|
|
|
assert.equal(match?.id, "match");
|
|
});
|
|
|
|
test("findSshDeepLinkHost avoids ambiguous saved hosts", () => {
|
|
const hosts = [
|
|
host({ id: "one", hostname: "example.com", username: "alice", port: 2200 }),
|
|
host({ id: "two", hostname: "example.com", username: "alice", port: 2222 }),
|
|
];
|
|
|
|
const match = findSshDeepLinkHost(hosts, parseSshDeepLink("ssh://alice@example.com")!);
|
|
|
|
assert.equal(match, null);
|
|
});
|
|
|
|
test("findSshDeepLinkHost treats omitted ports as the ssh default", () => {
|
|
const hosts = [
|
|
host({ id: "custom-port", hostname: "example.com", username: "alice", port: 2200 }),
|
|
host({ id: "default-port", hostname: "example.com", username: "alice" }),
|
|
];
|
|
|
|
const match = findSshDeepLinkHost(hosts, parseSshDeepLink("ssh://alice@example.com")!);
|
|
|
|
assert.equal(match?.id, "default-port");
|
|
});
|
|
|
|
test("buildSshDeepLinkConnectionHost forces a saved host to open with ssh", () => {
|
|
const savedHost = host({
|
|
id: "saved",
|
|
hostname: "example.com",
|
|
username: "alice",
|
|
moshEnabled: true,
|
|
etEnabled: true,
|
|
});
|
|
|
|
const connectionHost = buildSshDeepLinkConnectionHost(savedHost);
|
|
|
|
assert.equal(connectionHost.protocol, "ssh");
|
|
assert.equal(connectionHost.moshEnabled, false);
|
|
assert.equal(connectionHost.etEnabled, false);
|
|
assert.equal(savedHost.moshEnabled, true);
|
|
assert.equal(savedHost.etEnabled, true);
|
|
});
|
|
|
|
test("buildSshDeepLinkHostDraft prepares an editable new ssh host", () => {
|
|
const draft = buildSshDeepLinkHostDraft(
|
|
parseSshDeepLink("ssh://alice@example.com:2200")!,
|
|
{ id: "new-id", now: 123 },
|
|
);
|
|
|
|
assert.deepEqual(draft, {
|
|
id: "new-id",
|
|
label: "alice@example.com",
|
|
hostname: "example.com",
|
|
username: "alice",
|
|
port: 2200,
|
|
group: "",
|
|
tags: [],
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
createdAt: 123,
|
|
});
|
|
});
|
|
|
|
test("buildSshDeepLinkOpenHost falls back to a draft host when no saved host matches", () => {
|
|
const openHost = buildSshDeepLinkOpenHost(
|
|
[],
|
|
parseSshDeepLink("ssh://root@missing.example.com:2200")!,
|
|
{ id: "draft-id", now: 456 },
|
|
);
|
|
|
|
assert.equal(openHost.id, "draft-id");
|
|
assert.equal(openHost.hostname, "missing.example.com");
|
|
assert.equal(openHost.username, "root");
|
|
assert.equal(openHost.port, 2200);
|
|
assert.equal(openHost.protocol, "ssh");
|
|
assert.equal(openHost.moshEnabled, false);
|
|
assert.equal(openHost.etEnabled, false);
|
|
});
|
|
|
|
test("buildSshNoteLinkOpenHost opens an existing ssh link host", () => {
|
|
const openHost = buildSshNoteLinkOpenHost(
|
|
[host({ id: "match", hostname: "10.2.0.32", username: "root" })],
|
|
"ssh://10.2.0.32",
|
|
"10.2.0.32",
|
|
{ id: "draft-id", now: 456 },
|
|
);
|
|
|
|
assert.equal(openHost?.id, "match");
|
|
assert.equal(openHost?.protocol, "ssh");
|
|
assert.equal(openHost?.moshEnabled, false);
|
|
assert.equal(openHost?.etEnabled, false);
|
|
});
|
|
|
|
test("buildSshNoteLinkOpenHost treats a bare host link as an existing ssh host reference", () => {
|
|
const openHost = buildSshNoteLinkOpenHost(
|
|
[host({ id: "match", hostname: "10.2.0.32", username: "root" })],
|
|
"10.2.0.32",
|
|
"10.2.0.32",
|
|
{ id: "draft-id", now: 456 },
|
|
);
|
|
|
|
assert.equal(openHost?.id, "match");
|
|
assert.equal(openHost?.protocol, "ssh");
|
|
});
|
|
|
|
test("buildSshNoteLinkOpenHost leaves document-relative note links alone", () => {
|
|
const hosts = [
|
|
host({ id: "docs", label: "docs", hostname: "docs.example.com" }),
|
|
host({ id: "section", label: "section", hostname: "section.example.com" }),
|
|
];
|
|
|
|
for (const [href, label] of [
|
|
["/docs", "docs"],
|
|
["#section", "section"],
|
|
["./docs", "docs"],
|
|
["../docs", "docs"],
|
|
["docs/page", "docs"],
|
|
["docs?tab=install", "docs"],
|
|
["docs#install", "docs"],
|
|
] as const) {
|
|
assert.equal(
|
|
buildSshNoteLinkOpenHost(hosts, href, label, { id: "draft-id", now: 456 }),
|
|
null,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("buildSshNoteLinkOpenHost does not treat sanitized editor links as hosts", () => {
|
|
const openHost = buildSshNoteLinkOpenHost(
|
|
[host({ id: "match", hostname: "10.2.0.32", username: "root" })],
|
|
"about:blank",
|
|
"10.2.0.32",
|
|
{ id: "draft-id", now: 456 },
|
|
);
|
|
|
|
assert.equal(openHost, null);
|
|
});
|
|
|
|
test("buildSshNoteLinkOpenHost ignores unrelated external links", () => {
|
|
const openHost = buildSshNoteLinkOpenHost(
|
|
[host({ id: "match", hostname: "10.2.0.32", username: "root" })],
|
|
"https://example.com",
|
|
"Example",
|
|
{ id: "draft-id", now: 456 },
|
|
);
|
|
|
|
assert.equal(openHost, null);
|
|
});
|