forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal file
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AiConfigController } from './ai-config.controller';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { AiProvider } from './ai-config.entity';
|
||||
|
||||
describe('AiConfigController', () => {
|
||||
let controller: AiConfigController;
|
||||
let service: jest.Mocked<Pick<AiConfigService, 'getConfig' | 'saveConfig' | 'testConnection' | 'clearKey'>>;
|
||||
let opLog: jest.Mocked<Pick<OperationLogsService, 'log'>>;
|
||||
|
||||
const mockConfig = {
|
||||
id: 1,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
hasApiKey: true,
|
||||
hasDatabaseKey: true,
|
||||
maskedApiKey: '••••1234',
|
||||
keySource: 'database' as const,
|
||||
defaultModel: 'gpt-4',
|
||||
enabled: true,
|
||||
timeoutMs: 30000,
|
||||
verified: true,
|
||||
lastTestedAt: '2024-01-01T00:00:00.000Z',
|
||||
lastTestLatencyMs: 250,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockReq = {
|
||||
user: { id: 1, username: 'admin' },
|
||||
headers: { 'user-agent': 'test', 'x-forwarded-for': '1.2.3.4' },
|
||||
connection: { remoteAddress: '1.2.3.4' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
service = {
|
||||
getConfig: jest.fn(),
|
||||
saveConfig: jest.fn(),
|
||||
testConnection: jest.fn(),
|
||||
clearKey: jest.fn(),
|
||||
};
|
||||
|
||||
opLog = {
|
||||
log: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AiConfigController],
|
||||
providers: [
|
||||
{ provide: AiConfigService, useValue: service },
|
||||
{ provide: OperationLogsService, useValue: opLog },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AiConfigController>(AiConfigController);
|
||||
});
|
||||
|
||||
// ── GET /ai/config ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /ai/config', () => {
|
||||
it('returns config with success wrapper', async () => {
|
||||
service.getConfig.mockResolvedValue(mockConfig);
|
||||
const result = await controller.getConfig();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
it('calls service.getConfig', async () => {
|
||||
service.getConfig.mockResolvedValue(mockConfig);
|
||||
await controller.getConfig();
|
||||
expect(service.getConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /ai/config ────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /ai/config', () => {
|
||||
const saveDto = {
|
||||
provider: AiProvider.OPENAI,
|
||||
apiKey: 'sk-new-key',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('saves config and logs operation', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
const result = await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'save',
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
detail: expect.stringContaining('provider=OPENAI'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain apiKey', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('sk-new-key');
|
||||
expect(logCall.detail).not.toContain(saveDto.apiKey);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain full baseUrl', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('api.openai.com/v1');
|
||||
// Only hostname should be present
|
||||
expect(logCall.detail).toContain('host=api.openai.com');
|
||||
});
|
||||
|
||||
it('operation log detail logs model as configured/not-set not raw value', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(
|
||||
{ provider: AiProvider.OPENAI, defaultModel: 'gpt-4' },
|
||||
mockReq,
|
||||
);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).toContain('model=configured');
|
||||
expect(logCall.detail).not.toContain('gpt-4');
|
||||
});
|
||||
|
||||
it('operation log detail logs model=not-set when no defaultModel', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1' } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(
|
||||
{ provider: AiProvider.OPENAI },
|
||||
mockReq,
|
||||
);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).toContain('model=not-set');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /ai/config/test ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /ai/config/test', () => {
|
||||
const testDto = { provider: AiProvider.OPENAI };
|
||||
const testResult = {
|
||||
success: true,
|
||||
latencyMs: 200,
|
||||
modelCount: 5,
|
||||
modelAvailable: true,
|
||||
testedAt: '2024-01-01T00:00:00.000Z',
|
||||
message: '连接成功',
|
||||
};
|
||||
|
||||
it('returns test result and logs operation', async () => {
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
const result = await controller.testConnection(testDto, mockReq);
|
||||
|
||||
expect(result).toEqual(testResult);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'test',
|
||||
detail: expect.stringContaining('success=true'),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs status=failure on test failure', async () => {
|
||||
const failResult = { ...testResult, success: false, message: '认证失败' };
|
||||
service.testConnection.mockResolvedValue(failResult);
|
||||
|
||||
await controller.testConnection(testDto, mockReq);
|
||||
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'failure',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain sensitive info', async () => {
|
||||
const dtoWithKey = { provider: AiProvider.OPENAI, apiKey: 'sk-secret-key' };
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
await controller.testConnection(dtoWithKey, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('sk-secret-key');
|
||||
expect(logCall.detail).not.toContain('Authorization');
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain modelCount', async () => {
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
await controller.testConnection(testDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('modelCount');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /ai/config/clear-key ─────────────────────────────────────────
|
||||
|
||||
describe('POST /ai/config/clear-key', () => {
|
||||
it('clears key and logs operation', async () => {
|
||||
service.clearKey.mockResolvedValue({
|
||||
...mockConfig,
|
||||
hasApiKey: false,
|
||||
hasDatabaseKey: false,
|
||||
maskedApiKey: null,
|
||||
keySource: 'none',
|
||||
});
|
||||
|
||||
const result = await controller.clearKey(mockReq);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.keySource).toBe('none');
|
||||
expect(result.data.hasDatabaseKey).toBe(false);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'clear-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Permission decorators ─────────────────────────────────────────────
|
||||
|
||||
describe('route permissions', () => {
|
||||
it('GET /ai/config requires ai:config:read', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.getConfig,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:read');
|
||||
});
|
||||
|
||||
it('PUT /ai/config requires ai:config:write', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:write');
|
||||
});
|
||||
|
||||
it('POST /ai/config/test requires ai:config:test', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.testConnection,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:test');
|
||||
});
|
||||
|
||||
it('POST /ai/config/clear-key requires ai:config:write', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.clearKey,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:write');
|
||||
});
|
||||
|
||||
it('read permission cannot write', () => {
|
||||
const getPermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.getConfig,
|
||||
);
|
||||
const savePermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
expect(getPermissions).not.toEqual(savePermissions);
|
||||
});
|
||||
|
||||
it('write and test permissions are distinct', () => {
|
||||
const writePermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
const testPermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.testConnection,
|
||||
);
|
||||
expect(writePermissions).not.toEqual(testPermissions);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/server/src/ai-config/ai-config.controller.ts
Normal file
93
apps/server/src/ai-config/ai-config.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user?: { id: number; username: string };
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
connection?: { remoteAddress?: string };
|
||||
}
|
||||
|
||||
@Controller('ai/config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiConfigController {
|
||||
constructor(
|
||||
private readonly service: AiConfigService,
|
||||
private readonly opLog: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('ai:config:read')
|
||||
async getConfig() {
|
||||
const data = await this.service.getConfig();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('ai:config:write')
|
||||
async saveConfig(@Body() body: SaveAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const config = await this.service.saveConfig(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'save',
|
||||
targetId: config.id,
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'} enabled=${body.enabled ?? config.enabled}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@RequirePermission('ai:config:test')
|
||||
async testConnection(@Body() body: TestAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const result = await this.service.testConnection(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'test',
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: result.success ? 'success' : 'failure',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('clear-key')
|
||||
@RequirePermission('ai:config:write')
|
||||
async clearKey(@Req() req: AuthenticatedRequest) {
|
||||
const data = await this.service.clearKey();
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'clear-key',
|
||||
targetType: 'AiConfig',
|
||||
detail: `keySource=${data.keySource}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return { success: true, message: '密钥已清除', data };
|
||||
}
|
||||
}
|
||||
68
apps/server/src/ai-config/ai-config.entity.ts
Normal file
68
apps/server/src/ai-config/ai-config.entity.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum AiProvider {
|
||||
OPENAI = 'OPENAI',
|
||||
DEEPSEEK = 'DEEPSEEK',
|
||||
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
|
||||
}
|
||||
|
||||
export const SINGLETON_KEY = 'GLOBAL';
|
||||
|
||||
@Entity('ai_config')
|
||||
@Index('uq_ai_config_singleton', ['singletonKey'], { unique: true })
|
||||
export class AiConfig {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
|
||||
singletonKey: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
|
||||
provider: AiProvider;
|
||||
|
||||
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
|
||||
baseUrl: string;
|
||||
|
||||
@Column({ name: 'encrypted_api_key', type: 'text', nullable: true })
|
||||
encryptedApiKey: string | null;
|
||||
|
||||
@Column({ name: 'api_key_iv', type: 'varchar', length: 50, nullable: true })
|
||||
apiKeyIv: string | null;
|
||||
|
||||
@Column({ name: 'api_key_auth_tag', type: 'varchar', length: 50, nullable: true })
|
||||
apiKeyAuthTag: string | null;
|
||||
|
||||
@Column({ name: 'key_last4', type: 'varchar', length: 4, nullable: true })
|
||||
keyLast4: string | null;
|
||||
|
||||
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
|
||||
defaultModel: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
||||
timeoutMs: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verified: boolean;
|
||||
|
||||
@Column({ name: 'last_tested_at', type: 'datetime', nullable: true })
|
||||
lastTestedAt: Date | null;
|
||||
|
||||
@Column({ name: 'last_test_latency_ms', type: 'int', nullable: true })
|
||||
lastTestLatencyMs: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
14
apps/server/src/ai-config/ai-config.module.ts
Normal file
14
apps/server/src/ai-config/ai-config.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AiConfig } from './ai-config.entity';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { AiConfigController } from './ai-config.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AiConfig]), OperationLogsModule],
|
||||
controllers: [AiConfigController],
|
||||
providers: [AiConfigService],
|
||||
exports: [AiConfigService],
|
||||
})
|
||||
export class AiConfigModule {}
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
753
apps/server/src/ai-config/ai-config.service.ts
Normal file
753
apps/server/src/ai-config/ai-config.service.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { isIP } from 'node:net';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
|
||||
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||
import {
|
||||
SaveAiConfigDto,
|
||||
TestAiConfigDto,
|
||||
AiConfigResponseDto,
|
||||
AiConfigTestResultDto,
|
||||
AiRuntimeConfig,
|
||||
DEFAULT_BASE_URLS,
|
||||
} from './dto/ai-config.dto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _encryptionWarned = false;
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!_encryptionWarned) {
|
||||
_encryptionWarned = true;
|
||||
Logger.warn(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
|
||||
'AiConfigService',
|
||||
);
|
||||
}
|
||||
// 32 hex pairs → 32 bytes
|
||||
return Buffer.from('ff'.repeat(32), 'hex');
|
||||
}
|
||||
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
|
||||
}
|
||||
|
||||
// Hex: exactly 64 hex chars
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, 'hex');
|
||||
}
|
||||
|
||||
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
|
||||
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length !== 32) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 解码后须为 32 字节',
|
||||
);
|
||||
}
|
||||
// Re-encode to canonical base64 (no line breaks) and compare
|
||||
const canonical = buf.toString('base64');
|
||||
if (raw !== canonical) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 编码须为标准格式(无多余 padding)',
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
|
||||
);
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
|
||||
const key = getEncryptionKey();
|
||||
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 decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
|
||||
const key = getEncryptionKey();
|
||||
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');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL / SSRF helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRIVATE_IPV4_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
||||
];
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Strip IPv6 brackets from URL.hostname
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
hostname = hostname.slice(1, -1);
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
|
||||
if (isIP(hostname) === 6) {
|
||||
// IPv6 private/loopback
|
||||
if (hostname === '::1' || hostname === '::') return true;
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
if (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
)
|
||||
return true; // fe80::/10
|
||||
// IPv4-mapped IPv6: ::ffff:0:0/96
|
||||
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known provider hosts — only these are allowed for fixed providers
|
||||
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
|
||||
[AiProvider.OPENAI]: ['api.openai.com'],
|
||||
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
|
||||
};
|
||||
|
||||
// Required pathname for fixed providers
|
||||
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
||||
[AiProvider.OPENAI]: '/v1',
|
||||
[AiProvider.DEEPSEEK]: '/',
|
||||
};
|
||||
|
||||
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
|
||||
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
|
||||
if (!raw) {
|
||||
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
|
||||
}
|
||||
|
||||
// Reject search/query and hash/fragment
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
|
||||
throw new BadRequestException('生产环境禁止使用 http://');
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
||||
|
||||
// Provider-specific host check
|
||||
const allowedHosts = PROVIDER_HOSTS[provider];
|
||||
if (allowedHosts) {
|
||||
if (!allowedHosts.includes(parsed.hostname)) {
|
||||
throw new BadRequestException(`${provider} 必须使用固定域名`);
|
||||
}
|
||||
// Enforce exact path for fixed providers
|
||||
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
|
||||
if (
|
||||
requiredPath !== undefined &&
|
||||
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
|
||||
) {
|
||||
throw new BadRequestException(`请求参数无效`);
|
||||
}
|
||||
} else {
|
||||
// OPENAI_COMPATIBLE — SSRF check
|
||||
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
|
||||
throw new BadRequestException('不允许使用内网地址');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveHostnames(hostname: string): Promise<{ address: string; family: number }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析返回空结果'));
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
addresses.map((a) => ({
|
||||
address: a.address,
|
||||
family: a.family,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
if (allowPrivate) return;
|
||||
|
||||
let addresses: { address: string; family: number }[];
|
||||
try {
|
||||
addresses = await resolveHostnames(hostname);
|
||||
} catch {
|
||||
throw new BadRequestException('无法解析域名');
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateHost(address)) {
|
||||
throw new BadRequestException('域名解析到内网地址');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection test — uses node:http/https with DNS pinning to prevent rebinding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
/**
|
||||
* Perform a pinned HTTP GET request.
|
||||
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
|
||||
* Redirects are forbidden. HTTPS certificate validation is enforced.
|
||||
*/
|
||||
function pinnedGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
|
||||
const hostname = parsed.hostname;
|
||||
const path = parsed.pathname + parsed.search;
|
||||
|
||||
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
|
||||
if (dnsErr || !addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = addresses.find((a) => !isPrivateHost(a.address));
|
||||
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
|
||||
reject(new Error('解析到内网地址'));
|
||||
return;
|
||||
}
|
||||
const targetIp = resolved ? resolved.address : addresses[0].address;
|
||||
const family = resolved ? resolved.family : addresses[0].family;
|
||||
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const requestStart = Date.now();
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: targetIp,
|
||||
port,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { ...headers, Host: hostname },
|
||||
servername: isHttps ? hostname : undefined,
|
||||
rejectUnauthorized: isHttps,
|
||||
family: family === 6 ? 6 : 4,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
const status = res.statusCode ?? 500;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
res.destroy();
|
||||
return reject(new Error('禁止重定向'));
|
||||
}
|
||||
|
||||
const contentType = res.headers['content-type'] ?? null;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error('响应过大'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve({ status, contentType, body, latencyMs });
|
||||
});
|
||||
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('连接超时'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class AiConfigService {
|
||||
private readonly logger = new Logger(AiConfigService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiConfig)
|
||||
private readonly repo: Repository<AiConfig>,
|
||||
) {}
|
||||
|
||||
/** Resolve the effective API key: DB first, then env, then none */
|
||||
private resolveApiKey(config: AiConfig | null): {
|
||||
plaintext: string | null;
|
||||
source: 'database' | 'environment' | 'none';
|
||||
} {
|
||||
// DB stored key
|
||||
if (config?.encryptedApiKey && config?.apiKeyIv && config?.apiKeyAuthTag) {
|
||||
try {
|
||||
const plaintext = decrypt(config.encryptedApiKey, config.apiKeyIv, config.apiKeyAuthTag);
|
||||
return { plaintext, source: 'database' };
|
||||
} catch {
|
||||
this.logger.error('解密数据库 API Key 失败,密文可能已损坏');
|
||||
throw new InternalServerErrorException('无法解密 API Key');
|
||||
}
|
||||
}
|
||||
|
||||
// Environment fallback
|
||||
const envKey = process.env.AI_API_KEY;
|
||||
if (envKey) {
|
||||
return { plaintext: envKey, source: 'environment' };
|
||||
}
|
||||
|
||||
return { plaintext: null, source: 'none' };
|
||||
}
|
||||
|
||||
/** Load or create the singleton config row */
|
||||
async getOrCreateConfig(): Promise<AiConfig> {
|
||||
let config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (!config) {
|
||||
config = this.repo.create({
|
||||
singletonKey: SINGLETON_KEY,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
|
||||
enabled: false,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
try {
|
||||
config = await this.repo.save(config);
|
||||
} catch (err: unknown) {
|
||||
// Unique constraint violation → another request created it first
|
||||
const isErrWithCode = err !== null && typeof err === 'object' && 'code' in err;
|
||||
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
|
||||
const errno = isErrWithCode ? (err as Record<string, unknown>).errno : undefined;
|
||||
// MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062
|
||||
// SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT')
|
||||
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
|
||||
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Build masked key display */
|
||||
private buildMaskedKey(keyLast4: string | null): string | null {
|
||||
if (keyLast4 && keyLast4.length === 4) {
|
||||
return `••••${keyLast4}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** GET response */
|
||||
async getConfig(): Promise<AiConfigResponseDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
const { source } = this.resolveApiKey(config);
|
||||
|
||||
const hasDbKey = !!(config.encryptedApiKey && config.apiKeyIv && config.apiKeyAuthTag);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
provider: config.provider,
|
||||
baseUrl: config.baseUrl,
|
||||
hasApiKey: source !== 'none',
|
||||
hasDatabaseKey: hasDbKey,
|
||||
maskedApiKey: config.keyLast4
|
||||
? this.buildMaskedKey(config.keyLast4)
|
||||
: source !== 'none'
|
||||
? '••••'
|
||||
: null,
|
||||
keySource: source,
|
||||
defaultModel: config.defaultModel ?? null,
|
||||
enabled: config.enabled,
|
||||
timeoutMs: config.timeoutMs,
|
||||
verified: config.verified,
|
||||
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
|
||||
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
|
||||
createdAt: config.createdAt.toISOString(),
|
||||
updatedAt: config.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** PUT / save */
|
||||
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
|
||||
// Validate and normalize baseUrl
|
||||
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
|
||||
|
||||
// DNS SSRF check for all providers
|
||||
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
||||
|
||||
config.provider = dto.provider;
|
||||
config.baseUrl = normalizedBaseUrl;
|
||||
|
||||
if (dto.defaultModel !== undefined) {
|
||||
config.defaultModel = dto.defaultModel || null;
|
||||
}
|
||||
|
||||
if (dto.timeoutMs !== undefined) {
|
||||
config.timeoutMs = dto.timeoutMs;
|
||||
}
|
||||
|
||||
// Handle apiKey — empty/undefined = keep existing
|
||||
if (dto.apiKey !== undefined && dto.apiKey !== '') {
|
||||
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
|
||||
config.encryptedApiKey = ciphertext;
|
||||
config.apiKeyIv = iv;
|
||||
config.apiKeyAuthTag = authTag;
|
||||
config.keyLast4 = dto.apiKey.slice(-4);
|
||||
}
|
||||
|
||||
// enabled validation
|
||||
if (dto.enabled !== undefined) {
|
||||
if (dto.enabled) {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
throw new BadRequestException('未配置 API Key,无法启用。请先保存 API Key 再启用');
|
||||
}
|
||||
// defaultModel is required when enabled
|
||||
const effectiveDefaultModel =
|
||||
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
|
||||
if (!effectiveDefaultModel) {
|
||||
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
|
||||
}
|
||||
}
|
||||
config.enabled = dto.enabled;
|
||||
}
|
||||
|
||||
return this.repo.save(config);
|
||||
}
|
||||
|
||||
/** Clear DB key only */
|
||||
async clearKey(): Promise<AiConfigResponseDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
config.encryptedApiKey = null;
|
||||
config.apiKeyIv = null;
|
||||
config.apiKeyAuthTag = null;
|
||||
config.keyLast4 = null;
|
||||
// If no env key either, disable
|
||||
const envKey = process.env.AI_API_KEY;
|
||||
if (!envKey) {
|
||||
config.enabled = false;
|
||||
}
|
||||
await this.repo.save(config);
|
||||
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
/** Test connection — uses saved config or request body overrides */
|
||||
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Determine effective provider / baseUrl
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine effective defaultModel
|
||||
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
|
||||
|
||||
// DNS check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
let result: AiConfigTestResultDto;
|
||||
try {
|
||||
const { status, contentType, body, latencyMs } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
// Classify by HTTP status first, then content-type
|
||||
if (status === 401 || status === 403) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '认证失败,请检查 API Key',
|
||||
};
|
||||
} else if (status >= 500) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '服务不可用',
|
||||
};
|
||||
} else if (status >= 400) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: `服务返回错误状态 ${status}`,
|
||||
};
|
||||
} else if (!contentType || !contentType.includes('application/json')) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
} else {
|
||||
let data: { data?: Array<{ id: string }> };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
|
||||
data = parsed;
|
||||
} catch {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = latencyMs;
|
||||
config.verified = false;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
const modelCount = models.length;
|
||||
const modelAvailable =
|
||||
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
|
||||
|
||||
const message = modelAvailable
|
||||
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
|
||||
: effectiveDefaultModel
|
||||
? '连接成功,但未找到目标模型'
|
||||
: models.length > 0
|
||||
? `连接成功,可用模型 ${models.length} 个`
|
||||
: '连接成功,但未返回可用模型';
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
latencyMs,
|
||||
modelCount,
|
||||
modelAvailable,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message === '连接超时'
|
||||
? '连接超时'
|
||||
: err.message === '响应过大'
|
||||
? '响应过大'
|
||||
: err.message === '禁止重定向'
|
||||
? '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL';
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last tested info on config
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = result.latencyMs;
|
||||
config.verified = result.success;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only runtime config — for future AI adapters.
|
||||
* Re-validates the stored base URL and DNS at runtime to guard
|
||||
* against config-table tampering or DNS record changes.
|
||||
* Future adapters should still use a restricted transport helper.
|
||||
*/
|
||||
async getRuntimeConfig(): Promise<AiRuntimeConfig> {
|
||||
const config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (!config) {
|
||||
throw new InternalServerErrorException('AI 配置未初始化');
|
||||
}
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new BadRequestException('AI 服务未启用');
|
||||
}
|
||||
|
||||
// Re-validate and normalize the stored base URL
|
||||
const normalizedBaseUrl = validateAndNormalizeBaseUrl(config.baseUrl, config.provider);
|
||||
|
||||
// Re-check DNS at runtime
|
||||
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
||||
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
throw new BadRequestException('未配置 API Key');
|
||||
}
|
||||
|
||||
// defaultModel is required for actual AI calls
|
||||
if (!config.defaultModel) {
|
||||
throw new BadRequestException('未配置默认模型');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: config.provider,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: plaintext,
|
||||
defaultModel: config.defaultModel,
|
||||
timeoutMs: config.timeoutMs,
|
||||
enabled: config.enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal file
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { SaveAiConfigDto, TestAiConfigDto } from './ai-config.dto';
|
||||
import { AiProvider } from '../ai-config.entity';
|
||||
|
||||
describe('SaveAiConfigDto', () => {
|
||||
it('validates a correct OPENAI config', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('validates a correct DEEPSEEK config', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.DEEPSEEK;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fixed provider can omit baseUrl', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fixed provider can provide baseUrl (valid string)', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.baseUrl = 'https://api.openai.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE must provide baseUrl', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE with baseUrl passes', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = 'https://custom.api.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE with empty baseUrl fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = '';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('invalid provider fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
(dto as Record<string, unknown>).provider = 'INVALID';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
|
||||
it('timeoutMs outside range fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.timeoutMs = 500;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('apiKey is optional string', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.apiKey = 'sk-test-1234';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('defaultModel is optional string', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.defaultModel = 'gpt-4';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enabled is optional boolean', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.enabled = true;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestAiConfigDto', () => {
|
||||
it('empty DTO is valid (all fields optional)', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('partial fields validate', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = 'https://custom.api.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('invalid provider fails', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
(dto as Record<string, unknown>).provider = 'INVALID';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('timeoutMs outside range fails', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
dto.timeoutMs = 0;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal file
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { AiProvider } from '../ai-config.entity';
|
||||
|
||||
const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COMPATIBLE] as const;
|
||||
|
||||
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
|
||||
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
|
||||
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
|
||||
[AiProvider.OPENAI_COMPATIBLE]: '',
|
||||
};
|
||||
|
||||
/** DTO for PUT /api/ai/config — all fields required or validated */
|
||||
export class SaveAiConfigDto {
|
||||
@IsIn(PROVIDERS)
|
||||
provider!: AiProvider;
|
||||
|
||||
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
|
||||
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
/** Raw API key — never returned by GET; empty / undefined = keep existing */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultModel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** DTO for POST /api/ai/config/test — all fields optional, validate only when provided */
|
||||
export class TestAiConfigDto {
|
||||
@IsOptional()
|
||||
@IsIn(PROVIDERS)
|
||||
provider?: AiProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultModel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Response shape for GET /api/ai/config — NEVER includes plaintext key */
|
||||
export interface AiConfigResponseDto {
|
||||
id: number;
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
hasDatabaseKey: boolean;
|
||||
maskedApiKey: string | null;
|
||||
keySource: 'database' | 'environment' | 'none';
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
lastTestLatencyMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Response shape for POST /api/ai/config/test */
|
||||
export interface AiConfigTestResultDto {
|
||||
success: boolean;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
modelAvailable: boolean;
|
||||
testedAt: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Server-only runtime config — NEVER exported via controller DTO */
|
||||
export interface AiRuntimeConfig {
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export { DEFAULT_BASE_URLS };
|
||||
Reference in New Issue
Block a user