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
177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { importVaultHostsFromText } from "../vaultImport.ts";
|
|
import { exportHostsToCsvWithStats } from "./csvExport.ts";
|
|
import type { Host } from "../models.ts";
|
|
|
|
test("CSV exports include a UTF-8 BOM and preserve Chinese text when imported again", () => {
|
|
const host: Host = {
|
|
id: "host-1",
|
|
label: "中文服务器",
|
|
hostname: "10.0.0.1",
|
|
username: "root",
|
|
port: 22,
|
|
};
|
|
|
|
const { csv } = exportHostsToCsvWithStats([host]);
|
|
|
|
assert.equal(csv.charCodeAt(0), 0xfeff);
|
|
assert.deepEqual([...new TextEncoder().encode(csv).slice(0, 3)], [0xef, 0xbb, 0xbf]);
|
|
|
|
const imported = importVaultHostsFromText("csv", csv);
|
|
assert.equal(imported.hosts[0]?.label, host.label);
|
|
assert.equal(imported.hosts[0]?.hostname, host.hostname);
|
|
});
|
|
|
|
test("CSV round-trips local key authentication and its saved passphrase", () => {
|
|
const host: Host = {
|
|
id: "host-key",
|
|
label: "Key host",
|
|
hostname: "key.example.com",
|
|
username: "ubuntu",
|
|
port: 22,
|
|
identityFilePaths: ["~/.ssh/id_ed25519"],
|
|
authMethod: "key",
|
|
};
|
|
|
|
const { csv } = exportHostsToCsvWithStats([host], {
|
|
keyPassphrases: new Map([["~/.ssh/id_ed25519", "+secret"]]),
|
|
});
|
|
assert.equal(csv.includes(",+secret"), false);
|
|
const imported = importVaultHostsFromText("csv", csv);
|
|
|
|
assert.deepEqual(imported.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
|
|
assert.equal(imported.hosts[0]?.authMethod, "key");
|
|
assert.equal(imported.hosts[0]?.password, undefined);
|
|
assert.deepEqual(imported.keyPassphrases, [{
|
|
hostId: imported.hosts[0]?.id,
|
|
keyPath: "~/.ssh/id_ed25519",
|
|
passphrase: "+secret",
|
|
}]);
|
|
});
|
|
|
|
test("CSV round-trips a referenced Keychain file path and saved passphrase", () => {
|
|
const host: Host = {
|
|
id: "host-reference-key",
|
|
label: "Reference key host",
|
|
hostname: "reference.example.com",
|
|
username: "ubuntu",
|
|
port: 22,
|
|
identityFileId: "key-reference",
|
|
identityFilePaths: ["/Users/alice/.ssh/stale"],
|
|
authMethod: "key",
|
|
};
|
|
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
|
|
|
const { csv } = exportHostsToCsvWithStats([host], {
|
|
keyPathsById: new Map([["key-reference", keyPath]]),
|
|
keyPassphrasesById: new Map([["key-reference", "reference-secret"]]),
|
|
keyPassphrases: new Map([[keyPath, "stale-side-store-secret"]]),
|
|
});
|
|
const imported = importVaultHostsFromText("csv", csv);
|
|
|
|
assert.deepEqual(imported.hosts[0]?.identityFilePaths, [keyPath]);
|
|
assert.deepEqual(imported.keyPassphrases, [{
|
|
hostId: imported.hosts[0]?.id,
|
|
keyPath,
|
|
passphrase: "reference-secret",
|
|
}]);
|
|
});
|
|
|
|
test("CSV never falls back to path storage for a referenced key", () => {
|
|
const host: Host = {
|
|
id: "host-reference-key",
|
|
label: "Reference key host",
|
|
hostname: "reference.example.com",
|
|
username: "ubuntu",
|
|
port: 22,
|
|
identityFileId: "key-reference",
|
|
authMethod: "key",
|
|
};
|
|
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
|
|
|
const { csv } = exportHostsToCsvWithStats([host], {
|
|
keyPathsById: new Map([["key-reference", keyPath]]),
|
|
keyPassphrases: new Map([[keyPath, "stale-side-store-secret"]]),
|
|
});
|
|
const imported = importVaultHostsFromText("csv", csv);
|
|
|
|
assert.deepEqual(imported.hosts[0]?.identityFilePaths, [keyPath]);
|
|
assert.deepEqual(imported.keyPassphrases, []);
|
|
assert.equal(csv.includes("stale-side-store-secret"), false);
|
|
});
|
|
|
|
test("CSV reversibly guards key paths that spreadsheets treat as formulas", () => {
|
|
const hosts: Host[] = [
|
|
"-relative-key",
|
|
"'-literal-key",
|
|
"__netcatty_csv_keypath_v1__:literal",
|
|
].map((keyPath, index) => ({
|
|
id: `host-${index}`,
|
|
label: `Host ${index}`,
|
|
hostname: `host-${index}.example.com`,
|
|
username: "root",
|
|
port: 22,
|
|
identityFilePaths: [keyPath],
|
|
authMethod: "key",
|
|
}));
|
|
|
|
const { csv } = exportHostsToCsvWithStats(hosts);
|
|
const imported = importVaultHostsFromText("csv", csv);
|
|
|
|
assert.deepEqual(imported.hosts.map((host) => host.identityFilePaths?.[0]), [
|
|
"-relative-key",
|
|
"'-literal-key",
|
|
"__netcatty_csv_keypath_v1__:literal",
|
|
]);
|
|
});
|
|
|
|
test("CSV export never writes credentials from skipped serial hosts", () => {
|
|
const serialHost: Host = {
|
|
id: "serial-with-stale-key",
|
|
label: "Serial with stale key",
|
|
hostname: "ttyUSB0",
|
|
protocol: "serial",
|
|
port: 22,
|
|
identityFilePaths: ["~/.ssh/id_stale"],
|
|
authMethod: "key",
|
|
};
|
|
const sshHost: Host = {
|
|
id: "ssh-host",
|
|
label: "SSH host",
|
|
hostname: "ssh.example.com",
|
|
protocol: "ssh",
|
|
port: 22,
|
|
};
|
|
|
|
const result = exportHostsToCsvWithStats([serialHost, sshHost], {
|
|
keyPassphrases: new Map([["~/.ssh/id_stale", "must-not-leak"]]),
|
|
});
|
|
|
|
assert.equal(result.exportedCount, 1);
|
|
assert.equal(result.skippedCount, 1);
|
|
assert.equal(result.csv.includes(serialHost.label), false);
|
|
assert.equal(result.csv.includes("id_stale"), false);
|
|
assert.equal(result.csv.includes("must-not-leak"), false);
|
|
assert.equal(result.csv.includes(sshHost.hostname), true);
|
|
});
|
|
|
|
test("CSV export skips plugin hosts instead of discarding opaque provider configuration", () => {
|
|
const pluginHost: Host = {
|
|
id: "plugin-host",
|
|
label: "Plugin host",
|
|
hostname: "com.example.transport.connection",
|
|
username: "alice",
|
|
protocol: "plugin:com.example.transport.connection",
|
|
pluginConnection: {
|
|
providerId: "com.example.transport.connection",
|
|
configuration: { endpoint: "opaque://target" },
|
|
},
|
|
};
|
|
const result = exportHostsToCsvWithStats([pluginHost]);
|
|
assert.equal(result.exportedCount, 0);
|
|
assert.equal(result.skippedCount, 1);
|
|
assert.equal(result.csv.includes("opaque://target"), false);
|
|
});
|