Files
SeedPass/js/packages/cli/test/cli.test.ts
T
thePR0M3TH3ANandClaude Opus 5 75e370d098 feat(vault): detect shared BIP-85 derivation coordinates; bind Python imports too (L-1, L-8)
L-1 — three entry kinds derive from BIP-85 app 32 at the entry's index:

    ssh@N       -> 32 bytes of m/83696968'/32'/N'
    pgp@N       -> 32 bytes of m/83696968'/32'/N'   (the SAME 32 bytes)
    password@N  -> 64 bytes of the same, then PBKDF2

So ssh@N and pgp@N are byte-for-byte the same Ed25519 private key, and
password@N takes that key as its entropy prefix. The password stays safe (256
unknown bits remain, PBKDF2 is one-way) but this is not key separation, and
SSH keys are exactly what gets exported to a server or uploaded to a forge.

Deliberately NOT fixing the derivation itself yet. Domain-separating the three
uses is a versioned protocol change in both implementations, i.e. a new
derivation path -- and the project's own note is that "a derivation bug does
not throw, it quietly produces a secret that cannot be recovered". Adding one
immediately before the first outside review inverts the point of the review.
The v2 design is written up in TODO.md instead, ready to implement after it.

What ships is the audit's short-term recommendation, which needs no derivation
change: notice the state and say so. findDerivationCollisions /
find_derivation_collisions report cross-kind clashes at one app-32 index,
worst first, in both implementations with identical message text -- a user
moving between the two must not be told different things about one vault.
Wired into every path the state can arrive by: vault import (CLI and TUI, both
implementations), nostr restore's merge, `vault import --inspect` before
anything is written, and a new `util check-derivation` for auditing an
existing vault (exit 1 when it finds something, for scripts).

Neither implementation can create the state -- index == vault id, ids unique,
watermark prevents reuse -- so this is about imported, foreign, and merged
data, which is also why it must never be the thing that fails an import: it
reports, never refuses, and tolerates malformed input.

The lead test in each implementation does not exercise the detector at all.
It derives the keys and compares the bytes, proving the property the detector
reports. Without that, the rest only checks that a written-down rule is
applied consistently, and a derivation change could falsify the rule with
everything still green.

L-8 (Python) — while wiring the above it turned out Python's import_backup had
the same unbound-plaintext-import gap the audit found in TypeScript: the
wrapper carries a `fingerprint` and nothing compared it. Now fixed there too,
with a new ProfileMismatchError so the TUI can offer the override instead of
reporting a generic failure.

The comparison is against the fingerprint DERIVED FROM THE SEED, not the
profile directory's name. In production they are the same string, but the
question worth asking is "was this backup made from my seed?", and only the
seed answers it -- comparing directory names refused a legitimate restore of
the same seed into a freshly named profile, which test_account_roundtrip_
restore caught immediately. It is enforced only when the wrapper records
something that is actually a fingerprint: a backup carrying some other label
offers no evidence about its source seed, and refusing on no evidence blocks
restores without making anyone safer.

All guards mutation-verified on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 08:58:12 -04:00

681 lines
28 KiB
TypeScript

