156 lines
13 KiB
TypeScript
156 lines
13 KiB
TypeScript
/** @jest-environment node */
|
||
import { randomUUID } from 'node:crypto'
|
||
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from '@/lib/ai-model-access'
|
||
import { checkAiModelSubmission, reconcileAiModelVerification } from '@/lib/ai-model-recovery'
|
||
import { aiModelAudit } from '@/lib/ai-model-repository'
|
||
import { assertAiModelRecoveryTextSafe, resolveAiModelVerification } from '@/lib/ai-model-service'
|
||
import { aiModelVerificationSummary } from '@/lib/ai-model-verification-state'
|
||
import { createVerificationChallenge, validateAiModelVerificationOutput, verificationReferenceImage } from '@/lib/ai-model-verification-output'
|
||
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: {} }))
|
||
jest.mock('@/lib/ai-model-access', () => ({ ...jest.requireActual('@/lib/ai-model-access'), assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() }))
|
||
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn(), assertAiModelRecoveryTextSafe: jest.fn() }))
|
||
const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() }
|
||
jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async action => action(mockDb)), aiModelAudit: jest.fn() }))
|
||
const providerId = randomUUID(), modelKey = randomUUID(), request = { stage: 'analysis', idempotencyKey: randomUUID() }
|
||
const proof = { outcome: 'failed', reference: 'upstream-log-123', note: '核查上游日志,原请求已失败并停止执行。', confirmed: true }
|
||
let row: any, group: any[] | null, exists: boolean, closed: boolean, found: boolean
|
||
beforeEach(() => {
|
||
jest.clearAllMocks(); exists = true; closed = false; found = true; group = null
|
||
row = { id: randomUUID(), modelKey, stage: 'analysis', idempotencyKey: request.idempotencyKey, actorId: 'admin', status: 'unknown', taskId: null, evidence: {}, contextHash: 'a'.repeat(64), deadlineAt: new Date(Date.now() - 1), startedAt: new Date(), finishedAt: new Date(), requestId: null }
|
||
;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
|
||
;(assertAiModelAdminForWrite as jest.Mock).mockResolvedValue(undefined)
|
||
;(assertAiModelRecoveryTextSafe as jest.Mock).mockResolvedValue(undefined)
|
||
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: row.contextHash })
|
||
mockDb.$queryRawUnsafe.mockImplementation(async sql => sql.includes("evidence->>'executionGroupId'=$3") ? group || [] : sql.includes('FROM ai_model_catalog WHERE id=') ? found ? [{ id: modelKey }] : [] : sql.includes('FROM ai_model_audit') ? closed ? [{ id: 'audit' }] : [] : exists ? [row] : [])
|
||
mockDb.$executeRawUnsafe.mockResolvedValue(1)
|
||
})
|
||
test('absent intake is closed by audit and never creates a verification or calls a model', async () => {
|
||
exists = false
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: false, idempotencyKey: request.idempotencyKey } })
|
||
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-intake-cancelled', providerId, expect.objectContaining(request))
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
expect(resolveAiModelVerification).not.toHaveBeenCalled()
|
||
})
|
||
test('existing intake is read without cancellation or repeat model calls', async () => {
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
|
||
expect(aiModelAudit).not.toHaveBeenCalled()
|
||
expect(resolveAiModelVerification).not.toHaveBeenCalled()
|
||
})
|
||
|
||
function jointRows() {
|
||
const evidence = { purpose: 'analysis-copy', executionGroupId: randomUUID() }
|
||
group = [{ ...row, stage: 'analysis', evidence }, { ...row, id: randomUUID(), stage: 'copy', evidence }]
|
||
row = group[1]
|
||
}
|
||
test('one joint intake resolves both records and one recovery closes both without approving them', async () => {
|
||
jointRows()
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, { ...request, stage: 'analysis-copy' })).resolves.toMatchObject({ submissionCheck: { received: true, stage: 'analysis-copy' } })
|
||
await reconcileAiModelVerification('admin', providerId, row.id, proof)
|
||
expect(mockDb.$executeRawUnsafe.mock.calls.map(([, id]) => id)).toEqual(group!.map(value => value.id))
|
||
expect(mockDb.$executeRawUnsafe.mock.calls.every(([sql]) => sql.includes("status='failed'"))).toBe(true)
|
||
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-reconciled', providerId, expect.objectContaining({ stage: 'analysis-copy', verificationIds: group!.map(value => value.id) }))
|
||
})
|
||
test('incomplete or still-running joint records cannot be manually closed', async () => {
|
||
jointRows(); group![0].status = 'running'; group![0].deadlineAt = new Date(Date.now() + 600000)
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_not_unknown' })
|
||
group!.shift()
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_checkpoint_invalid' })
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
})
|
||
test('a joint original task must be queried unless at least one of its contexts changed', async () => {
|
||
jointRows(); group!.forEach(value => { value.taskId = 'task-joint' })
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
|
||
;(resolveAiModelVerification as jest.Mock).mockImplementation(async (_provider, _model, stage) => ({ contextHash: stage === 'analysis' ? 'changed' : row.contextHash }))
|
||
await reconcileAiModelVerification('admin', providerId, row.id, proof)
|
||
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledTimes(2)
|
||
})
|
||
test('closing the same absent intake twice does not duplicate audit', async () => {
|
||
exists = false; closed = true
|
||
await checkAiModelSubmission('admin', providerId, modelKey, request)
|
||
expect(aiModelAudit).not.toHaveBeenCalled()
|
||
})
|
||
test('wrong provider is denied; another super administrator can inspect the original global intake', async () => {
|
||
found = false
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 404 })
|
||
found = true; row.actorId = 'another-admin'
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
|
||
expect(aiModelAudit).not.toHaveBeenCalled()
|
||
})
|
||
test('unknown without checkpoint can be closed with evidence, never passed or enabled', async () => {
|
||
await reconcileAiModelVerification('admin', providerId, row.id, proof)
|
||
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
|
||
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-reconciled', providerId, expect.any(Object))
|
||
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /status='passed'|UPDATE ai_model_catalog/.test(sql))).toBe(false)
|
||
})
|
||
test.each([{ confirmed: false }, { note: '确认' }, { reference: '' }, { outcome: 'unknown' }, { compatibility: 'confirmed' }])('insufficient evidence cannot close an unknown request: %j', async change => {
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, { ...proof, ...change })).rejects.toMatchObject({ status: 400 })
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
})
|
||
test('live running cannot be reconciled and queryable original task cannot be manually closed', async () => {
|
||
row.status = 'running'; row.deadlineAt = new Date(Date.now() + 600000)
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_not_unknown' })
|
||
row.status = 'unknown'; row.taskId = 'original-task'
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('a retained image lookup may still be closed with evidence when its response cannot be retrieved; closure never approves it', async () => {
|
||
row.stage = 'image'; row.requestId = randomUUID()
|
||
expect(aiModelVerificationSummary([row], modelKey, 'image', row.contextHash)).toMatchObject({ canResume: false, canReconcile: true })
|
||
await reconcileAiModelVerification('admin', providerId, row.id, proof)
|
||
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
|
||
expect(resolveAiModelVerification).not.toHaveBeenCalled()
|
||
})
|
||
test('changed connection allows recording upstream evidence but never regenerates', async () => {
|
||
row.taskId = 'original-task'
|
||
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'changed' })
|
||
await reconcileAiModelVerification('admin', providerId, row.id, proof)
|
||
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledTimes(1)
|
||
})
|
||
test('idempotent reconciliation cannot rewrite the original evidence', async () => {
|
||
row.evidence = { reconciliation: proof }
|
||
await reconcileAiModelVerification('admin', providerId, row.id, { ...proof, outcome: 'completed' })
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
expect(aiModelAudit).not.toHaveBeenCalled()
|
||
})
|
||
test('revocation inside transaction and credential-bearing notes cause no write', async () => {
|
||
;(assertAiModelAdminForWrite as jest.Mock).mockRejectedValue(new AiModelError('已撤权', 403))
|
||
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 403 })
|
||
;(assertAiModelRecoveryTextSafe as jest.Mock).mockRejectedValue(new AiModelError('包含密钥', 400))
|
||
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ status: 400 })
|
||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||
})
|
||
test('old passed rules require retest; task recovery preserves context and cannot be silently reapproved', () => {
|
||
row.status = 'passed'
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash).status).toBe('stale')
|
||
row.status = 'unknown'; row.taskId = 'old-task'
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', canResume: true, canReconcile: false })
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash, false)).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
|
||
})
|
||
|
||
test('unknown outcome retains its safe cause; an expired running record uses the interruption warning', () => {
|
||
row.message = '上游返回异常响应(502),请核查请求结果;勿重复提交'
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: row.message, canReconcile: true })
|
||
row.status = 'running'; row.message = '正在执行'
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' })
|
||
})
|
||
|
||
test('a reconciled failed upstream request exposes the checked cause without passing or enabling it', () => {
|
||
row.status = 'failed'; row.evidence = { reconciliation: proof }
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'failed', reconciled: true, canResume: false, canReconcile: false, message: `已核查上游执行失败:${proof.note}` })
|
||
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'stale', message: '连接、路由或测试规则已改变,请重新测试' })
|
||
})
|
||
test('randomized reference image validation rejects swapped layout and wrong image interpretation', async () => {
|
||
const challenge = createVerificationChallenge(), correct = await verificationReferenceImage(challenge)
|
||
expect(challenge.topLeft).not.toBe(challenge.bottomRight)
|
||
await expect(validateAiModelVerificationOutput('image', { image: correct }, challenge)).resolves.toMatchObject({ referenceLayoutMatched: true })
|
||
const swapped = { topLeft: challenge.bottomRight, bottomRight: challenge.topLeft }
|
||
await expect(validateAiModelVerificationOutput('image', { image: await verificationReferenceImage(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
|
||
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
|
||
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(challenge) }, challenge)).resolves.toMatchObject({ imageInputVerified: true })
|
||
})
|