173 lines
15 KiB
TypeScript
173 lines
15 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 { parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities'
|
|
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() }))
|
|
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, compatibility: 'confirmed', 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, compatibility: 'confirmed', 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 } })
|
|
;(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') models[0].compatibility = 'unconfirmed'
|
|
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('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('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 preserves manual compatibility while adding new models disabled', 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, compatibility: 'confirmed', 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: false, stages: [], capabilities: { kind: 'unknown', allowedStages: [] } })
|
|
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: ['/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)
|
|
})
|