Files
Benjamin Diedrichsen da9df57e11 [keyman] thread the configured keys and tmp directories through encrypt/decrypt
encryptKeys and decryptKeys each took `vaultDir` and rebuilt `<vault>/keys`
and `<vault>/tmp` from it, so `keysDir` and `tmpDir` in .keymanrc.json were
honoured by main and list and silently ignored by the two operations that
write. main was also passing vaultRoot where encrypt expected the keys
directory, which put encrypted keys one level above where list looks for
them: with any config at all, a key encrypted a second ago was invisible.

Both now take keysDir and tmpDir explicitly. The decrypt location prompt
names the real directories instead of the hardcoded `vault/tmp` and
`~/.ssh`, which meant its labels were also its values — hence LOCAL_MODE.

tests/vault-layout.test.ts is the regression: encrypt then list, driven
through keyman() with only age and the prompts mocked, against a config
using keysDir `encrypted` and tmpDir `plain`. Every unit suite passed
through this bug because each was told which directory to use; the seam
between them was untested. Verified it fails when main is reverted to pass
vaultRoot.
2026-07-30 14:45:27 +02:00

128 lines
4.4 KiB
TypeScript

/**
* End-to-end over the configured vault layout.
*
* Everything except `age` and the prompts is real here — the config loader, the
* path resolution, encrypt and list all run — because the bug this covers lived
* in the seam between them: encrypt wrote to `vaultRoot` while list read from
* `keysDir`, so with the defaults (`keysDir: 'keys'`) an encrypted key was
* invisible to the very next listing. Every unit suite passed throughout, since
* each was told which directory to use.
*
* Non-default names on purpose: `keys`/`tmp` would also pass against a function
* that ignored the config entirely.
*/
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 { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
vi.mock('execa', () => ({ execa }));
vi.mock('inquirer', () => ({ default: { prompt } }));
import { keyman } from '../src/keyman.main.js';
describe('the configured vault layout', () => {
let root: string;
let project: string;
let home: string;
let sshDir: string;
let vaultRoot: string;
let cwd: string;
let env: NodeJS.ProcessEnv;
let logSpy: ReturnType<typeof vi.spyOn>;
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
/** The one line of the listing table describing `name`. */
const listingRow = (name: string) =>
output()
.split('\n')
.find((line) => line.includes(name) && line.includes('['));
beforeEach(() => {
vi.clearAllMocks();
cwd = process.cwd();
env = { ...process.env };
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-layout-')));
project = path.join(root, 'project');
home = path.join(root, 'home');
sshDir = path.join(home, '.ssh');
vaultRoot = path.join(project, 'vault');
fs.mkdirSync(project, { recursive: true });
fs.mkdirSync(sshDir, { recursive: true });
fs.writeFileSync(path.join(sshDir, 'id_prod'), 'PRIVATE');
fs.writeFileSync(path.join(sshDir, 'id_prod.pub'), 'PUBLIC');
fs.writeFileSync(
path.join(project, '.keymanrc.json'),
JSON.stringify({ vaultRoot: 'vault', keysDir: 'encrypted', tmpDir: 'plain' })
);
// HOME also redirects os.homedir(), so the real ~/.keymanrc.json cannot
// reach the loader and make this test depend on the machine it runs on.
process.env.HOME = home;
delete process.env.VAULT_ROOT;
process.chdir(project);
// The age identity has to exist before extractAgePublicKey will shell out.
fs.mkdirSync(vaultRoot, { recursive: true });
fs.writeFileSync(path.join(vaultRoot, 'age.key'), 'AGE-SECRET-KEY-1');
execa.mockImplementation(async (binary: string, args: string[]) => {
if (binary === 'age-keygen') {
return { stdout: 'age1recipient' };
}
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { stdout: '' };
});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
process.chdir(cwd);
process.env = env;
fs.rmSync(root, { recursive: true, force: true });
});
/** Walks the menu, answering encrypt's key selection along the way. */
const run = (categories: string[]) => {
const queue = [...categories, 'quit'];
prompt.mockImplementation(async (questions: { name: string }[]) => {
switch (questions[0].name) {
case 'user':
return { user: '@current' };
case 'selectedKeys':
return { selectedKeys: ['id_prod'] };
default:
return { category: queue.shift() };
}
});
return keyman();
};
it('encrypts into the configured keys directory, where the listing looks', async () => {
await run(['encrypt', 'list']);
expect(fs.existsSync(path.join(vaultRoot, 'encrypted', 'prod', 'id_prod.age'))).toBe(true);
// ✅ is reachable only via inVault && inSsh, and the columns are
// [vault] [tmp] [.ssh] — either alone would pass on a blank vault column.
expect(listingRow('id_prod')).toContain('✅');
expect(listingRow('id_prod')).toMatch(/\[✓]\s+\[ ]\s+\[✓]/);
});
it('honours the configured directory names for every path it prints', async () => {
await run([]);
expect(output()).toContain(path.join(vaultRoot, 'encrypted'));
expect(output()).toContain(path.join(vaultRoot, 'plain'));
});
});