/** * Tests for encryptKeys. * * `age` is mocked out; everything the function does to the filesystem itself * (creating the vault layout, copying public keys) is asserted for real. */ 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 { encryptKeys } from '../src/keyman.encrypt.js'; describe('encryptKeys', () => { let root: string; let sshDir: string; let vaultDir: string; let tmpDir: string; let logSpy: ReturnType; const PUBKEY = 'age1recipient'; const key = (dir: string, name: string, marker: string) => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, name), `PRIVATE ${marker}`); fs.writeFileSync(path.join(dir, `${name}.pub`), `PUBLIC ${marker}`); }; const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[]; const messages = (spy: ReturnType) => spy.mock.calls.map((c) => c.join(' ')).join('\n'); beforeEach(() => { vi.clearAllMocks(); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-'))); sshDir = path.join(root, '.ssh'); vaultDir = path.join(root, 'vault'); tmpDir = path.join(root, 'vault', 'tmp'); fs.mkdirSync(sshDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true }); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // Stand in for `age`: record the call and write the output file. execa.mockImplementation(async (_binary: string, args: string[]) => { fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED'); return { exitCode: 0 }; }); }); afterEach(() => { vi.restoreAllMocks(); fs.rmSync(root, { recursive: true, force: true }); }); it('warns when there is nothing to encrypt', async () => { await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); expect(prompt).not.toHaveBeenCalled(); }); it('ignores public keys and unrelated files when building the list', async () => { fs.writeFileSync(path.join(sshDir, 'known_hosts'), ''); fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC'); await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); }); it('offers the keys from .ssh and tmp without duplicates', async () => { key(sshDir, 'id_prod', 'ssh'); key(tmpDir, 'id_prod', 'tmp'); key(tmpDir, 'id_stage', 'tmp'); prompt.mockResolvedValue({ selectedKeys: [] }); await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); expect(choices()).toEqual(['id_prod', 'id_stage']); }); it('encrypts a key from .ssh into the vault', async () => { key(sshDir, 'id_prod', 'ssh'); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); const vaultPath = path.join(vaultDir, 'keys', 'prod'); expect(execa).toHaveBeenCalledWith('age', [ '-r', PUBKEY, '-o', path.join(vaultPath, 'id_prod.age'), path.join(sshDir, 'id_prod'), ]); expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe('PUBLIC ssh'); expect(messages(logSpy)).toContain('Encrypted and stored'); }); it('prefers the tmp copy when a key exists in both directories', async () => { key(sshDir, 'id_prod', 'ssh'); key(tmpDir, 'id_prod', 'tmp'); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); await encryptKeys(sshDir, vaultDir, 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' ); }); it('encrypts every selected key', async () => { key(sshDir, 'id_prod', 'ssh'); key(sshDir, 'id_stage', 'ssh'); prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] }); await encryptKeys(sshDir, vaultDir, 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); }); it('does nothing when the selection is empty', async () => { key(sshDir, 'id_prod', 'ssh'); prompt.mockResolvedValue({ selectedKeys: [] }); await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); expect(execa).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false); }); });