import assert from "node:assert/strict"; import { createServer } from "node:http"; import { rm } from "node:fs/promises"; import { dbRun, withTransaction } from "../server/db.mjs"; const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; async function request(path, options = {}) { const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); const payload = await response.json().catch(() => ({})); return { response, payload }; } async function login(email = "producer@local.test") { const result = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); assert.equal(result.response.ok, true, `${email} 登录失败:${result.payload.detail || result.payload.error || ""}`); return { authorization: `Bearer ${result.payload.session.token}`, ...scope }; } const mockRunner = createServer((req, res) => { if (req.method === "POST" && req.url === "/generate") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ data: [{ b64_json: onePixelPng, mimeType: "image/png" }], metadata: { source: "local-smoke-runner", jobId: req.headers["x-ai-drama-job-id"] || "" } })); return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not_found" })); }); await new Promise((resolve) => mockRunner.listen(0, "127.0.0.1", resolve)); const runnerPort = mockRunner.address().port; const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const connectorId = `smoke-approval-connector-${suffix}`; const catalogId = `smoke-approval-catalog-${suffix}`; const routeId = `smoke-approval-route-${suffix}`; let approvalId = ""; let jobId = ""; function seedApprovalRoute() { const timestamp = new Date().toISOString(); withTransaction(() => { dbRun( `INSERT INTO model_connectors( id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, protocol_json, auth_env, created_by, created_at, updated_at ) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'Smoke Mixed Approval Runner', 'http-json', ?, ?, 'ready', 'mixed', 1, '{}', '', 'u-owner', ?, ?)`, [connectorId, JSON.stringify(["text-to-image", "single-frame"]), `http://127.0.0.1:${runnerPort}/generate`, timestamp, timestamp] ); dbRun( `INSERT INTO model_catalog_entries( id, organization_id, workspace_id, connector_id, model_key, display_name, family, capabilities_json, context_window, max_output_tokens, cost_json, status, approval_status, metadata_json, created_by, created_at, updated_at ) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', ?, 'smoke-single-frame', 'Smoke 单画面审批模型', 'smoke', ?, 4096, 1024, ?, 'active', 'approved', '{}', 'u-owner', ?, ?)`, [catalogId, connectorId, JSON.stringify(["text-to-image", "single-frame"]), JSON.stringify({ mode: "per-image", estimatedCny: 0.02, locality: "mixed" }), timestamp, timestamp] ); dbRun( `INSERT INTO model_routing_policies( id, organization_id, workspace_id, name, workflow_key, operation_key, primary_model_id, fallback_model_id, policy_mode, approval_mode, budget_limit_cny, status, policy_json, created_by, created_at, updated_at ) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'Smoke 审批路由', 'smoke-approval', 'image-keyframe', ?, NULL, 'prefer-approved', 'explicit-review', 1, 'active', ?, 'u-owner', ?, ?)`, [routeId, catalogId, JSON.stringify({ qaGate: "single-frame", smoke: true }), timestamp, timestamp] ); }); } function cleanup() { withTransaction(() => { if (jobId) { dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]); dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]); dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]); dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); } if (approvalId) dbRun("DELETE FROM model_route_approval_requests WHERE id = ?", [approvalId]); dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?, ?, ?)", [approvalId || "none", jobId || "none", routeId, connectorId]); dbRun("DELETE FROM model_routing_policies WHERE id = ?", [routeId]); dbRun("DELETE FROM model_catalog_entries WHERE id = ?", [catalogId]); dbRun("DELETE FROM model_connectors WHERE id = ?", [connectorId]); }); } try { seedApprovalRoute(); const owner = await login("producer@local.test"); const writer = await login("writer@local.test"); const approvalBody = { workflowKey: "smoke-approval", operationKey: "image-keyframe", kind: "单画面关键帧", localOnly: false, estimatedCostCny: 0.02, reason: "smoke: 需要一次受控混合成本模型执行审批" }; const withoutApproval = await request("/api/jobs", { method: "POST", headers: writer, body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform" }) }); assert.equal(withoutApproval.response.status, 403, "未携带审批单时,普通编剧不能创建受控模型任务"); assert.ok(["external_connector_requires_approval", "model_route_requires_approval"].includes(withoutApproval.payload.error), "未审批阻断错误码不正确"); const submitted = await request("/api/platform/model-route-approvals", { method: "POST", headers: writer, body: JSON.stringify(approvalBody) }); assert.equal(submitted.response.status, 201, `审批单提交失败:${submitted.payload.detail || submitted.payload.error || ""}`); approvalId = submitted.payload.approval.id; assert.equal(submitted.payload.approval.status, "submitted", "审批单初始状态必须是 submitted"); assert.equal(submitted.payload.approval.routeId, routeId, "审批单必须绑定命中的路由"); assert.equal(submitted.payload.approval.connectorId, connectorId, "审批单必须绑定命中的连接器"); const writerDecision = await request(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", headers: writer, body: JSON.stringify({ status: "approved" }) }); assert.equal(writerDecision.response.status, 403, "普通编剧不能审批模型路由申请"); const approved = await request(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", headers: owner, body: JSON.stringify({ status: "approved", expiresHours: 24, note: "smoke approval" }) }); assert.equal(approved.response.status, 200, `管理员审批失败:${approved.payload.detail || approved.payload.error || ""}`); assert.equal(approved.payload.approval.status, "approved", "审批后状态必须是 approved"); assert.ok(approved.payload.approval.expiresAt, "审批通过必须写入过期时间"); const created = await request("/api/jobs", { method: "POST", headers: writer, body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform", approvalRequestId: approvalId }) }); assert.equal(created.response.status, 201, `审批后任务创建失败:${created.payload.detail || created.payload.error || ""}`); jobId = created.payload.job.id; assert.equal(created.payload.job.status, "queued", "审批后的 ready 连接器任务应进入队列"); assert.equal(created.payload.job.modelRouteApprovalId, approvalId, "任务必须保存审批单 ID"); assert.equal(created.payload.job.request.preflight.modelRouteApproval.id, approvalId, "请求合同必须携带审批证据"); const executed = await request(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", headers: writer, body: JSON.stringify({}) }); assert.equal(executed.response.status, 200, `审批任务执行失败:${executed.payload.detail || executed.payload.error || ""}`); assert.equal(executed.payload.job.status, "completed", "本地 mock runner 应完成审批任务"); const approvalsAfterRun = await request("/api/platform/model-route-approvals", { headers: owner }); const consumed = approvalsAfterRun.payload.approvals.find((item) => item.id === approvalId); assert.ok(consumed?.consumedAt, "执行后审批单必须被消耗"); const reused = await request("/api/jobs", { method: "POST", headers: writer, body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform", approvalRequestId: approvalId }) }); assert.equal(reused.response.status, 403, "已消耗审批单不能复用创建下一次任务"); assert.equal(reused.payload.error, "model_route_approval_consumed", "审批单复用错误码不正确"); console.log(`model approval smoke passed: request, RBAC decision, job binding, execution consume (${approvalId})`); } finally { cleanup(); if (jobId) await rm(new URL(`../storage/jobs/${jobId}`, import.meta.url), { recursive: true, force: true }); await new Promise((resolve) => mockRunner.close(resolve)); }