196 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|