124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|