[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>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user