import assert from "node:assert/strict"; import { dbAll, 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 createdAssetIds = []; 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 assertOk(path, options = {}) { const result = await request(path, options); assert.equal(result.response.ok, true, `${options.method || "GET"} ${path}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`); return result.payload; } async function login(email) { const result = await assertOk("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); return { authorization: `Bearer ${result.session.token}`, ...scope }; } function cleanup() { withTransaction(() => { for (const assetId of createdAssetIds) { dbRun("DELETE FROM asset_governance_reviews WHERE asset_id = ?", [assetId]); dbRun("DELETE FROM asset_bindings WHERE asset_id = ?", [assetId]); dbRun("DELETE FROM asset_versions WHERE asset_id = ?", [assetId]); dbRun("DELETE FROM assets WHERE id = ?", [assetId]); dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [assetId, `%${assetId}%`]); } }); } try { const art = await login("art@local.test"); const owner = await login("producer@local.test"); const reviewer = await login("review@local.test"); const reviewerAssets = await request("/api/assets", { headers: reviewer }); assert.equal(reviewerAssets.response.status, 403, "审片角色不能因为 voice:approve 越权读取全资产库"); const risky = await assertOk("/api/assets", { method: "POST", headers: art, body: JSON.stringify({ name: `smoke-risk-${Date.now()}`, kind: "character", subtitle: "疑似既有 IP 风险", detail: "外形参考蜘蛛侠,生成分屏故事板拼图", lock: "用于测试风险阻断", provenance: { sourceType: "third-party-reference", sourceName: "网络下载截图", sourceRef: "" }, tags: ["smoke", "风险样本"] }) }); createdAssetIds.push(risky.asset.id); assert.equal(risky.asset.currentVersion.risk.status, "blocked", "疑似 IP 和多画面素材必须被扫描为 blocked"); const riskyScan = await assertOk(`/api/assets/${encodeURIComponent(risky.asset.id)}/governance/scan`, { method: "POST", headers: art, body: JSON.stringify({ rightsStatus: "submitted" }) }); assert.equal(riskyScan.scan.status, "blocked", "手动风险扫描必须返回阻断状态"); const riskyApproval = await request(`/api/assets/${encodeURIComponent(risky.asset.id)}/rights`, { method: "POST", headers: owner, body: JSON.stringify({ rightsStatus: "approved", provenance: { sourceType: "licensed", sourceName: "smoke-risk-license", sourceRef: "local-license/risk" }, evidence: { reference: "local-license/risk" }, licenseScope: "commercial" }) }); assert.equal(riskyApproval.response.status, 422, "blocked 风险资产不能被批准商用"); assert.equal(riskyApproval.payload.error, "asset_governance_blocked", "风险阻断错误码必须明确"); const clean = await assertOk("/api/assets", { method: "POST", headers: art, body: JSON.stringify({ name: `smoke-clean-prop-${Date.now()}`, kind: "prop", subtitle: "原创道具", detail: "原创蓝色防水背包,单一完整画面引用", lock: "斜挎在角色左肩,不遮挡脸", provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" }, tags: ["smoke", "原创", "道具锁"] }) }); createdAssetIds.push(clean.asset.id); assert.notEqual(clean.asset.currentVersion.risk.status, "blocked", "原创资产初始扫描不应阻塞"); const artSubmit = await assertOk(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, { method: "POST", headers: art, body: JSON.stringify({ rightsStatus: "submitted", provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" }, evidence: { reference: "local-evidence/prop-original", notes: "smoke 提交证据" }, licenseScope: "project" }) }); assert.equal(artSubmit.asset.currentVersion.rights_status, "submitted", "资产编辑者必须能提交证据"); const artApprove = await request(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, { method: "POST", headers: art, body: JSON.stringify({ rightsStatus: "approved", provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" }, evidence: { reference: "local-evidence/prop-original" }, licenseScope: "commercial" }) }); assert.equal(artApprove.response.status, 403, "资产编辑者不能做最终授权批准"); const noEvidence = await assertOk("/api/assets", { method: "POST", headers: art, body: JSON.stringify({ name: `smoke-no-evidence-${Date.now()}`, kind: "reference", subtitle: "缺证据审批测试", detail: "原创单画面参考", lock: "仅用于 smoke", provenance: { sourceType: "original", sourceName: "smoke 无证据资产", sourceRef: "local-original/no-evidence" }, tags: ["smoke"] }) }); createdAssetIds.push(noEvidence.asset.id); const missingEvidence = await request(`/api/assets/${encodeURIComponent(noEvidence.asset.id)}/rights`, { method: "POST", headers: owner, body: JSON.stringify({ rightsStatus: "approved", provenance: { sourceType: "original", sourceName: "smoke 无证据资产", sourceRef: "local-original/no-evidence" }, evidence: {}, licenseScope: "commercial" }) }); assert.equal(missingEvidence.response.status, 400, "缺少授权证据不能批准"); assert.equal(missingEvidence.payload.error, "rights_evidence_required", "缺证据错误码必须明确"); const approved = await assertOk(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, { method: "POST", headers: owner, body: JSON.stringify({ rightsStatus: "approved", provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" }, evidence: { reference: "local-evidence/prop-original", notes: "smoke 批准商用" }, licenseScope: "commercial" }) }); assert.equal(approved.asset.currentVersion.rights_status, "approved", "合规/管理员必须能批准干净资产"); assert.equal(approved.asset.currentVersion.licenseScope, "commercial", "授权范围必须持久化"); assert.ok(approved.asset.governanceReviews.length >= 2, "提交和批准都必须写入审核记录"); const gateAsset = await assertOk("/api/assets", { method: "POST", headers: art, body: JSON.stringify({ name: `smoke-unapproved-prop-${Date.now()}`, kind: "prop", subtitle: "未授权生成前拦截", detail: "原创临时道具", lock: "用于测试生成门禁", provenance: { sourceType: "original", sourceName: "smoke 临时道具", sourceRef: "local-original/unapproved" }, tags: ["smoke", "未授权"] }) }); createdAssetIds.push(gateAsset.asset.id); await assertOk(`/api/assets/${encodeURIComponent(gateAsset.asset.id)}/bindings`, { method: "POST", headers: art, body: JSON.stringify({ shotId: "shot-01", usageRole: "prop" }) }); const visualJob = await request("/api/jobs", { method: "POST", headers: art, body: JSON.stringify({ adapter: "owned-image", kind: "单画面关键帧", shotId: "shot-01", output: `storage/jobs/smoke-asset-governance-${Date.now()}/output` }) }); assert.equal(visualJob.response.status, 422, "绑定未授权道具后,图片生成必须被资产治理门禁拦截"); assert.equal(visualJob.payload.error, "asset_governance_gate_failed", "生成门禁错误码必须明确"); const residue = dbAll(`SELECT id FROM assets WHERE id IN (${createdAssetIds.map(() => "?").join(",")})`, createdAssetIds); assert.equal(residue.length, createdAssetIds.length, "清理前测试资产应仍在库中,确保测试过程真实写入"); console.log("asset governance smoke passed: RBAC, scan, approval ledger, generation gate"); } finally { cleanup(); }