/** * Repository tests for AAO Gateway Phase 2a virtual keys. * * Coverage targets: * - create / findByHash / findById / list * - allowedModels JSON round-trip (incl. null vs [] vs ['a','b']) * - source defaulting + persistence * - revoke idempotency + activeOnly filter * - UNIQUE(key_hash) constraint * - touch updates last_used_at * - delete returns boolean and removes the row */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Repository } from './repository.js'; describe('Repository gateway_virtual_keys (Phase 2a)', () => { let tmpDir: string; let repo: Repository; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'gw-keys-repo-test-')); repo = new Repository(join(tmpDir, 'test.db')); }); afterEach(() => { repo.close(); rmSync(tmpDir, { recursive: true, force: true }); }); it('creates a key with admin defaults and is retrievable by hash', () => { const created = repo.createGatewayVirtualKey({ keyHash: 'hash-1', keyPrefix: 'sk-aao-AAAAAA', team: 'alpha', }); expect(created.id).toBeTruthy(); expect(created.source).toBe('admin'); expect(created.allowedModels).toBeNull(); expect(created.revokedAt).toBeNull(); const found = repo.findGatewayVirtualKeyByHash('hash-1'); expect(found?.id).toBe(created.id); expect(found?.team).toBe('alpha'); }); it('persists allowed_models JSON and distinguishes null vs empty array', () => { const withList = repo.createGatewayVirtualKey({ keyHash: 'hash-list', keyPrefix: 'sk-aao-LIST', team: 't1', allowedModels: ['qwen3:8b', 'qwen3:14b'], }); expect(withList.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']); const empty = repo.createGatewayVirtualKey({ keyHash: 'hash-empty', keyPrefix: 'sk-aao-EMPTY', team: 't1', allowedModels: [], }); expect(empty.allowedModels).toEqual([]); const explicitNull = repo.createGatewayVirtualKey({ keyHash: 'hash-null', keyPrefix: 'sk-aao-NULL0', team: 't1', allowedModels: null, }); expect(explicitNull.allowedModels).toBeNull(); }); it('records the source value verbatim (config-import vs admin)', () => { const cfg = repo.createGatewayVirtualKey({ keyHash: 'h-cfg', keyPrefix: 'sk-aao-CFGCFG', team: 'imported', source: 'config-import', createdBy: 'config', }); expect(cfg.source).toBe('config-import'); expect(cfg.createdBy).toBe('config'); }); it('rejects duplicate key_hash via UNIQUE constraint', () => { repo.createGatewayVirtualKey({ keyHash: 'dup-hash', keyPrefix: 'sk-aao-DUPDUP', team: 'alpha', }); expect(() => repo.createGatewayVirtualKey({ keyHash: 'dup-hash', keyPrefix: 'sk-aao-OTHER', team: 'beta', }), ).toThrow(/UNIQUE/); }); it('hides revoked keys from findGatewayVirtualKeyByHash', () => { const k = repo.createGatewayVirtualKey({ keyHash: 'will-revoke', keyPrefix: 'sk-aao-REVOKE', team: 'alpha', }); expect(repo.findGatewayVirtualKeyByHash('will-revoke')).not.toBeNull(); const ok = repo.revokeGatewayVirtualKey(k.id, 'admin-user'); expect(ok).toBe(true); expect(repo.findGatewayVirtualKeyByHash('will-revoke')).toBeNull(); // Second revoke is a no-op. expect(repo.revokeGatewayVirtualKey(k.id, 'admin-user')).toBe(false); // But findById still exposes it for auditing. const audited = repo.findGatewayVirtualKeyById(k.id); expect(audited?.revokedAt).toBeTruthy(); expect(audited?.revokedBy).toBe('admin-user'); }); it('lists with team + activeOnly filters', () => { const a = repo.createGatewayVirtualKey({ keyHash: 'h-a', keyPrefix: 'sk-aao-AAAAA1', team: 'alpha', }); repo.createGatewayVirtualKey({ keyHash: 'h-b', keyPrefix: 'sk-aao-BBBBBB', team: 'beta', }); const revoked = repo.createGatewayVirtualKey({ keyHash: 'h-a-old', keyPrefix: 'sk-aao-OLD000', team: 'alpha', }); repo.revokeGatewayVirtualKey(revoked.id, 'admin'); const allAlpha = repo.listGatewayVirtualKeys({ team: 'alpha' }); expect(allAlpha).toHaveLength(2); const activeAlpha = repo.listGatewayVirtualKeys({ team: 'alpha', activeOnly: true }); expect(activeAlpha).toHaveLength(1); expect(activeAlpha[0]!.id).toBe(a.id); }); it('touches last_used_at without changing other columns', () => { const k = repo.createGatewayVirtualKey({ keyHash: 'h-touch', keyPrefix: 'sk-aao-TOUCH0', team: 'alpha', }); expect(k.lastUsedAt).toBeNull(); repo.touchGatewayVirtualKeyLastUsed(k.id, '2026-05-19T12:00:00.000Z'); const after = repo.findGatewayVirtualKeyById(k.id); expect(after?.lastUsedAt).toBe('2026-05-19T12:00:00.000Z'); expect(after?.team).toBe('alpha'); expect(after?.revokedAt).toBeNull(); }); it('delete returns true on hit and false on miss', () => { const k = repo.createGatewayVirtualKey({ keyHash: 'h-delete', keyPrefix: 'sk-aao-DELETE', team: 'alpha', }); expect(repo.deleteGatewayVirtualKey(k.id)).toBe(true); expect(repo.findGatewayVirtualKeyById(k.id)).toBeNull(); expect(repo.deleteGatewayVirtualKey(k.id)).toBe(false); }); it('refuses to delete a config-import key (defense-in-depth)', () => { // The admin REST API also rejects this, but the Repository must // refuse too: a future internal caller could otherwise hard-delete // a config-import row that would just be replayed on next gateway // boot when importConfigKeysToDb re-imports it with a different id. const cfg = repo.createGatewayVirtualKey({ keyHash: 'cfg-protect', keyPrefix: 'sk-aao-CFGGGG', team: 'imported', source: 'config-import', createdBy: 'config', }); expect(() => repo.deleteGatewayVirtualKey(cfg.id)).toThrow(/config-import/i); // Row must still be present. expect(repo.findGatewayVirtualKeyById(cfg.id)).not.toBeNull(); }); it('still deletes admin-issued keys after the source check is in place', () => { const admin = repo.createGatewayVirtualKey({ keyHash: 'admin-key', keyPrefix: 'sk-aao-ADMIN0', team: 'team1', source: 'admin', }); expect(repo.deleteGatewayVirtualKey(admin.id)).toBe(true); expect(repo.findGatewayVirtualKeyById(admin.id)).toBeNull(); }); });