import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { VapidKeyStore } from './vapid-store.js'; describe('VapidKeyStore', () => { let tempDir = ''; const SUBJECT = 'https://aao.example/'; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-vapid-')); }); afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); function makeStore() { return new VapidKeyStore(join(tempDir, 'vapid.json'), join(tempDir, 'vapid-history')); } it('generates a new key file on first load', () => { const store = makeStore(); const k = store.loadOrGenerate(SUBJECT); expect(k.keyId).toMatch(/^v1-\d{4}-\d{2}-\d{2}-[a-z0-9]+$/); expect(k.publicKey).toBeTruthy(); expect(k.privateKey).toBeTruthy(); expect(k.subject).toBe(SUBJECT); expect(existsSync(join(tempDir, 'vapid.json'))).toBe(true); }); it('writes the key file with mode 0600', () => { const store = makeStore(); store.loadOrGenerate(SUBJECT); const mode = statSync(join(tempDir, 'vapid.json')).mode & 0o777; expect(mode).toBe(0o600); }); it('returns the same key on repeated load (idempotent)', () => { const store1 = makeStore(); const a = store1.loadOrGenerate(SUBJECT); const store2 = makeStore(); const b = store2.loadOrGenerate(SUBJECT); expect(b.keyId).toBe(a.keyId); expect(b.publicKey).toBe(a.publicKey); expect(b.privateKey).toBe(a.privateKey); }); it('getKey returns current key by id', () => { const store = makeStore(); const k = store.loadOrGenerate(SUBJECT); expect(store.getKey(k.keyId)).toEqual(k); }); it('getKey returns null for unknown keyId', () => { const store = makeStore(); store.loadOrGenerate(SUBJECT); expect(store.getKey('nonexistent-key-id')).toBeNull(); }); it('rotate moves the old key to history and creates a new current', () => { const store = makeStore(); const oldKey = store.loadOrGenerate(SUBJECT); const newKey = store.rotate(SUBJECT); expect(newKey.keyId).not.toBe(oldKey.keyId); expect(newKey.publicKey).not.toBe(oldKey.publicKey); expect(existsSync(join(tempDir, 'vapid.json'))).toBe(true); expect(existsSync(join(tempDir, 'vapid-history', `${oldKey.keyId}.json`))).toBe(true); }); it('rotate: old key is still retrievable via getKey from history', () => { const store = makeStore(); const oldKey = store.loadOrGenerate(SUBJECT); store.rotate(SUBJECT); const recovered = store.getKey(oldKey.keyId); expect(recovered).not.toBeNull(); expect(recovered?.publicKey).toBe(oldKey.publicKey); expect(recovered?.privateKey).toBe(oldKey.privateKey); }); it('history file load works across process restart (fresh store instance)', () => { const s1 = makeStore(); const oldKey = s1.loadOrGenerate(SUBJECT); s1.rotate(SUBJECT); // Simulate restart: a fresh VapidKeyStore reads the same on-disk state. const s2 = makeStore(); s2.loadOrGenerate(SUBJECT); const recovered = s2.getKey(oldKey.keyId); expect(recovered?.publicKey).toBe(oldKey.publicKey); }); it('throws on malformed key file', () => { writeFileSync(join(tempDir, 'vapid.json'), '{"keyId":"only-id"}', { mode: 0o600 }); const store = makeStore(); expect(() => store.loadOrGenerate(SUBJECT)).toThrow(/malformed/); }); it('getCurrent throws if loadOrGenerate was never called', () => { const store = makeStore(); expect(() => store.getCurrent()).toThrow(/loadOrGenerate/); }); it('keyId reflects the date prefix', () => { const store = makeStore(); const k = store.loadOrGenerate(SUBJECT); const today = new Date().toISOString().slice(0, 10); expect(k.keyId).toContain(today); }); it('readKeyFile preserves the original key material verbatim', () => { const store = makeStore(); const k = store.loadOrGenerate(SUBJECT); const raw = JSON.parse(readFileSync(join(tempDir, 'vapid.json'), 'utf-8')); expect(raw.publicKey).toBe(k.publicKey); expect(raw.privateKey).toBe(k.privateKey); expect(raw.subject).toBe(k.subject); }); });