Files
gongxue-base/apps/server/src/ai-config/ai-config.service.spec.ts

794 lines
27 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from 'node:crypto';
jest.mock('node:dns', () => ({
lookup: jest.fn(
(
_hostname: string,
_options: unknown,
callback: (error: null, addresses: Array<{ address: string; family: number }>) => void,
) => callback(null, [{ address: '203.0.113.10', family: 4 }]),
),
}));
import { AiConfigService } from './ai-config.service';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
// ---------------------------------------------------------------------------
// Helpers for testing encryption directly
// ---------------------------------------------------------------------------
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function encryptWithKey(key: Buffer, plaintext: string) {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: tag.toString('base64'),
};
}
function decryptWithKey(
key: Buffer,
ciphertextB64: string,
ivB64: string,
authTagB64: string,
): string {
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf-8');
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AiConfigService', () => {
let service: AiConfigService;
let repo: jest.Mocked<Pick<Repository<AiConfig>, 'findOne' | 'save' | 'create'>>;
// Use a known encryption key so tests are deterministic
const TEST_KEY_BYTES_32 = Buffer.alloc(32, 'a'); // 32 bytes of 'a'
const TEST_KEY_HEX = TEST_KEY_BYTES_32.toString('hex'); // 64 hex chars
function makeConfig(overrides: Partial<AiConfig> = {}): AiConfig {
return {
id: 1,
singletonKey: SINGLETON_KEY,
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1',
encryptedApiKey: null,
apiKeyIv: null,
apiKeyAuthTag: null,
keyLast4: null,
defaultModel: null,
enabled: false,
timeoutMs: 30000,
verified: false,
lastTestedAt: null,
lastTestLatencyMs: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
beforeEach(async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
delete process.env.AI_API_KEY;
repo = {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
service = module.get<AiConfigService>(AiConfigService);
});
afterEach(() => {
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
delete process.env.AI_API_KEY;
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
delete process.env.NODE_ENV;
});
// ── Encryption ────────────────────────────────────────────────────────
describe('encryption', () => {
it('roundtrip: encrypt then decrypt returns original text', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const { ciphertext, iv, authTag } = encryptWithKey(TEST_KEY_BYTES_32, plaintext);
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, ciphertext, iv, authTag);
expect(decrypted).toBe(plaintext);
});
it('random IV: same key + same plaintext produces different ciphertexts', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const r1 = encryptWithKey(key, plaintext);
const r2 = encryptWithKey(key, plaintext);
expect(r1.iv).not.toBe(r2.iv);
expect(r1.ciphertext).not.toBe(r2.ciphertext);
expect(decryptWithKey(key, r1.ciphertext, r1.iv, r1.authTag)).toBe(plaintext);
expect(decryptWithKey(key, r2.ciphertext, r2.iv, r2.authTag)).toBe(plaintext);
});
it('wrong key fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const correctKey = TEST_KEY_BYTES_32;
const wrongKey = Buffer.alloc(32, 'b');
const { ciphertext, iv, authTag } = encryptWithKey(correctKey, plaintext);
expect(() =>
decryptWithKey(wrongKey, ciphertext, iv, authTag),
).toThrow();
});
it('tampered auth tag fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const { ciphertext, iv, authTag } = encryptWithKey(key, plaintext);
const tamperedTag = Buffer.from(authTag, 'base64');
tamperedTag[0] ^= 1;
expect(() =>
decryptWithKey(key, ciphertext, iv, tamperedTag.toString('base64')),
).toThrow();
});
});
// ── Encryption key validation ─────────────────────────────────────────
describe('encryption key validation', () => {
it('rejects plain 32-char string (not hex or base64)', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
repo.findOne.mockResolvedValue(makeConfig());
// Re-create service with new key
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects invalid base64 input', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!invalid!!!';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('accepts valid base64 32-byte key', async () => {
const b64key = TEST_KEY_BYTES_32.toString('base64');
process.env.AI_CONFIG_ENCRYPTION_KEY = b64key;
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
const result = await svc.saveConfig({ provider: AiProvider.OPENAI });
expect(result).toBeDefined();
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects non-canonical base64 (extra padding)', async () => {
const canonicalB64 = TEST_KEY_BYTES_32.toString('base64');
// Non-canonical: add extra padding
const badB64 = canonicalB64 + '==';
process.env.AI_CONFIG_ENCRYPTION_KEY = badB64;
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects base64 with invalid characters', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!!aaaa';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('throws in production when no key set', async () => {
process.env.NODE_ENV = 'production';
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
delete process.env.NODE_ENV;
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
});
// ── Config management ─────────────────────────────────────────────────
describe('getOrCreateConfig', () => {
it('returns existing config when found', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
const result = await service.getOrCreateConfig();
expect(result).toBe(existing);
expect(repo.findOne).toHaveBeenCalledWith({ where: { singletonKey: SINGLETON_KEY } });
expect(repo.create).not.toHaveBeenCalled();
});
it('creates default config when none exists', async () => {
repo.findOne.mockResolvedValue(null);
const created = makeConfig();
repo.create.mockReturnValue(created);
repo.save.mockResolvedValue(created);
const result = await service.getOrCreateConfig();
expect(repo.create).toHaveBeenCalled();
expect(result.provider).toBe(AiProvider.OPENAI);
expect(result.enabled).toBe(false);
});
});
describe('getConfig', () => {
it('returns masked key info with source=none when no key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.maskedApiKey).toBeNull();
expect(result.keySource).toBe('none');
});
it('returns hasApiKey=true and hasDatabaseKey=true when DB key exists', async () => {
const key = 'sk-abcdefghij1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(true);
expect(result.maskedApiKey).toBe('••••1234');
expect(result.keySource).toBe('database');
});
it('never returns plaintext key in GET response', async () => {
const key = 'sk-topsecret1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
const json = JSON.stringify(result);
expect(json).not.toContain('topsecret');
expect(json).not.toContain('sk-');
});
it('env key fallback: source=environment, hasDatabaseKey=false', async () => {
process.env.AI_API_KEY = 'sk-env-key-1234';
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('environment');
delete process.env.AI_API_KEY;
});
});
// ── Save config ───────────────────────────────────────────────────────
describe('saveConfig', () => {
it('saves provider and baseUrl', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
baseUrl: 'https://api.deepseek.com',
});
expect(result.provider).toBe(AiProvider.DEEPSEEK);
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('encrypts and saves apiKey', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const apiKey = 'sk-saved-key-5678';
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey,
});
expect(result.encryptedApiKey).toBeTruthy();
expect(result.apiKeyIv).toBeTruthy();
expect(result.apiKeyAuthTag).toBeTruthy();
expect(result.keyLast4).toBe('5678');
const encKey = result.encryptedApiKey;
const encIv = result.apiKeyIv;
const encTag = result.apiKeyAuthTag;
expect(encKey).toBeTruthy();
expect(encIv).toBeTruthy();
expect(encTag).toBeTruthy();
if (!encKey || !encIv || !encTag) throw new Error('encrypted fields missing');
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, encKey, encIv, encTag);
expect(decrypted).toBe(apiKey);
});
it('empty apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey: '',
});
expect(result.encryptedApiKey).toBe(ciphertext);
expect(result.keyLast4).toBe('9999');
});
it('undefined apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
});
expect(result.encryptedApiKey).toBe(ciphertext);
});
it('rejects enabled=true without any key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
it('allows enabled=true when DB key exists and defaultModel is set', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag, defaultModel: 'gpt-4' }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
});
it('allows enabled=true with env key fallback and defaultModel', async () => {
process.env.AI_API_KEY = 'sk-env-key';
repo.findOne.mockResolvedValue(makeConfig({ defaultModel: 'gpt-4' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
delete process.env.AI_API_KEY;
});
it('provider switch replaces default baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig({ baseUrl: 'https://api.openai.com/v1' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
});
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('OPENAI_COMPATIBLE requires baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
}),
).rejects.toThrow(BadRequestException);
});
it('enabled=true with defaultModel in DTO works even if config has none', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
defaultModel: 'gpt-4',
});
expect(result.enabled).toBe(true);
});
it('enabled=true rejects when defaultModel is empty everywhere', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
});
// ── Clear key ─────────────────────────────────────────────────────────
describe('clearKey', () => {
it('clears DB key and disables when no env key', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
enabled: true,
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('none');
expect(result.maskedApiKey).toBeNull();
});
it('clear DB key falls back to env source', async () => {
process.env.AI_API_KEY = 'sk-env-after-clear';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.keySource).toBe('environment');
expect(result.hasDatabaseKey).toBe(false);
delete process.env.AI_API_KEY;
});
});
// ── URL / SSRF validation ──────────────────────────────────────────────
describe('baseUrl validation', () => {
it('rejects non-http protocol', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'ftp://evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with username/password', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://user:pass@evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with search/query string', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1?proxy=internal',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with hash/fragment', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1#section',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects localhost for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects 127.0.0.1 for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://127.0.0.1:8080',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects .local hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://myservice.local/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects IPv6 loopback ::1', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://[::1]:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('allows private IP when AI_ALLOW_PRIVATE_BASE_URL=true', async () => {
process.env.AI_ALLOW_PRIVATE_BASE_URL = 'true';
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
});
expect(result.baseUrl).toBe('http://localhost:8080/v1');
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
});
it('OPENAI rejects non-openai hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://evil.com/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('OPENAI rejects wrong pathname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/evil-proxy',
}),
).rejects.toThrow(BadRequestException);
});
it('normalizes trailing slash', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1/',
});
expect(result.baseUrl).toBe('https://api.openai.com/v1');
});
});
// ── getRuntimeConfig ──────────────────────────────────────────────────
describe('getRuntimeConfig', () => {
it('returns config with plaintext key', async () => {
const apiKey = 'sk-runtime-key';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
apiKey,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: 'gpt-4',
}),
);
const runtime = await service.getRuntimeConfig();
expect(runtime.apiKey).toBe(apiKey);
expect(runtime.enabled).toBe(true);
});
it('throws when not enabled', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: false }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no key available', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: true }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when defaultModel is empty', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-runtime-key',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: null,
}),
);
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no config row exists', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.getRuntimeConfig()).rejects.toThrow(InternalServerErrorException);
});
});
});