diff --git a/packages/keyman/src/keyman.decrypt.ts b/packages/keyman/src/keyman.decrypt.ts index 768e7a8..e91f7f2 100644 --- a/packages/keyman/src/keyman.decrypt.ts +++ b/packages/keyman/src/keyman.decrypt.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import inquirer from 'inquirer'; import { runTool } from './keyman.utils.js'; -const LOCAL_MODE = 'Local (vault/tmp)'; +/** The two decryption targets. Values, so the label can name the real directory. */ +const LOCAL_MODE = 'local'; interface DecryptPlan { key: string; @@ -13,12 +14,13 @@ interface DecryptPlan { publicKeyOut: string; } -export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) { - const keyDir = path.join(vaultDir, 'keys'); +export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) { // Guarded: nothing creates the keys directory until the first encrypt, so on a // fresh vault this readdir threw instead of reporting an empty vault. - const vaultKeys = fs.existsSync(keyDir) - ? fs.readdirSync(keyDir).filter((key) => fs.existsSync(path.join(keyDir, key, `id_${key}.age`))) + const vaultKeys = fs.existsSync(keysDir) + ? fs + .readdirSync(keysDir) + .filter((key) => fs.existsSync(path.join(keysDir, key, `id_${key}.age`))) : []; if (vaultKeys.length === 0) { @@ -37,16 +39,20 @@ export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: stri type: 'list', name: 'decryptMode', message: 'Choose decryption location:', - choices: [LOCAL_MODE, 'SSH (~/.ssh)'], + // Named after the directories actually in use, which are configurable. + choices: [ + { name: `Local (${tmpDir})`, value: LOCAL_MODE }, + { name: `SSH (${sshDir})`, value: 'ssh' }, + ], }, ]); - const outDir = decryptMode === LOCAL_MODE ? path.join(vaultDir, 'tmp') : sshDir; + const outDir = decryptMode === LOCAL_MODE ? tmpDir : sshDir; const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({ key, - encryptedKey: path.join(keyDir, key, `id_${key}.age`), - publicKey: path.join(keyDir, key, `id_${key}.pub`), + encryptedKey: path.join(keysDir, key, `id_${key}.age`), + publicKey: path.join(keysDir, key, `id_${key}.pub`), privateKeyOut: path.join(outDir, `id_${key}`), publicKeyOut: path.join(outDir, `id_${key}.pub`), })); diff --git a/packages/keyman/src/keyman.encrypt.ts b/packages/keyman/src/keyman.encrypt.ts index 7fb1c4a..fdf5a67 100644 --- a/packages/keyman/src/keyman.encrypt.ts +++ b/packages/keyman/src/keyman.encrypt.ts @@ -16,12 +16,7 @@ function privateKeysIn(dir: string): string[] { return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); } -export async function encryptKeys( - sshDir: string, - vaultDir: string, - tmpDir: string, - pubkey: string -) { +export async function encryptKeys(sshDir: string, keysDir: string, tmpDir: string, pubkey: string) { const sshKeys = privateKeysIn(sshDir); const tmpKeys = privateKeysIn(tmpDir); const keys = [...new Set([...sshKeys, ...tmpKeys])]; @@ -42,7 +37,7 @@ export async function encryptKeys( for (const key of selectedKeys) { const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key); - const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', '')); + const vaultPath = path.join(keysDir, key.replace('id_', '')); fs.mkdirSync(vaultPath, { recursive: true, mode: 0o700 }); // Encrypt key using `age` diff --git a/packages/keyman/src/keyman.main.ts b/packages/keyman/src/keyman.main.ts index 23a7752..51be4be 100644 --- a/packages/keyman/src/keyman.main.ts +++ b/packages/keyman/src/keyman.main.ts @@ -95,12 +95,12 @@ export async function keyman() { case 'encrypt': { const pubkey = await ageRecipient(); if (pubkey) { - await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, pubkey); + await encryptKeys(sshDir, paths.keysDir, paths.tmpDir, pubkey); } break; } case 'decrypt': - await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath); + await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath); break; case 'quit': console.log('\nšŸ‘‹ Goodbye!\n'); diff --git a/packages/keyman/tests/decrypt.test.ts b/packages/keyman/tests/decrypt.test.ts index d9239db..caf0683 100644 --- a/packages/keyman/tests/decrypt.test.ts +++ b/packages/keyman/tests/decrypt.test.ts @@ -19,14 +19,14 @@ vi.mock('inquirer', () => ({ default: { prompt } })); import { decryptKeys } from '../src/keyman.decrypt.js'; -const LOCAL = 'Local (vault/tmp)'; -const SSH = 'SSH (~/.ssh)'; +const LOCAL = 'local'; +const SSH = 'ssh'; describe('decryptKeys', () => { let root: string; let sshDir: string; let vaultDir: string; - let keyDir: string; + let keysDir: string; let tmpDir: string; let logSpy: ReturnType; @@ -34,7 +34,7 @@ describe('decryptKeys', () => { /** Creates /keys//id_.{age,pub}. */ const vaultKey = (name: string) => { - const dir = path.join(keyDir, name); + const dir = path.join(keysDir, name); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`); fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`); @@ -62,9 +62,9 @@ describe('decryptKeys', () => { root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-'))); sshDir = path.join(root, '.ssh'); vaultDir = path.join(root, 'vault'); - keyDir = path.join(vaultDir, 'keys'); + keysDir = path.join(vaultDir, 'keys'); tmpDir = path.join(vaultDir, 'tmp'); - fs.mkdirSync(keyDir, { recursive: true }); + fs.mkdirSync(keysDir, { recursive: true }); fs.mkdirSync(sshDir, { recursive: true }); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -83,16 +83,16 @@ describe('decryptKeys', () => { }); it('warns when the vault holds no encrypted keys', async () => { - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(messages(logSpy)).toContain('No encrypted keys found.'); expect(prompt).not.toHaveBeenCalled(); }); it('warns instead of throwing when the vault has no keys directory', async () => { - fs.rmSync(keyDir, { recursive: true }); + fs.rmSync(keysDir, { recursive: true }); - await expect(decryptKeys(sshDir, vaultDir, AGE_KEY)).resolves.toBeUndefined(); + await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).resolves.toBeUndefined(); expect(messages(logSpy)).toContain('No encrypted keys found.'); }); @@ -103,18 +103,18 @@ describe('decryptKeys', () => { throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' }); }); - await expect(decryptKeys(sshDir, vaultDir, AGE_KEY)).rejects.toThrow( + await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).rejects.toThrow( '`age` was not found on PATH' ); }); it('offers only directories that actually contain an encrypted key', async () => { vaultKey('prod'); - fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true }); - fs.writeFileSync(path.join(keyDir, 'README.md'), ''); + fs.mkdirSync(path.join(keysDir, 'empty'), { recursive: true }); + fs.writeFileSync(path.join(keysDir, 'README.md'), ''); answers([]); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(choices()).toEqual(['prod']); }); @@ -123,7 +123,7 @@ describe('decryptKeys', () => { vaultKey('prod'); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); const out = path.join(tmpDir, 'id_prod'); expect(argsOf('age')).toEqual([ @@ -132,7 +132,7 @@ describe('decryptKeys', () => { AGE_KEY, '-o', out, - path.join(keyDir, 'prod', 'id_prod.age'), + path.join(keysDir, 'prod', 'id_prod.age'), ]); expect(fs.readFileSync(out, 'utf-8')).toBe('PLAINTEXT'); expect(fs.readFileSync(`${out}.pub`, 'utf-8')).toBe('PUBLIC prod'); @@ -143,7 +143,7 @@ describe('decryptKeys', () => { vaultKey('prod'); answers(['prod'], SSH); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); const out = path.join(sshDir, 'id_prod'); expect(argsOf('age')?.[4]).toBe(out); @@ -155,7 +155,7 @@ describe('decryptKeys', () => { vaultKey('prod'); answers(['prod'], SSH); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(modeOf(sshDir)).toBe(0o700); expect(fs.existsSync(path.join(sshDir, 'id_prod'))).toBe(true); @@ -165,7 +165,7 @@ describe('decryptKeys', () => { vaultKey('prod'); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(modeOf(path.join(tmpDir, 'id_prod'))).toBe(0o600); }); @@ -175,7 +175,7 @@ describe('decryptKeys', () => { vaultKey('stage'); answers(['prod', 'stage']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); // One age per key: the cp and chmod spawns are gone. expect(execa).toHaveBeenCalledTimes(2); @@ -186,17 +186,17 @@ describe('decryptKeys', () => { vaultKey('prod'); answers([]); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(execa).not.toHaveBeenCalled(); }); it('writes the private key even when the vault entry has no public key', async () => { vaultKey('prod'); - fs.rmSync(path.join(keyDir, 'prod', 'id_prod.pub')); + fs.rmSync(path.join(keysDir, 'prod', 'id_prod.pub')); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true); expect(messages(logSpy)).toContain('has no public key in the vault'); @@ -214,7 +214,7 @@ describe('decryptKeys', () => { const target = existing(tmpDir); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY'); expect(execa).not.toHaveBeenCalled(); @@ -226,7 +226,7 @@ describe('decryptKeys', () => { existing(tmpDir); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); const confirm = prompt.mock.calls.at(-1)?.[0][0]; expect(confirm).toMatchObject({ type: 'confirm', default: false }); @@ -238,7 +238,7 @@ describe('decryptKeys', () => { const target = existing(tmpDir); answers(['prod'], LOCAL, true); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(fs.readFileSync(target, 'utf-8')).toBe('PLAINTEXT'); }); @@ -248,7 +248,7 @@ describe('decryptKeys', () => { existing(tmpDir, 'id_prod.pub'); answers(['prod']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(execa).not.toHaveBeenCalled(); expect(prompt.mock.calls.at(-1)?.[0][0].message).toContain('id_prod.pub'); @@ -259,7 +259,7 @@ describe('decryptKeys', () => { const target = existing(sshDir); answers(['prod'], SSH); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY'); }); @@ -270,7 +270,7 @@ describe('decryptKeys', () => { existing(tmpDir); answers(['prod', 'stage']); - await decryptKeys(sshDir, vaultDir, AGE_KEY); + await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY); // stage is written, prod is kept — and the question about prod was asked // before either was touched. diff --git a/packages/keyman/tests/encrypt.test.ts b/packages/keyman/tests/encrypt.test.ts index e19f114..bb6f006 100644 --- a/packages/keyman/tests/encrypt.test.ts +++ b/packages/keyman/tests/encrypt.test.ts @@ -20,7 +20,7 @@ import { encryptKeys } from '../src/keyman.encrypt.js'; describe('encryptKeys', () => { let root: string; let sshDir: string; - let vaultDir: string; + let keysDir: string; let tmpDir: string; let logSpy: ReturnType; @@ -41,7 +41,7 @@ describe('encryptKeys', () => { vi.clearAllMocks(); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-'))); sshDir = path.join(root, '.ssh'); - vaultDir = path.join(root, 'vault'); + keysDir = path.join(root, 'vault', 'keys'); tmpDir = path.join(root, 'vault', 'tmp'); fs.mkdirSync(sshDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true }); @@ -60,7 +60,7 @@ describe('encryptKeys', () => { }); it('warns when there is nothing to encrypt', async () => { - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); expect(prompt).not.toHaveBeenCalled(); @@ -69,7 +69,7 @@ describe('encryptKeys', () => { it('warns instead of throwing when the .ssh directory does not exist', async () => { fs.rmSync(sshDir, { recursive: true }); - await expect(encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY)).resolves.toBeUndefined(); + await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).resolves.toBeUndefined(); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); }); @@ -78,7 +78,7 @@ describe('encryptKeys', () => { key(sshDir, 'id_prod', 'ssh'); prompt.mockResolvedValue({ selectedKeys: [] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(choices()).toEqual(['id_prod']); }); @@ -88,7 +88,7 @@ describe('encryptKeys', () => { prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); execa.mockRejectedValue(Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' })); - await expect(encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY)).rejects.toThrow( + await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow( '`age` was not found on PATH' ); }); @@ -97,7 +97,7 @@ describe('encryptKeys', () => { fs.writeFileSync(path.join(sshDir, 'known_hosts'), ''); fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC'); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); }); @@ -108,7 +108,7 @@ describe('encryptKeys', () => { key(tmpDir, 'id_stage', 'tmp'); prompt.mockResolvedValue({ selectedKeys: [] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(choices()).toEqual(['id_prod', 'id_stage']); }); @@ -117,9 +117,9 @@ describe('encryptKeys', () => { key(sshDir, 'id_prod', 'ssh'); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); - const vaultPath = path.join(vaultDir, 'keys', 'prod'); + const vaultPath = path.join(keysDir, 'prod'); expect(execa).toHaveBeenCalledWith('age', [ '-r', PUBKEY, @@ -136,12 +136,10 @@ describe('encryptKeys', () => { key(tmpDir, 'id_prod', 'tmp'); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod')); - expect(fs.readFileSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.pub'), 'utf-8')).toBe( - 'PUBLIC tmp' - ); + expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe('PUBLIC tmp'); }); it('encrypts every selected key', async () => { @@ -149,20 +147,20 @@ describe('encryptKeys', () => { key(sshDir, 'id_stage', 'ssh'); prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(execa).toHaveBeenCalledTimes(2); - expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true); - expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true); + expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true); + expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true); }); it('does nothing when the selection is empty', async () => { key(sshDir, 'id_prod', 'ssh'); prompt.mockResolvedValue({ selectedKeys: [] }); - await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY); expect(execa).not.toHaveBeenCalled(); - expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false); + expect(fs.existsSync(keysDir)).toBe(false); }); }); diff --git a/packages/keyman/tests/main.test.ts b/packages/keyman/tests/main.test.ts index ed01f6c..73be963 100644 --- a/packages/keyman/tests/main.test.ts +++ b/packages/keyman/tests/main.test.ts @@ -172,27 +172,28 @@ describe('keyman', () => { expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient'); }); - it('encrypts keys into the vault root', async () => { + it('encrypts keys into the configured keys directory', async () => { menu(['encrypt']); await keyman(); expect(encryptKeys).toHaveBeenCalledWith( path.join(process.env.HOME as string, '.ssh'), - paths.vaultRoot, + paths.keysDir, paths.tmpDir, 'age1recipient' ); }); - it('decrypts keys using the age identity file', async () => { + it('decrypts from the configured keys directory using the age identity file', async () => { menu(['decrypt']); await keyman(); expect(decryptKeys).toHaveBeenCalledWith( path.join(process.env.HOME as string, '.ssh'), - paths.vaultRoot, + paths.keysDir, + paths.tmpDir, paths.keyPath ); }); diff --git a/packages/keyman/tests/vault-layout.test.ts b/packages/keyman/tests/vault-layout.test.ts new file mode 100644 index 0000000..2ca277d --- /dev/null +++ b/packages/keyman/tests/vault-layout.test.ts @@ -0,0 +1,127 @@ +/** + * 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; + + 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')); + }); +});