Files
Benjamin Diedrichsen 77bd43818f [keyman] phase 3: derive the age recipient, and survive not having one
main.ts asserted the recipient non-null twice — extractAgePublicKey(...)!
— and the type already said null was possible. With no age.key the vault
encrypted to the string "null": execa stringifies it, age exits 1, and on
the generate path that happens *after* ssh-keygen has written a plaintext
private key into tmpDir, so the user is told the operation failed and left
with a key on disk. Now the recipient is resolved once, remembered on
success, and a null prints the remedy (age-keygen -o <path>) and returns
to the menu. list, copy and decrypt still work without one.

extractAgePublicKey now derives the public key with `age-keygen -y`
instead of scraping the `# public key:` comment. The comment is ordinary
text nothing re-checks; verified that rewriting it does not change what
-y reports, so a stale or forged comment silently encrypted the vault to
a recipient nobody holds the private half of.

The comment survives as a fallback for a machine with no age-keygen,
behind a warning that it is unverified — but not when age-keygen runs and
refuses the file. That means age cannot read the identity, and trusting
the comment there would encrypt to a recipient the vault could never
decrypt with.

runTool throws ToolNotFoundError for ENOENT so the two cases can be told
apart. Its own tests move to tool.test.ts, which keeps real processes;
utils.test.ts mocks execa, since the gate cannot require age installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:37:26 +02:00

57 lines
1.8 KiB
TypeScript

/**
* Tests for runTool.
*
* These spawn real processes rather than mocking execa. What runTool exists for
* is the shape of an execa failure — a mock would assert only what this test
* already assumes. It lives apart from utils.test.ts, which mocks execa to test
* the callers.
*/
import { describe, expect, it } from 'vitest';
import { runTool, ToolNotFoundError } from '../src/keyman.utils.js';
describe('runTool', () => {
it('returns stdout on success', async () => {
const result = await runTool('node', ['-e', 'process.stdout.write("hi")']);
expect(result.stdout).toBe('hi');
});
it('passes options through', async () => {
const result = await runTool('node', ['-e', 'process.stdout.write(process.env.PROBE ?? "")'], {
env: { PROBE: 'from-options' },
});
expect(result.stdout).toBe('from-options');
});
it('reports empty stdout when the output went elsewhere', async () => {
const result = await runTool('node', ['-e', 'process.stdout.write("hi")'], {
stdout: 'ignore',
});
expect(result.stdout).toBe('');
});
it('turns a missing binary into an instruction rather than an ENOENT', async () => {
const failure = runTool('keyman-no-such-binary', []);
await expect(failure).rejects.toThrow(ToolNotFoundError);
await expect(failure).rejects.toThrow(
'`keyman-no-such-binary` was not found on PATH. Install it and try again.'
);
});
it('surfaces what the binary wrote to stderr', async () => {
await expect(
runTool('node', ['-e', 'process.stderr.write("no recipient\\n"); process.exit(1)'])
).rejects.toThrow('`node` failed: no recipient');
});
it('falls back to the command summary when stderr is empty', async () => {
await expect(runTool('node', ['-e', 'process.exit(3)'])).rejects.toThrow(
/`node` failed: .*exit code 3/
);
});
});