Files
ansiblings/packages/keyman/tests/main.test.ts
T
Benjamin Diedrichsen 11c323b715 [keyman] phase 2: guard the directories nothing creates
encrypt read ~/.ssh and the tmp directory, and decrypt read <vault>/keys,
with no existsSync between them. main.ts created vaultRoot and tmpDir but
never keysDir, so decrypt on a fresh vault threw ENOENT instead of
printing the "no encrypted keys" message it already had — the message was
unreachable until something else created the directory.

Both functions now fall through to their warning. main.ts creates all
three directories, 0700: the vault holds the age identity and tmp holds
plaintext private keys.

age spawns go through runTool, which separates "not installed" (ENOENT,
whose message is `spawn age ENOENT`) from "age refused" (whose reason is
on stderr and nowhere in the thrown message). Tested against real
processes, not a mocked execa — the shape of the failure is the point.

list.ts kept statSync rather than switching to withFileTypes as planned:
withFileTypes reports a symlinked key directory as a link and would have
silently dropped it. `throwIfNoEntry: false` fixes the dangling-symlink
throw and keeps following the good ones. Both cases now have a test.

Also deletes the three debug logs (encrypt.ts printed both key arrays,
decrypt.ts printed every candidate path from inside a filter).

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

228 lines
6.5 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.mockReturnValue('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
);
});
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');
});
});