feat: add CASL authorization and AI configuration
This commit is contained in:
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal file
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user