/**
* CLI behavior tests against a fixture vault.
*
* The load-bearing assertions are the agent-blind properties from plan
* section 9.3: default output never contains secret values; `reveal` is the
* only command that prints one; sinks deliver without printing.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import {
entriesIndex,
entrySecrets,
passwordV1Cases,
passwordV2Cases,
totpCases,
mnemonics,
} from "@seedpass/test-vectors";
import { deriveIndexKeyBytes, encryptV3, generateFingerprint, utf8 } from "@seedpass/core";
import { buildProgram, type ProgramIo } from "../src/index.js";
const MNEMONIC = mnemonics["abandon12"]!;
let vaultPath: string;
interface RunResult {
stdout: string;
stderr: string;
error?: unknown;
}
async function run(...argv: string[]): Promise<RunResult> {
const out: string[] = [];
const err: string[] = [];
const io: ProgramIo = {
out: (l: string) => out.push(l),
err: (l: string) => err.push(l),
};
const program = buildProgram(io);
let error: unknown;
try {
await program.parseAsync(["node", "seedpass-js", ...argv]);
} catch (e) {
error = e;
}
return { stdout: out.join("\n"), stderr: err.join("\n"), error };
}
beforeAll(async () => {
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
// Hermetic app dir: without this, any code path that touches the DEFAULT
// profile directory silently depends on the developer's real ~/.seedpass —
// which is exactly how a "vault import --vault x needs a default profile"
// bug passed locally for months while failing in CI, where no ~/.seedpass
// exists. Tests must fail the way CI fails.
process.env["SEEDPASS_APP_DIR"] = await mkdtemp(join(tmpdir(), "seedpass-cli-appdir-"));
const dir = await mkdtemp(join(tmpdir(), "seedpass-cli-"));
vaultPath = join(dir, "vault.enc");
const key = deriveIndexKeyBytes(MNEMONIC);
const payload = await encryptV3(key, utf8(JSON.stringify(entriesIndex.entries)));
await writeFile(vaultPath, payload);
});
describe("capabilities", () => {
it("reports the agent surface", async () => {
const r = await run("capabilities");
const caps = JSON.parse(r.stdout);
expect(caps.agent_surface.reference_first_output).toBe(true);
expect(caps.protocol.entry_schema_version).toBe(4);
});
});
describe("reference-first output (agent-blind default)", () => {
it("entry list returns refs and metadata for all entries, no secret values", async () => {
const r = await run("--vault", vaultPath, "entry", "list");
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
expect(rows).toHaveLength(10);
for (const row of rows) {
expect(String(row["ref"])).toMatch(/^sp:\/\/entry\/\d+$/);
expect(row).not.toHaveProperty("secret");
expect(row).not.toHaveProperty("value");
expect(row).not.toHaveProperty("content");
}
// The imported TOTP secret must not appear anywhere in the output
expect(r.stdout).not.toContain("JBSWY3DPEHPK3PXP");
// ...but its presence is flagged
const importedTotp = rows.find((row) => row["label"] === "imported-totp")!;
expect(importedTotp["has_secret"]).toBe(true);
});
it("entry get resolves by label, id, and ref identically", async () => {
const byLabel = await run("--vault", vaultPath, "entry", "get", "example.com");
const byId = await run("--vault", vaultPath, "entry", "get", "0");
const byRef = await run("--vault", vaultPath, "entry", "get", "sp://entry/0");
expect(byLabel.stdout).toBe(byId.stdout);
expect(byId.stdout).toBe(byRef.stdout);
const row = JSON.parse(byRef.stdout);
expect(row.label).toBe("example.com");
expect(row.kind).toBe("password");
});
it("entry search matches tags and never leaks values", async () => {
const r = await run("--vault", vaultPath, "entry", "search", "api");
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
expect(rows.map((x) => x["label"])).toEqual(["api-token"]);
expect(r.stdout).not.toContain("abc123");
});
});
describe("plaintext egress is explicit and correct", () => {
it("reveal derives the same password as the Python fixture", async () => {
// Vault entry 0: example.com, length 16, gen_version 2, derivation index 0
const expected = passwordV2Cases.find(
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
)!.password;
const r = await run("--vault", vaultPath, "entry", "reveal", "example.com");
expect(r.stdout).toBe(expected);
});
it("reveal computes deterministic TOTP codes at a pinned time", async () => {
const fixture = totpCases.find((c) => c.mnemonic_id === "abandon12" && c.index === 0)!;
const r = await run(
"--vault", vaultPath, "entry", "reveal", "example-totp", "--at", "1700000000",
);
expect(r.stdout).toBe(fixture.codes_at["1700000000"]);
});
it("reveal prints an imported key_value secret", async () => {
const r = await run("--vault", vaultPath, "entry", "reveal", "api-token");
expect(r.stdout).toBe("abc123");
});
it("reveal matches every Python-computed entry secret", async () => {
expect((await run("--vault", vaultPath, "entry", "reveal", "example.com")).stdout).toBe(
entrySecrets.password_entry_0,
);
expect(
(await run("--vault", vaultPath, "entry", "reveal", "example-totp", "--at", "1700000000"))
.stdout,
).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
// Nostr entries use BIP-85 app 39 (not the sync client's app 1237)
expect((await run("--vault", vaultPath, "entry", "reveal", "example-nostr")).stdout).toBe(
entrySecrets.nostr_entry_4.nsec,
);
expect((await run("--vault", vaultPath, "entry", "reveal", "example-seed")).stdout).toBe(
entrySecrets.seed_entry_7_mnemonic,
);
expect((await run("--vault", vaultPath, "entry", "reveal", "example-managed")).stdout).toBe(
entrySecrets.managed_entry_8_mnemonic,
);
});
});
describe("provisioning is agent-blind", () => {
let writablePath: string;
beforeAll(async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-provision-"));
writablePath = join(dir, "vault.enc");
await writeFile(writablePath, await readFile(vaultPath));
});
it("entry add password returns a reference, never the derived password", async () => {
const r = await run(
"--vault", writablePath, "entry", "add", "password", "new-site.example",
"--length", "16", "--username", "bob",
);
const row = JSON.parse(r.stdout);
expect(row.ref).toBe("sp://entry/10");
expect(row.kind).toBe("password");
expect(row.gen_version).toBe(2);
// The derived password exists and reveals correctly, but appeared
// nowhere in the provisioning output.
const reveal = await run("--vault", writablePath, "entry", "reveal", "sp://entry/10");
expect(reveal.stdout).toHaveLength(16);
expect(r.stdout).not.toContain(reveal.stdout);
});
it("entry add totp --secret stores but never echoes the imported secret", async () => {
const secret = "GEZDGNBVGY3TQOJQ";
const r = await run(
"--vault", writablePath, "entry", "add", "totp", "imported-2", "--secret", secret,
);
const row = JSON.parse(r.stdout);
expect(row.has_secret).toBe(true);
expect(r.stdout).not.toContain(secret);
});
it("entry add key-value persists across reopen", async () => {
await run(
"--vault", writablePath, "entry", "add", "key-value", "deploy-token", "token", "s3cr3t-value",
);
const list = await run("--vault", writablePath, "entry", "list");
const rows = JSON.parse(list.stdout) as { label: string }[];
expect(rows.map((x) => x.label)).toContain("deploy-token");
expect(list.stdout).not.toContain("s3cr3t-value");
const reveal = await run("--vault", writablePath, "entry", "reveal", "deploy-token");
expect(reveal.stdout).toBe("s3cr3t-value");
});
it("entry add managed-account records the child fingerprint only", async () => {
const r = await run("--vault", writablePath, "entry", "add", "managed-account", "child-acct");
const row = JSON.parse(r.stdout);
expect(row.fingerprint).toMatch(/^[0-9A-F]{16}$/);
expect(row.word_count).toBe(12);
// The child mnemonic is derivable but was never printed
const reveal = await run("--vault", writablePath, "entry", "reveal", "child-acct");
expect(reveal.stdout.split(" ")).toHaveLength(12);
expect(r.stdout).not.toContain(reveal.stdout);
});
});
describe("sink delivery keeps secrets out of CLI output", () => {
it("use --exec injects SEEDPASS_SECRET into the child env only", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-"));
const capture = join(dir, "captured.txt");
const script = join(dir, "cap.sh");
await writeFile(script, `#!/bin/sh\nprintf '%s' "$SEEDPASS_SECRET" > "${capture}"\n`, {
mode: 0o755,
});
const r = await run("--vault", vaultPath, "use", "api-token", "--exec", script);
const delivered = JSON.parse(r.stdout);
expect(delivered.sink).toBe("exec");
expect(delivered.exitCode).toBe(0);
expect(delivered.ref).toBe("sp://entry/5");
// The secret reached the child...
expect(await readFile(capture, "utf8")).toBe("abc123");
// ...and never appeared in the CLI's own output
expect(r.stdout).not.toContain("abc123");
expect(r.stderr).not.toContain("abc123");
});
it("use --stdin-to pipes the secret to the command's stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-"));
const capture = join(dir, "captured.txt");
const script = join(dir, "cap.sh");
await writeFile(script, `#!/bin/sh\ncat > "${capture}"\n`, { mode: 0o755 });
const r = await run("--vault", vaultPath, "use", "api-token", "--stdin-to", script);
expect(JSON.parse(r.stdout).sink).toBe("stdin");
expect(await readFile(capture, "utf8")).toBe("abc123");
expect(r.stdout).not.toContain("abc123");
});
it("accepts a quoted command spec so flags survive option parsing", async () => {
// Regression: commander's variadic options stop at the next "-token",
// so `--stdin-to wc -c` lost the flag. A quoted spec is the documented
// way to include flags.
const dir = await mkdtemp(join(tmpdir(), "seedpass-spec-"));
const capture = join(dir, "captured.txt");
const script = join(dir, "cap.sh");
await writeFile(script, `#!/bin/sh\nprintf '%s %s' "$1" "$(cat)" > "${capture}"\n`, {
mode: 0o755,
});
const r = await run(
"--vault", vaultPath, "use", "api-token", "--stdin-to", `${script} -flagged`,
);
expect(JSON.parse(r.stdout).sink).toBe("stdin");
expect(await readFile(capture, "utf8")).toBe("-flagged abc123");
});
it("use refuses ambiguous sink selection", async () => {
const r = await run(
"--vault", vaultPath, "use", "api-token", "--clipboard", "--stdin-to", "cat",
);
expect(String((r.error as Error).message)).toContain("exactly one sink");
});
});
describe("modification commands", () => {
let modPath: string;
beforeAll(async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-mod-"));
modPath = join(dir, "vault.enc");
await writeFile(modPath, await readFile(vaultPath));
});
it("entry modify updates fields and bumps timestamps", async () => {
const before = JSON.parse(
(await run("--vault", modPath, "entry", "get", "example.com")).stdout,
);
const r = await run(
"--vault", modPath, "entry", "modify", "example.com",
"--username", "carol", "--tags", "web", "staging",
);
const row = JSON.parse(r.stdout);
expect(row.username).toBe("carol");
expect(row.tags).toEqual(["web", "staging"]);
expect(row.modified_ts).toBeGreaterThan(before.modified_ts);
});
it("entry modify rejects fields the kind does not allow", async () => {
const r = await run(
"--vault", modPath, "entry", "modify", "example-totp", "--username", "x",
);
expect(String((r.error as Error).message)).toContain("does not support fields");
});
it("archive hides from totp-codes; unarchive restores", async () => {
await run("--vault", modPath, "entry", "archive", "example-totp");
const archived = JSON.parse(
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
) as { label: string }[];
expect(archived.map((x) => x.label)).not.toContain("example-totp");
await run("--vault", modPath, "entry", "unarchive", "example-totp");
const restored = JSON.parse(
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
) as { label: string; code: string }[];
const det = restored.find((x) => x.label === "example-totp")!;
expect(det.code).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
});
it("link-add/links/link-remove round-trip with resolved targets", async () => {
await run(
"--vault", modPath, "entry", "link-add", "example.com", "example-totp",
"--relation", "totp", "--note", "2fa",
);
const links = JSON.parse(
(await run("--vault", modPath, "entry", "links", "example.com")).stdout,
);
expect(links).toEqual([
{ target_id: 1, relation: "totp", note: "2fa", target_label: "example-totp", target_kind: "totp" },
]);
const after = await run(
"--vault", modPath, "entry", "link-remove", "example.com", "example-totp",
);
expect(JSON.parse(after.stdout).links).toEqual([]);
});
});
describe("util generate-password", () => {
it("matches the v1 fixture at default index/version", async () => {
const expected = passwordV1Cases.find(
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
)!.password;
const r = await run("util", "generate-password", "--length", "16");
expect(r.stdout).toBe(expected);
});
it("matches v2 and policy fixtures", async () => {
const v2 = passwordV2Cases.find(
(c) => c.policy === "safe_special" && c.length === 40 && c.index === 2,
)!.password;
const r = await run(
"util", "generate-password", "--length", "40", "--index", "2",
"--gen-version", "2", "--special-mode", "safe",
);
expect(r.stdout).toBe(v2);
});
});
describe("vault export/import", () => {
it("exports an encrypted portable backup and inspects it without writing", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-export-"));
const dest = join(dir, "backup.json");
const exportResult = await run("--vault", vaultPath, "vault", "export", dest);
expect(JSON.parse(exportResult.stdout).encrypted).toBe(true);
const importResult = await run("vault", "import", dest, "--inspect");
const summary = JSON.parse(importResult.stdout);
expect(summary.schema_version).toBe(4);
expect(summary.entry_count).toBe(10);
expect(summary.written).toBe(false);
});
it("import actually restores into a vault and guards non-empty targets", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-import-"));
const backup = join(dir, "backup.json");
await run("--vault", vaultPath, "vault", "export", backup);
// Fresh empty vault: import writes without --yes
const target = join(dir, "target.enc");
await writeFile(
target,
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify({ schema_version: 4, entries: {} }))),
);
const first = await run("--vault", target, "vault", "import", backup);
expect(JSON.parse(first.stdout).entry_count).toBe(10);
const listed = JSON.parse((await run("--vault", target, "entry", "list")).stdout);
expect(listed).toHaveLength(10);
// Now non-empty: refuses without --yes, proceeds with it
const guarded = await run("--vault", target, "vault", "import", backup);
expect(String((guarded.error as Error).message)).toContain("--yes");
const forced = await run("--vault", target, "vault", "import", backup, "--yes");
expect(JSON.parse(forced.stdout).entry_count).toBe(10);
});
/**
* A plaintext backup has no cryptographic binding to the seed it came from
* — the wrong profile can read it perfectly well. Importing one re-derives
* every password, SSH key, PGP key and seed from the TARGET profile's seed,
* so the user ends up with a vault full of entries whose secrets differ
* from the ones the backup was taken to preserve. Nothing errors on the way
* through, which is what makes it worth refusing.
*/
it("refuses a plaintext backup from a different seed unless told otherwise", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-foreign-"));
const foreignMnemonic = mnemonics["legal12"]!;
const saved = process.env["SEEDPASS_MNEMONIC"];
// A genuine backup of a genuinely different profile.
const foreignVault = join(dir, "foreign.enc");
await writeFile(
foreignVault,
await encryptV3(
deriveIndexKeyBytes(foreignMnemonic),
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
),
);
const backup = join(dir, "foreign-backup.json");
process.env["SEEDPASS_MNEMONIC"] = foreignMnemonic;
const exported = await run(
"--vault", foreignVault, "vault", "export", backup, "--plaintext",
);
expect(JSON.parse(exported.stdout).encrypted).toBe(false);
process.env["SEEDPASS_MNEMONIC"] = saved!;
const target = join(dir, "target.enc");
await writeFile(
target,
await encryptV3(
deriveIndexKeyBytes(MNEMONIC),
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
),
);
const refused = await run("--vault", target, "vault", "import", backup);
const message = String((refused.error as Error).message);
expect(message).toContain("belongs to profile");
expect(message).toContain("--allow-fingerprint-mismatch");
// Still possible on purpose — the point is that it cannot happen by
// accident, not that it is forbidden.
const allowed = await run(
"--vault", target, "vault", "import", backup, "--allow-fingerprint-mismatch",
);
expect(allowed.error).toBeUndefined();
expect(JSON.parse(allowed.stdout).entry_count).toBe(0);
});
it("names entries that share a BIP-85 derivation index on import", async () => {
// ssh@N and pgp@N are the same Ed25519 key. Neither implementation can
// create that state, so an import is one of the ways it arrives — and the
// user needs telling, because the two entries look independent.
const dir = await mkdtemp(join(tmpdir(), "seedpass-collide-"));
const source = join(dir, "source.enc");
await writeFile(
source,
await encryptV3(
deriveIndexKeyBytes(MNEMONIC),
utf8(
JSON.stringify({
schema_version: 4,
entries: {
"3": { kind: "ssh", type: "ssh", label: "deploy", index: 3, notes: "", tags: [], archived: false, modified_ts: 1700000000 },
"4": { kind: "pgp", type: "pgp", label: "sign", index: 3, notes: "", tags: [], archived: false, modified_ts: 1700000000 },
},
}),
),
),
);
const backup = join(dir, "collide.json");
await run("--vault", source, "vault", "export", backup);
const inspected = JSON.parse(
(await run("--vault", source, "vault", "import", backup, "--inspect")).stdout,
);
expect(inspected.derivation_collisions).toHaveLength(1);
expect(inspected.derivation_collisions[0].severity).toBe("identical-key");
expect(inspected.derivation_collisions[0].index).toBe(3);
// And the dedicated check reports it on an existing vault, with a
// non-zero exit status for scripts.
const checked = JSON.parse(
(await run("--vault", source, "util", "check-derivation")).stdout,
);
expect(checked.collisions).toHaveLength(1);
expect(checked.collisions[0].message).toContain("SAME private key");
});
it("stays silent about derivation on a normally created vault", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-nocollide-"));
const dest = join(dir, "backup.json");
await run("--vault", vaultPath, "vault", "export", dest);
const inspected = JSON.parse(
(await run("vault", "import", dest, "--inspect")).stdout,
);
expect(inspected.derivation_collisions).toEqual([]);
const checked = JSON.parse(
(await run("--vault", vaultPath, "util", "check-derivation")).stdout,
);
expect(checked.collisions).toEqual([]);
});
it("reports the backup's own fingerprint and mode when inspecting", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-inspect-"));
const dest = join(dir, "backup.json");
await run("--vault", vaultPath, "vault", "export", dest);
const summary = JSON.parse((await run("vault", "import", dest, "--inspect")).stdout);
expect(summary.encryption_mode).toBe("seed-only");
expect(summary.fingerprint).toBe(generateFingerprint(MNEMONIC));
});
});
describe("document import/export", () => {
it("imports a file and exports it back, matching Python's naming rules", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-doc-"));
const src = join(dir, "meeting notes.md");
const body = "# Notes\n\nrecovery phrase is NOT here\n";
await writeFile(src, body);
const vaultCopy = join(dir, "vault.enc");
await writeFile(vaultCopy, await readFile(vaultPath));
const imported = JSON.parse(
(await run("--vault", vaultCopy, "entry", "import-document", src)).stdout,
);
expect(imported.kind).toBe("document");
expect(imported.label).toBe("meeting notes");
expect(imported.file_type).toBe("md");
// The body is a secret: it must not appear in provisioning output
expect(imported).not.toHaveProperty("content");
const outDir = join(dir, "out");
const exported = JSON.parse(
(await run(
"--vault", vaultCopy, "entry", "export-document", "meeting notes", "--out", outDir,
)).stdout,
);
// Spaces collapse to "_" exactly as Python sanitizes them
expect(exported.exported).toBe(join(outDir, "meeting_notes.md"));
expect(await readFile(exported.exported, "utf8")).toBe(body);
const again = await run(
"--vault", vaultCopy, "entry", "export-document", "meeting notes", "--out", outDir,
);
expect(String((again.error as Error).message)).toContain("already exists");
const forced = await run(
"--vault", vaultCopy, "entry", "export-document", "meeting notes",
"--out", outDir, "--overwrite",
);
expect(JSON.parse(forced.stdout).exported).toBe(join(outDir, "meeting_notes.md"));
});
it("refuses to export a non-document entry", async () => {
const r = await run("--vault", vaultPath, "entry", "export-document", "example.com");
expect(String((r.error as Error).message)).toContain("not a document entry");
});
});
describe("exit behaviour", () => {
it("prints help without reporting it as an error when stdout is not a terminal", async () => {
// Bare invocation launches the interactive mode on a terminal, but there
// is no terminal to drive here, so it falls back to the help. Two bugs
// met on this path: exitOverride() makes commander throw when it prints
// help, and the handler reported that throw as "error: (outputHelp)".
const out: string[] = [];
const err: string[] = [];
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
const previous = process.exitCode;
let thrown: unknown;
try {
await buildProgram(io).parseAsync(["node", "seedpass-js"]);
} catch (e) {
thrown = e;
}
const printed = [...out, ...err].join("\n");
expect(thrown).toBeUndefined();
expect(printed).toContain("Usage: seedpass-js");
expect(printed).not.toContain("outputHelp");
expect(process.exitCode).toBe(1);
process.exitCode = previous;
});
});
describe("error messages", () => {
it("explains a corrupt or foreign vault file instead of leaking a codec error", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-corrupt-"));
const broken = join(dir, "broken.enc");
await writeFile(broken, "not a seedpass vault");
const r = await run("--vault", broken, "entry", "list");
const msg = String((r.error as Error).message);
expect(msg).toContain("could not decrypt vault");
expect(msg).toContain("different seed");
});
});
describe("unknown entry kinds at the CLI surface", () => {
let foreignVault: string;
const FOREIGN = {
kind: "bitlogin_org",
type: "bitlogin_org",
label: "Acme Corporation",
modified_ts: 1700000123,
bitlogin: { admins: ["npub1aaaa"], api_token: "sk-live-visible-if-leaked" },
};
beforeAll(async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-cli-foreign-"));
foreignVault = join(dir, "vault.enc");
const key = deriveIndexKeyBytes(MNEMONIC);
const index = {
schema_version: 4,
entries: {
"0": {
type: "key_value", kind: "key_value", label: "api", key: "k", value: "v",
archived: false, notes: "", tags: [], links: [],
},
"1": FOREIGN,
},
};
await writeFile(foreignVault, await encryptV3(key, utf8(JSON.stringify(index))));
});
it("lists a vault containing a foreign record, values redacted", async () => {
const r = await run("--vault", foreignVault, "entry", "list");
expect(r.error).toBeUndefined();
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
const foreign = rows.find((row) => row["kind"] === "bitlogin_org")!;
expect(foreign).toBeDefined();
expect(foreign["label"]).toBe("Acme Corporation");
// Fields this build does not recognize are reported as present, never
// dumped: another application's data is not ours to hand out.
expect(foreign["has_bitlogin"]).toBe(true);
expect(r.stdout).not.toContain("sk-live-visible-if-leaked");
expect(r.stdout).not.toContain("npub1aaaa");
});
it("refuses to reveal a foreign record instead of guessing", async () => {
const r = await run("--vault", foreignVault, "entry", "reveal", "sp://entry/1");
expect(String((r.error as Error)?.message ?? r.stderr)).toMatch(/unsupported entry kind/);
expect(r.stdout).not.toContain("sk-live");
});
it("mutating a neighbour entry does not disturb the foreign record", async () => {
const r = await run("--vault", foreignVault, "entry", "add", "password", "new-site", "--length", "20");
expect(r.error).toBeUndefined();
const list = await run("--vault", foreignVault, "entry", "list");
const rows = JSON.parse(list.stdout) as Record<string, unknown>[];
// Allocation skipped past the foreign record's id.
expect(rows.map((row) => row["id"])).toContain("2");
expect(rows.find((row) => row["kind"] === "bitlogin_org")).toBeDefined();
});
});
describe(".seedpass export naming", () => {
it("a directory target gets the generated seedpass-<fp>-<date>.seedpass name", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const r = await run("--vault", vaultPath, "vault", "export", dir);
expect(r.error).toBeUndefined();
const dest = String(JSON.parse(r.stdout).exported);
expect(dest.startsWith(dir)).toBe(true);
expect(dest).toMatch(/seedpass-[0-9A-F]{16}-\d{8}\.seedpass$/);
// And the file restores like any other backup: same wrapper shape.
const wrapper = JSON.parse(await readFile(dest, "utf8"));
expect(wrapper.format_version).toBe(1);
});
it("an explicit file path is used exactly as typed (scripts depend on it)", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const exact = join(dir, "my-backup.json");
const r = await run("--vault", vaultPath, "vault", "export", exact);
expect(String(JSON.parse(r.stdout).exported)).toBe(exact);
});
it("an extensionless explicit path is also used exactly as typed", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const bare = join(dir, "backup");
const r = await run("--vault", vaultPath, "vault", "export", bare);
expect(String(JSON.parse(r.stdout).exported)).toBe(bare);
});
});