import { randomBytes } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { parseKeySecret } from '../src/config.js'; import { ACCESS_KEY_LENGTH, decryptSecret, encryptSecret, generateAccessKey } from '../src/crypto.js'; import { YamlFile } from '../src/yamlFile.js'; import { tempDir } from './helpers.js'; describe('crypto', () => { it('generates 32-char alphanumeric keys', () => { const keys = new Set(Array.from({ length: 100 }, generateAccessKey)); expect(keys.size).toBe(100); for (const key of keys) expect(key).toMatch(new RegExp(`^[A-Za-z0-9]{${ACCESS_KEY_LENGTH}}$`)); }); it('round-trips with AES-256-GCM using a random IV', () => { const secret = randomBytes(32); const a = encryptSecret('hello', secret); const b = encryptSecret('hello', secret); expect(a).not.toBe(b); expect(decryptSecret(a, secret)).toBe('hello'); }); it('rejects a wrong secret or tampered data', () => { const secret = randomBytes(32); const encrypted = encryptSecret('hello', secret); expect(() => decryptSecret(encrypted, randomBytes(32))).toThrow(); const buf = Buffer.from(encrypted, 'base64'); buf[buf.length - 1]! ^= 1; expect(() => decryptSecret(buf.toString('base64'), secret)).toThrow(); }); it('validates KEY_ENCRYPTION_SECRET', () => { expect(parseKeySecret(randomBytes(32).toString('base64'))).toHaveLength(32); expect(() => parseKeySecret(randomBytes(16).toString('base64'))).toThrow(/32 bytes/); expect(() => parseKeySecret(undefined)).toThrow(/required/); }); }); describe('YamlFile', () => { const make = (file: string) => new YamlFile<{ items: number[] }>(file, (raw) => (raw as { items: number[] }) ?? { items: [] }, () => ({ items: [] })); it('returns empty data for a missing file and persists updates', async () => { const file = path.join(await tempDir(), 'sub', 'data.yaml'); const store = make(file); expect(await store.read()).toEqual({ items: [] }); await store.update((d) => d.items.push(1)); expect(await make(file).read()).toEqual({ items: [1] }); }); it('serializes concurrent updates', async () => { const store = make(path.join(await tempDir(), 'data.yaml')); await Promise.all(Array.from({ length: 20 }, (_, i) => store.update((d) => d.items.push(i)))); expect((await store.read()).items).toHaveLength(20); }); it('does not write when the update throws', async () => { const file = path.join(await tempDir(), 'data.yaml'); const store = make(file); await store.update((d) => d.items.push(1)); await expect( store.update((d) => { d.items.push(2); throw new Error('boom'); }), ).rejects.toThrow('boom'); expect((await store.read()).items).toEqual([1]); expect(await readFile(file, 'utf8')).not.toContain('2'); }); it('picks up manual edits', async () => { const file = path.join(await tempDir(), 'data.yaml'); const store = make(file); await store.update((d) => d.items.push(1)); await new Promise((r) => setTimeout(r, 20)); await writeFile(file, 'items: [7, 8]\n'); expect((await store.read()).items).toEqual([7, 8]); }); });