gongxiangp2/scripts/verify-ai-model-admin.ts

111 lines
9.6 KiB
TypeScript

/** Local PostgreSQL + real HTTP acceptance. Catalog sync is read-only; never submits generation. */
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { PrismaClient } from '@prisma/client'
import { DETAIL_FONT_FAMILY } from '../lib/detail-generator/types'
const db = new URL(process.env.DATABASE_URL || '')
assert.equal(db.hostname, '127.0.0.1'); assert.equal(db.port, '55441'); assert.equal(db.pathname, '/ai_model_test')
const base = 'http://localhost:3120'
const p = new PrismaClient({ log: [] })
const evidence: Record<string, unknown> = { generationCalls: 0 }
async function session(index: number) {
const jar = new Map<string, string>()
const store = (response: Response) => { for (const cookie of response.headers.getSetCookie()) { const first = cookie.split(';')[0]; const split = first.indexOf('='); jar.set(first.slice(0, split), first.slice(split + 1)) } }
const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
const { csrfToken } = await csrf.json()
const response = await fetch(base + '/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, Cookie: [...jar].map(([k, v]) => `${k}=${v}`).join('; ') }, body: new URLSearchParams({ csrfToken, email: `ai-admin-${index}@example.invalid`, password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }) })
store(response); assert.equal(response.status, 200)
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
}
async function request(cookie: string, path = '', method = 'GET', body?: unknown) {
const response = await fetch(base + '/api/admin/ai-models' + path, { method, redirect: 'manual', headers: { ...(cookie ? { Cookie: cookie } : {}), 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
const text = await response.text()
if (process.env.DETAIL_GENERATOR_API_KEY) assert.ok(!text.includes(process.env.DETAIL_GENERATOR_API_KEY), 'No key in API response')
assert.ok(!text.includes('ai-admin-test-new-provider-key'), 'No replacement key in response')
return { status: response.status, data: text ? JSON.parse(text) : null }
}
function assertStatus(result: { status: number; data: any }, expected = 200) { assert.equal(result.status, expected, JSON.stringify(result.data)); return result.data }
async function main() {
const initialJobs = await p.detailGeneratorJob.count()
assert.equal((await request('')).status, 401)
const ordinary = await session(2)
for (const [path, method, body] of [['', 'GET', undefined], ['/import-environment', 'POST', undefined], ['/providers', 'POST', {}], ['/configuration', 'PUT', {}]] as const) assert.equal((await request(ordinary, path, method, body)).status, 403)
evidence.anonymous = 401; evidence.ordinaryAdmin = 403
const admin = await session(0)
let state = assertStatus(await request(admin))
const existing = state.providers.find((provider: any) => provider.credentialSource === 'environment')
if (!existing) state = assertStatus(await request(admin, '/import-environment', 'POST'))
const imported = state.providers.find((provider: any) => provider.credentialSource === 'environment')
assert.ok(imported?.hasApiKey)
assert.equal((await request(admin, '/import-environment', 'POST')).status, 409)
const again = assertStatus(await request(admin))
assert.equal(again.providers.filter((provider: any) => provider.credentialSource === 'environment').length, 1)
evidence.environmentImportIdempotent = true
const sync = await request(admin, `/providers/${imported.id}/sync`, 'POST', { revision: imported.revision })
state = assertStatus(sync)
const synced = state.providers.find((provider: any) => provider.id === imported.id)
assert.ok(synced.models.length >= 2)
assert.equal(new Set(synced.models.map((model: any) => model.id)).size, synced.models.length)
const known = new Set(['gemini-2.5-flash', 'nano-banana-2'])
for (const model of synced.models) if (!known.has(model.id)) { assert.equal(model.enabled, false); assert.equal(model.compatibility, 'unconfirmed') }
evidence.catalogModelCount = synced.models.filter((model: any) => model.present).length
evidence.newModelsInitiallyDisabled = true
assert.equal((await request(admin, `/providers/${imported.id}/sync`, 'POST', { revision: imported.revision })).status, 409)
evidence.staleRevisionRejected = true
assert.equal((await request(admin, '/providers', 'POST', { name: 'Unsafe fixture', baseUrl: 'https://127.0.0.1', apiKey: 'ai-admin-test-new-provider-key' })).status, 400)
evidence.privateAddressRejected = true
const created = await request(admin, '/providers', 'POST', { name: '本地验收上游', baseUrl: 'https://api.example.com/v1', apiKey: 'ai-admin-test-new-provider-key' })
state = assertStatus(created, created.status === 201 ? 201 : 200)
const newProvider = state.providers.find((provider: any) => provider.name === '本地验收上游')
assert.ok(newProvider.hasApiKey); assert.equal(newProvider.credentialSource, 'encrypted'); assert.equal(newProvider.enabled, false)
const renamed = assertStatus(await request(admin, `/providers/${newProvider.id}`, 'PATCH', { revision: newProvider.revision, name: '本地验收上游(已保存)' }))
assert.ok(renamed.providers.find((provider: any) => provider.id === newProvider.id).hasApiKey)
state = renamed
evidence.newKeyMaskedAndBlankPreserved = true
const defaultProvider = state.providers.find((provider: any) => provider.id === imported.id)
const selectable = defaultProvider.models.find((model: any) => model.id === 'gemini-2.5-pro')
assert.ok(selectable, 'Live catalog must include selected test model')
// Public protocol metadata allows copy only; confirmation cannot grant image understanding.
assert.ok(selectable.capabilities.allowedStages.includes('copy'))
selectable.enabled = true; selectable.stages = ['copy']; selectable.compatibility = 'confirmed'
const analyzer = defaultProvider.models.find((model: any) => model.id === 'gemini-2.5-flash')
assert.ok(analyzer.capabilities.allowedStages.includes('analysis'))
state.defaults.analysis = analyzer.key; state.defaults.copy = selectable.key
const input = { revision: state.revision, defaults: state.defaults, providers: state.providers.map((provider: any) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages, compatibility }: any) => ({ key, enabled, stages, compatibility })) })) }
state = assertStatus(await request(admin, '/configuration', 'PUT', input))
assert.equal(state.defaults.copy, selectable.key)
assert.equal((await request(admin, '/configuration', 'PUT', input)).status, 409)
const detail = await fetch(base + '/api/detail-generator', { headers: { Cookie: admin } }).then(response => response.json())
assert.equal(detail.config.defaults.copy, selectable.key)
assert.ok(detail.config.modelOptions.copy.some((option: any) => option.value === selectable.key && option.label.includes('gemini-2.5-pro')))
assert.ok(!detail.config.modelOptions.analysis.some((option: any) => option.value === selectable.key))
evidence.dynamicDetailDefaults = true
const document = { version: 1, name: '本地配置联动验收', product: { identifierType: 'sku', identifier: 'LOCAL-AI-TEST', variant: '', name: '本地验收产品', facts: '', assetIds: [], referenceAssetIds: [], documentAssetIds: [] }, brand: { name: '本地验收', tone: '', primaryColor: '#1677ff', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' }, models: detail.config.defaults, analysis: null, approvedAt: null, screens: [] }
const projectResponse = await fetch(base + '/api/detail-generator', { method: 'POST', headers: { Cookie: admin, 'Content-Type': 'application/json' }, body: JSON.stringify({ document }) })
assert.equal(projectResponse.status, 200)
const project = (await projectResponse.json()).project
assert.equal(project.document.models.copy, selectable.key)
const active = state.providers.find((provider: any) => provider.id === imported.id)
assertStatus(await request(admin, `/providers/${active.id}`, 'PATCH', { revision: active.revision, enabled: false }))
const editResponse = await fetch(base + `/api/detail-generator/projects/${project.id}`, { method: 'PATCH', headers: { Cookie: admin, 'Content-Type': 'application/json' }, body: JSON.stringify({ revision: project.revision, document: { ...project.document, name: '停用上游后仍可保存文字' } }) })
assert.equal(editResponse.status, 200)
evidence.disabledProviderDoesNotBlockEditing = true
state = assertStatus(await request(admin))
const disabled = state.providers.find((provider: any) => provider.id === imported.id)
state = assertStatus(await request(admin, `/providers/${disabled.id}`, 'PATCH', { revision: disabled.revision, enabled: true }))
const revokedSession = await session(1)
const revoked = await p.user.findUniqueOrThrow({ where: { email: 'ai-admin-1@example.invalid' } })
try {
await p.user.update({ where: { id: revoked.id }, data: { isSuperAdmin: false } })
assert.equal((await request(revokedSession)).status, 403)
assert.equal((await request(revokedSession, `/providers/${imported.id}/sync`, 'POST', { revision: 1 })).status, 403)
} finally { await p.user.update({ where: { id: revoked.id }, data: { isSuperAdmin: true } }) }
evidence.oldSessionRevoked = true
assert.equal(await p.detailGeneratorJob.count(), initialJobs)
evidence.jobCountUnchanged = true
await writeFile('output/detail-generator/ai-admin-http-evidence.json', JSON.stringify(evidence, null, 2))
console.log(JSON.stringify(evidence))
}
main().catch(error => { console.error(error.message); process.exitCode = 1 }).finally(() => p.$disconnect())