Files
ansiblings/packages/keyman/tests/main.test.ts
T
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

277 lines
8.0 KiB
TypeScript

/**
* Tests for the keyman() menu loop.
*
* Every operation it dispatches to has its own suite, so they are all mocked
* here: what is under test is path resolution, dispatch and the loop itself.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
prompt,
loadConfig,
resolveConfigPaths,
listKeys,
copyKey,
generateKey,
encryptKeys,
decryptKeys,
extractAgePublicKey,
} = vi.hoisted(() => ({
prompt: vi.fn(),
loadConfig: vi.fn(),
resolveConfigPaths: vi.fn(),
listKeys: vi.fn(),
copyKey: vi.fn(),
generateKey: vi.fn(),
encryptKeys: vi.fn(),
decryptKeys: vi.fn(),
extractAgePublicKey: vi.fn(),
}));
vi.mock('inquirer', () => ({ default: { prompt } }));
vi.mock('../src/keyman.config.js', () => ({ loadConfig, resolveConfigPaths }));
vi.mock('../src/keyman.list.js', () => ({ listKeys }));
vi.mock('../src/keyman.copy.js', () => ({ copyKey }));
vi.mock('../src/keyman.generate.js', () => ({ generateKey }));
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
import { keyman } from '../src/keyman.main.js';
describe('keyman', () => {
let root: string;
let paths: { vaultRoot: string; keysDir: string; tmpDir: string; keyPath: string };
let originalHome: string | undefined;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
/** Answers the leading `user` prompt, then walks the given menu choices. */
const menu = (categories: string[], user = '@current') => {
const queue = [...categories, 'quit'];
prompt.mockImplementation(async (questions: { name: string }[]) => {
const { name } = questions[0];
if (name === 'user') return { user };
return { category: queue.shift() };
});
};
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
beforeEach(() => {
vi.clearAllMocks();
originalHome = process.env.HOME;
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-main-')));
process.env.HOME = path.join(root, 'home');
paths = {
vaultRoot: path.join(root, 'vault'),
keysDir: path.join(root, 'vault', 'keys'),
tmpDir: path.join(root, 'vault', 'tmp'),
keyPath: path.join(root, 'vault', 'age.key'),
};
loadConfig.mockReturnValue({
vaultRoot: 'vault',
keysDir: 'keys',
tmpDir: 'tmp',
ageKeyFile: 'age.key',
});
resolveConfigPaths.mockReturnValue(paths);
extractAgePublicKey.mockResolvedValue('age1recipient');
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
menu([]);
});
afterEach(() => {
vi.restoreAllMocks();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
fs.rmSync(root, { recursive: true, force: true });
});
it('prints the resolved paths and creates the vault directories', async () => {
await keyman();
expect(output()).toContain(paths.vaultRoot);
expect(output()).toContain(paths.keysDir);
expect(output()).toContain(paths.keyPath);
expect(fs.existsSync(paths.vaultRoot)).toBe(true);
expect(fs.existsSync(paths.tmpDir)).toBe(true);
// keysDir too: decrypt reads it, and nothing created it before the first
// encrypt, so a fresh vault could not be decrypted from.
expect(fs.existsSync(paths.keysDir)).toBe(true);
});
it('creates the vault directories private to the owner', async () => {
await keyman();
for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) {
expect(fs.statSync(dir).mode & 0o777, dir).toBe(0o700);
}
});
it('quits without running any operation', async () => {
await keyman();
expect(output()).toContain('Goodbye!');
expect(listKeys).not.toHaveBeenCalled();
});
it('offers every operation in the menu', async () => {
await keyman();
const menuQuestion = prompt.mock.calls.at(-1)?.[0][0] as { choices: { value: string }[] };
expect(menuQuestion.choices.map((c) => c.value)).toEqual([
'list',
'copy',
'generate',
'encrypt',
'decrypt',
'quit',
]);
});
it('lists keys against the .ssh directory of the current user', async () => {
menu(['list']);
await keyman();
expect(listKeys).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'),
paths.keysDir,
paths.tmpDir
);
});
it('copies a public key', async () => {
menu(['copy']);
await keyman();
expect(copyKey).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'),
paths.tmpDir
);
});
it('generates a key with the age recipient from the key file', async () => {
menu(['generate']);
await keyman();
expect(extractAgePublicKey).toHaveBeenCalledWith(paths.keyPath);
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
});
it('encrypts keys into the vault root', async () => {
menu(['encrypt']);
await keyman();
expect(encryptKeys).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'),
paths.vaultRoot,
paths.tmpDir,
'age1recipient'
);
});
it('decrypts keys using the age identity file', async () => {
menu(['decrypt']);
await keyman();
expect(decryptKeys).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'),
paths.vaultRoot,
paths.keyPath
);
});
describe('without an age recipient', () => {
beforeEach(() => {
extractAgePublicKey.mockResolvedValue(null);
});
it.each([
['generate', generateKey],
['encrypt', encryptKeys],
])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => {
menu([choice]);
await keyman();
expect(operation).not.toHaveBeenCalled();
const reported = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(reported).toContain(`age-keygen -o ${paths.keyPath}`);
// The whole point: the loop survives and quit is still reached.
expect(output()).toContain('Goodbye!');
});
it('still allows the operations that need no recipient', async () => {
menu(['list', 'decrypt']);
await keyman();
expect(listKeys).toHaveBeenCalled();
expect(decryptKeys).toHaveBeenCalled();
});
it('retries the lookup, so creating the identity mid-session works', async () => {
extractAgePublicKey.mockResolvedValueOnce(null).mockResolvedValueOnce('age1later');
menu(['generate', 'generate']);
await keyman();
expect(extractAgePublicKey).toHaveBeenCalledTimes(2);
expect(generateKey).toHaveBeenCalledTimes(1);
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1later');
});
});
it('resolves the recipient once for repeated operations', async () => {
menu(['generate', 'encrypt']);
await keyman();
expect(extractAgePublicKey).toHaveBeenCalledTimes(1);
});
it('keeps showing the menu until the user quits', async () => {
menu(['list', 'copy', 'list']);
await keyman();
expect(listKeys).toHaveBeenCalledTimes(2);
expect(copyKey).toHaveBeenCalledTimes(1);
});
it('targets another user home directory when a user is named', async () => {
menu(['list'], 'deploy');
await keyman();
expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir);
});
it('aborts when the home directory cannot be determined', async () => {
delete process.env.HOME;
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
await expect(keyman()).rejects.toThrow('process.exit');
expect(exit).toHaveBeenCalledWith(1);
expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory');
});
});