maestro/src/db/repository.gateway-keys.test.ts
2026-06-03 05:08:00 +00:00

196 lines
6.4 KiB
TypeScript

/**
* 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();
});
});