gongxiangp2/lib/__tests__/ai-model-service.test.ts

201 lines
17 KiB
TypeScript

/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { AiModelError } from '@/lib/ai-model-access'
import { isSuperAdmin } from '@/lib/department-permission'
import { configureAiModels, editAiModelProvider, getAiModelAdminState, getAiModelRuntimeState, parseAiModelCatalog, resolveAiModel, syncAiModelProvider } from '@/lib/ai-model-service'
import { encryptAiModelKey } from '@/lib/ai-model-crypto'
import { requestAiModel, requestAiModelCapabilityCatalog } from '@/lib/ai-model-http'
import { getAiModelStageProtocol, parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities'
import { aiModelVerificationContext, listAiModelVerifications } from '@/lib/ai-model-verification-state'
import { aiModelAudit, getAiModelConfiguration, getAiModelProviderRow, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, type AiModelProviderRow, type AiModelRow } from '@/lib/ai-model-repository'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-http', () => ({ ...jest.requireActual('@/lib/ai-model-http'), requestAiModel: jest.fn(), requestAiModelCapabilityCatalog: jest.fn() }))
jest.mock('@/lib/ai-model-verification-state', () => ({ ...jest.requireActual('@/lib/ai-model-verification-state'), listAiModelVerifications: jest.fn() }))
const mockDb = { $executeRawUnsafe: jest.fn() }
jest.mock('@/lib/ai-model-repository', () => ({
...jest.requireActual('@/lib/ai-model-repository'), aiModelTransaction: jest.fn(async callback => callback(mockDb)),
listAiModelProviderRows: jest.fn(), listAiModelRows: jest.fn(), getAiModelConfiguration: jest.fn(), getAiModelProviderRow: jest.fn(), aiModelAudit: jest.fn(), saveAiModelDefaults: jest.fn(), updateAiModelProvider: jest.fn(),
}))
const originalEnv = { ...process.env }
const legacyId = '11111111-1111-4111-8111-111111111111'
const secondId = '22222222-2222-4222-8222-222222222222'
const legacyModelKey = '33333333-3333-4333-8333-333333333333'
const secondModelKey = '44444444-4444-4444-8444-444444444444'
let providers: AiModelProviderRow[]
let models: AiModelRow[]
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'synthetic-ai-encryption-key-32-bytes-minimum'
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-legacy-secret'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://legacy.example.com/v1'
providers = [
{ id: legacyId, name: '原上游', baseUrl: 'https://legacy.example.com/v1', enabled: true, credentialSource: 'environment', secretCiphertext: null, secretNonce: null, secretAuthTag: null, legacyBinding: true, revision: 1, syncedAt: null },
{ id: secondId, name: '第二上游', baseUrl: 'https://new.example.com/v1', enabled: true, credentialSource: 'encrypted', ...encryptAiModelKey('synthetic-second-secret'), legacyBinding: false, revision: 1, syncedAt: null },
]
models = [
{ key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
{ key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
]
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
;(listAiModelProviderRows as jest.Mock).mockImplementation(async () => providers)
;(listAiModelRows as jest.Mock).mockImplementation(async () => models)
;(getAiModelProviderRow as jest.Mock).mockImplementation(async id => ({ ...providers.find(provider => provider.id === id) }))
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 1, defaults: { analysis: legacyModelKey, copy: legacyModelKey, image: legacyModelKey } })
;(listAiModelVerifications as jest.Mock).mockResolvedValue(models.flatMap(model => model.stages.map(stage => ({ id: `${model.key}-${stage}`, modelKey: model.key, stage, status: 'passed', requestId: null, taskId: null, message: null, startedAt: new Date(), deadlineAt: new Date(Date.now() + 600000), finishedAt: new Date(), contextHash: aiModelVerificationContext(providers.find(provider => provider.id === model.providerId)!.baseUrl, model.id, model.capabilityMetadata, model.providerId === legacyId ? 'synthetic-legacy-secret' : 'synthetic-second-secret', getAiModelStageProtocol(model.capabilityMetadata, stage)!) }))))
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); return Response.json({ data: [{ id: 'vendor/shared-model' }, { id: 'brand/new-model' }] }) })
})
afterAll(() => { process.env = originalEnv })
test('same raw model names remain isolated by catalog UUID and legacy names bind only their explicit imported provider', async () => {
expect((await resolveAiModel('analysis', legacyModelKey)).binding.providerId).toBe(legacyId)
expect((await resolveAiModel('analysis', secondModelKey)).binding.providerId).toBe(secondId)
expect((await resolveAiModel('analysis', 'vendor/shared-model')).binding.providerId).toBe(legacyId)
providers[0].legacyBinding = false
await expect(resolveAiModel('analysis', 'vendor/shared-model')).rejects.toMatchObject({ code: 'model_unavailable' })
})
test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed', 'wrong-stage'])('rejects unavailable runtime selection: %s', async reason => {
if (reason === 'disabled-provider') providers[0].enabled = false
if (reason === 'disabled-model') models[0].enabled = false
if (reason === 'missing-model') models[0].present = false
if (reason === 'unconfirmed') (listAiModelVerifications as jest.Mock).mockResolvedValue([])
if (reason === 'wrong-stage') models[0].stages = ['copy']
await expect(resolveAiModel('analysis', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: legacyModelKey, copy: null, image: null } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(saveAiModelDefaults).not.toHaveBeenCalled()
})
test('changed connection revisions stop existing bindings before any model request', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
providers[1].revision++
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('environment URL drift cannot send a replacement environment key to the original imported endpoint', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = 'https://other.example.com/v1'
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
expect((await getAiModelRuntimeState()).legacyModels.image).toEqual([])
})
test('environment key rotation followed by new validation cannot reuse an old project binding', async () => {
const binding = (await resolveAiModel('image', legacyModelKey)).binding
const prior = await listAiModelVerifications()
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-rotated-secret'
await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
;(listAiModelVerifications as jest.Mock).mockResolvedValue(prior.map(value => value.modelKey === legacyModelKey && value.stage === 'image' ? {
...value, id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
contextHash: aiModelVerificationContext(providers[0].baseUrl, models[0].id, models[0].capabilityMetadata, 'synthetic-rotated-secret', getAiModelStageProtocol(models[0].capabilityMetadata, 'image')!),
} : value))
const fresh = (await resolveAiModel('image', legacyModelKey)).binding
expect(fresh.revision).toBe(binding.revision)
expect(fresh.protocolFingerprint).toBe(binding.protocolFingerprint)
expect(fresh.verificationId).not.toBe(binding.verificationId)
await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
await expect(resolveAiModel('image', legacyModelKey, fresh)).resolves.toMatchObject({ binding: fresh })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('legacy imported endpoint remains immutable even when a new API key is supplied', async () => {
await expect(editAiModelProvider('admin', legacyId, { revision: 1, baseUrl: 'https://other.example.com/v1', apiKey: 'synthetic-new-secret' })).rejects.toThrow('保留原服务地址')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('admin DTO strips all encrypted material and does not return plaintext or raw environment variables', async () => {
const state = await getAiModelAdminState()
expect(state.providers[0].hasApiKey).toBe(true)
expect(state.providers[1].hasApiKey).toBe(true)
const serialized = JSON.stringify(state)
for (const value of ['synthetic-legacy-secret', 'synthetic-second-secret', 'secretCiphertext', 'secretNonce', 'secretAuthTag', providers[1].secretCiphertext!]) expect(serialized).not.toContain(value)
})
test('a removed catalog model keeps its record but exposes no testable stages and a clear reason', async () => {
models[0].present = false
const state = await getAiModelAdminState()
expect(state.providers[0].models[0]).toMatchObject({ present: false, enabled: true, capabilities: { allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' } })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('uninitialized defaults produce no hardcoded menu or automatic default choice', async () => {
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 0, defaults: { analysis: null, copy: null, image: null } })
const state = await getAiModelRuntimeState()
expect(state.configured).toBe(false)
expect(state.defaults).toEqual({ analysis: '', copy: '', image: '' })
expect(state.models.analysis).toEqual([legacyModelKey, secondModelKey])
expect(state.modelOptions.analysis.map(value => value.label)).toEqual(['vendor/shared-model', 'vendor/shared-model'])
})
test('directory synchronization never creates verification evidence or enables new models', async () => {
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const statements = mockDb.$executeRawUnsafe.mock.calls.map(call => String(call[0]))
const upserts = statements.filter(sql => sql.startsWith('INSERT INTO ai_model_catalog'))
expect(upserts).toHaveLength(2)
expect(upserts.every(sql => !sql.includes('stages=') && !sql.includes('enabled=') && !sql.includes('compatibility='))).toBe(true)
expect(statements.some(sql => sql.includes('SET present=FALSE'))).toBe(true)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ addedCount: 1 }))
})
test('concurrent provider updates during catalog response reject all catalog writes', async () => {
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); providers[0].revision++; return Response.json({ data: [{ id: 'new' }] }) })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'revision_conflict' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('an upstream credential echo in model IDs is rejected before persistence or public DTO response', async () => {
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'synthetic-legacy-secret' }] }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'catalog_invalid' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(aiModelAudit).not.toHaveBeenCalled()
})
test.each([{ data: [{ id: '\nunsafe' }] }, { data: [{ id: 1 }] }, { data: 'incorrect' }, { data: [{ id: '' }] }])('invalid model catalogs are rejected: %o', value => {
expect(() => parseAiModelCatalog(value)).toThrow(AiModelError)
})
test('manual confirmation cannot enable image generation for a conversation model', async () => {
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: ['image'] }] }] }
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('forged read-only capability metadata is rejected before writes', async () => {
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'], capabilities: { allowedStages: ['image'] } }] }] })).rejects.toThrow('格式无效')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('stale enabled rows are filtered from menus, defaults and old project execution after capability withdrawal', async () => {
const binding = (await resolveAiModel('image', legacyModelKey)).binding
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
const state = await getAiModelRuntimeState()
expect(state.models.image).not.toContain(legacyModelKey)
expect(state.defaults.image).toBe('')
await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: null, copy: null, image: legacyModelKey } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('admin read removes invalid old stages without writes or raw capability metadata exposure', async () => {
models[0].capabilityMetadata = null
const state = await getAiModelAdminState()
expect(state.providers[0].models[0]).toMatchObject({ enabled: true, stages: ['analysis', 'copy', 'image'], capabilities: { kind: 'unknown', allowedStages: [] }, verification: { analysis: { status: 'stale' } } })
expect(JSON.stringify(state)).not.toContain('capabilityMetadata')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('typed public catalog is restricted to models authorized by the private directory', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'allowed-model' }] }))
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(Response.json({ code: 0, data: { schema_version: '2.2', sections: [{ id: 'chat', models: ['allowed-model', 'unauthorized-model'].map(id => ({ id, type: 'model', modality: 'text', invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })) }] } }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const inserts = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('INSERT INTO ai_model_catalog'))
expect(inserts).toHaveLength(1)
expect(inserts[0][3]).toBe('allowed-model')
expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/v1/chat/completions'] })
expect(requestAiModelCapabilityCatalog).toHaveBeenCalledWith('https://api.vip98k.com/v1', expect.any(Function))
})
test('failure of public capability discovery aborts both catalog and capability writes', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(new Response('', { status: 503 }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'capability_catalog_failed' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('identical capabilities do not bump provider revision after JSONB field reordering', async () => {
const declaration = { input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] }
models = [{ ...models[0], capabilityMetadata: Object.fromEntries(Object.entries(parseAiModelCapabilityMetadata(declaration, providers[0].baseUrl + '/models')!).reverse()) }]
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: models[0].id, ...declaration }] }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const revisionUpdate = mockDb.$executeRawUnsafe.mock.calls.find(call => String(call[0]).includes('synced_at=NOW()'))
expect(revisionUpdate?.[2]).toBe(0)
})