forked from wangziqi/gongxue-base
794 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|