feat: bootstrap commercial AI drama platform

This commit is contained in:
xz
2026-08-24 10:24:34 +08:00
commit ffb27d845b
100 changed files with 35314 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
const scripts = [
"smoke:tenant",
"smoke:mfa",
"smoke:security-events",
"smoke:device-risk",
"smoke:audit",
"smoke:identity",
"smoke:system-users",
"smoke:commercial-governance",
"smoke:invitations",
"smoke:commercial-ops",
"smoke:billing-ledger",
"smoke:release-workflow",
"smoke:delivery-portal",
"smoke:oidc",
"smoke:creator-suite",
"smoke:model-connectors",
"smoke:api-clients",
"smoke:api-rate-limit",
"smoke:ops",
"smoke:production-catalog",
"smoke:production-controls",
"smoke:worker",
"smoke:readiness",
"smoke:media-evidence",
"smoke:api-media",
"smoke:compose-evidence",
"smoke:work-items",
"smoke:tasks",
"smoke:notifications",
"smoke:project-isolation",
"smoke:project-lifecycle"
];
function run(script) {
return new Promise((resolve, reject) => {
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", script], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit"
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`${script} failed${signal ? ` with ${signal}` : ` with exit code ${code}`}`));
});
});
}
for (const script of scripts) {
console.log(`\n[smoke ${scripts.indexOf(script) + 1}/${scripts.length}] ${script}`);
await run(script);
}
console.log(`\nall smoke tests passed: ${scripts.length}`);
+124
View File
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
import { hashApiClientKey } from "../server/api-client-secrets.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"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.ok, true, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const owner = { ...(await login("producer@local.test")), ...scope };
const writer = { ...(await login("writer@local.test")), ...scope };
let clientId = "";
let firstKey = "";
let modelId = "";
try {
const denied = await request("/api/system/api-clients", writer);
assert.equal(denied.response.status, 403, "普通用户不应访问 API 客户端目录");
const invalidScopes = await request("/api/system/api-clients", owner, {
method: "POST",
body: JSON.stringify({ name: `Invalid Scope ${Date.now()}`, scopes: ["admin:all"] })
});
assert.equal(invalidScopes.response.status, 400, "不支持的 API scope 必须被拒绝");
assert.equal(invalidScopes.payload.error, "api_client_scopes_invalid", "不支持的 API scope 错误码必须稳定");
const created = await request("/api/system/api-clients", owner, {
method: "POST",
body: JSON.stringify({ name: `Smoke API Client ${Date.now()}`, scopes: ["jobs:read", "models:read"] })
});
assert.equal(created.response.status, 201, "系统管理员创建 API 客户端失败");
clientId = created.payload.client.id;
firstKey = created.payload.clientKey;
assert.ok(firstKey.startsWith("local-") && firstKey.length > 24, "新 API 密钥熵不足或格式错误");
const stored = dbGet("SELECT client_key, client_key_hash, client_key_prefix, key_version FROM api_clients WHERE id = ?", [clientId]);
assert.ok(stored, "新 API 客户端没有写入数据库");
assert.equal(stored.client_key_hash, hashApiClientKey(firstKey), "数据库中的 API 密钥摘要不匹配");
assert.equal(stored.key_version, 2, "API 客户端密钥版本没有升级");
assert.equal(stored.client_key_prefix, firstKey.slice(0, 12), "API 密钥预览前缀不匹配");
assert.notEqual(stored.client_key, firstKey, "数据库不能保存 API 密钥明文");
assert.ok(!String(stored.client_key).includes(firstKey), "数据库密钥字段不能包含可用密钥");
const listed = await request("/api/system/api-clients", owner);
assert.equal(listed.response.ok, true, "系统管理员读取 API 客户端目录失败");
const listedClient = listed.payload.apiClients.find((item) => item.id === clientId);
assert.ok(listedClient, "新 API 客户端没有出现在目录中");
assert.ok(!JSON.stringify(listedClient).includes(firstKey), "API 客户端目录不能返回完整密钥");
assert.deepEqual(listedClient.scopes, ["jobs:read", "models:read"], "API 客户端目录必须返回真实 scope");
const clientJobs = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope });
assert.equal(clientJobs.response.ok, true, "新 API 密钥不能访问已授权任务读取接口");
const firstJobId = clientJobs.payload.jobs?.[0]?.id;
if (firstJobId) {
const clientJobDetail = await request(`/api/jobs/${encodeURIComponent(firstJobId)}`, { authorization: `Bearer ${firstKey}`, ...scope });
assert.equal(clientJobDetail.response.ok, true, "jobs:read 必须允许读取任务详情");
}
const readOnlyCreate = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "POST", body: "{}" });
assert.equal(readOnlyCreate.response.status, 403, "jobs:read 不能创建生成任务");
assert.equal(readOnlyCreate.payload.error, "api_client_scope_denied", "缺少 jobs:write 时必须返回 scope 错误");
const modelRead = await request("/api/platform/models", { authorization: `Bearer ${firstKey}`, ...scope });
assert.equal(modelRead.response.ok, true, "models:read 必须允许读取模型连接器");
const modelWriteDenied = await request("/api/platform/models/owned-image", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "PATCH", body: JSON.stringify({}) });
assert.equal(modelWriteDenied.response.status, 403, "models:read 不能修改模型连接器");
assert.equal(modelWriteDenied.payload.error, "api_client_scope_denied", "缺少 models:write 时必须返回 scope 错误");
const scopeUpdate = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}`, owner, {
method: "PATCH",
body: JSON.stringify({ scopes: ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"] })
});
assert.equal(scopeUpdate.response.ok, true, "API 客户端 scope 更新失败");
assert.deepEqual(scopeUpdate.payload.client.scopes, ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"], "API 客户端 scope 更新结果不正确");
const writeKey = firstKey;
const dryRun = await request("/api/adapters/dry-run", { authorization: `Bearer ${writeKey}`, ...scope }, { method: "POST", body: JSON.stringify({ shotId: "shot-01", adapterId: "owned-image" }) });
assert.equal(dryRun.response.ok, true, "jobs:write 必须允许生成请求 dry-run");
const auditRead = await request("/api/audit", { authorization: `Bearer ${writeKey}`, ...scope });
assert.equal(auditRead.response.ok, true, "audit:read 必须允许读取审计日志");
modelId = `smoke-scope-model-${Date.now()}`;
const modelWrite = await request("/api/platform/models/register", { authorization: `Bearer ${writeKey}`, ...scope }, {
method: "POST",
body: JSON.stringify({ id: modelId, label: "Scope 回归本地模型", endpoint: "http://127.0.0.1:7879", kind: "http-json", capability: ["text-to-image"], costMode: "local" })
});
assert.equal(modelWrite.response.status, 201, "models:write 必须允许登记模型连接器");
const rotated = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}/rotate`, owner, { method: "POST", body: "{}" });
assert.equal(rotated.response.ok, true, "API 密钥轮换失败");
assert.notEqual(rotated.payload.clientKey, firstKey, "轮换后的 API 密钥不能与旧密钥相同");
const oldKey = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope });
assert.equal(oldKey.response.status, 401, "旧 API 密钥轮换后必须立即失效");
const newKey = await request("/api/jobs", { authorization: `Bearer ${rotated.payload.clientKey}`, ...scope });
assert.equal(newKey.response.ok, true, "轮换后的 API 密钥不能访问已授权任务读取接口");
console.log(`api client smoke passed: scoped access, one-time rotation, old-key revocation (${clientId})`);
} finally {
if (clientId) {
withTransaction(() => {
if (modelId) {
dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]);
}
dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]);
});
}
}
+66
View File
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { execFileSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbGet, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" };
const fixtureRelative = "storage/jobs/api-media-smoke/fixture.mp4";
const fixtureAbsolute = resolve(projectRoot, fixtureRelative);
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 };
}
const login = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) });
assert.equal(login.response.ok, true, "media API smoke login must pass");
const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope };
const connector = dbGet("SELECT * FROM model_connectors WHERE id = 'owned-i2v'");
const originalConnector = { endpoint: connector.endpoint, status: connector.status, errorMessage: connector.error_message };
const server = createServer(async (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
const jobId = req.headers["x-ai-drama-job-id"];
assert.ok(jobId, "adapter request must carry job id");
const outputRelative = `storage/jobs/${jobId}/output.mp4`;
await mkdir(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true });
execFileSync("cp", [fixtureAbsolute, resolve(projectRoot, outputRelative)]);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ outputPath: outputRelative, mimeType: "video/mp4", provider: "local-smoke" }));
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const address = server.address();
const endpoint = `http://127.0.0.1:${address.port}/generate`;
const createdJobIds = [];
try {
await mkdir(resolve(projectRoot, "storage/jobs/api-media-smoke"), { recursive: true });
execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", "color=c=0x8b3d59:s=320x568:d=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", fixtureAbsolute], { stdio: "pipe" });
dbRun("UPDATE model_connectors SET endpoint = ?, status = 'ready', error_message = '', updated_at = ? WHERE id = 'owned-i2v'", [endpoint, new Date().toISOString()]);
const created = await request("/api/jobs", { method: "POST", headers, body: JSON.stringify({ adapter: "owned-i2v", kind: "视频片段", shotId: "shot-01", output: "storage/jobs/api-media-smoke/request-output.mp4" }) });
assert.equal(created.response.ok, true, `job create failed: ${created.payload.detail || ""}`);
const jobId = created.payload.job.id;
createdJobIds.push(jobId);
const run = await request(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", headers, body: "{}" });
assert.equal(run.response.ok, true, `job run failed: ${run.payload.detail || ""}`);
assert.equal(run.payload.job.status, "completed", "HTTP generation job must complete");
assert.equal(run.payload.job.result.artifacts?.[0]?.status, "inspected", "completed job must return inspected media evidence");
assert.ok(run.payload.job.result.artifacts?.[0]?.sha256, "completed job must return SHA-256 evidence");
const artifacts = await request(`/api/production/media-artifacts?jobId=${encodeURIComponent(jobId)}`, { headers });
assert.equal(artifacts.response.ok, true, "media artifact API must be readable");
assert.equal(artifacts.payload.artifacts?.[0]?.job_id, jobId, "artifact must be linked to the generation job");
console.log(`api media smoke passed: ${jobId} -> ${artifacts.payload.artifacts[0].last_frame_path}`);
} finally {
for (const jobId of createdJobIds) {
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
await rm(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true, force: true });
}
dbRun("UPDATE model_connectors SET endpoint = ?, status = ?, error_message = ?, updated_at = ? WHERE id = 'owned-i2v'", [originalConnector.endpoint, originalConnector.status, originalConnector.errorMessage, new Date().toISOString()]);
await new Promise((resolveClose) => server.close(resolveClose));
await rm(resolve(projectRoot, "storage/jobs/api-media-smoke"), { recursive: true, force: true });
}
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
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"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login() {
const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) });
assert.equal(result.response.ok, true, "限流回归登录失败");
return { authorization: `Bearer ${result.payload.session.token}` };
}
const owner = { ...(await login()), ...scope };
const restoreOwner = { ...(await login()), ...scope };
let clientId = "";
let originalLimit = 120;
let changedLimit = false;
try {
const config = await request("/api/system/config", owner);
assert.equal(config.response.ok, true, "读取系统配置失败");
const setting = config.payload.settings.find((item) => item.key === "api.rate_limit_per_minute");
originalLimit = Number(setting?.value ?? 120);
const created = await request("/api/system/api-clients", owner, {
method: "POST",
body: JSON.stringify({ name: `Smoke Rate Limit Client ${Date.now()}`, scopes: ["jobs:read"] })
});
assert.equal(created.response.status, 201, "限流回归 API 客户端创建失败");
clientId = created.payload.client.id;
const clientHeaders = { authorization: `Bearer ${created.payload.clientKey}`, ...scope };
const updated = await request("/api/system/config", owner, {
method: "POST",
body: JSON.stringify({ settings: [{ key: "api.rate_limit_per_minute", value: 2 }] })
});
assert.equal(updated.response.ok, true, "降低 API 限流配置失败");
changedLimit = true;
const first = await request("/api/jobs", clientHeaders);
const second = await request("/api/jobs", clientHeaders);
const third = await request("/api/jobs", clientHeaders);
assert.equal(first.response.status, 200, "限流窗口内第一次请求不应被阻断");
assert.equal(second.response.status, 200, "限流窗口内第二次请求不应被阻断");
assert.equal(first.response.headers.get("x-ratelimit-limit"), "2", "响应必须返回限流上限");
assert.equal(second.response.headers.get("x-ratelimit-remaining"), "0", "第二次请求后剩余额度应为 0");
assert.equal(third.response.status, 429, "超过限流上限必须返回 429");
assert.equal(third.payload.error, "rate_limit_exceeded", "限流错误码必须稳定");
assert.ok(Number(third.response.headers.get("retry-after")) > 0, "429 必须返回 Retry-After");
assert.equal(third.response.headers.get("x-ratelimit-remaining"), "0", "429 响应剩余额度应为 0");
console.log("api rate-limit smoke passed: configured limit, headers, 429, retry-after");
} finally {
if (changedLimit) {
const restored = await request("/api/system/config", restoreOwner, {
method: "POST",
body: JSON.stringify({ settings: [{ key: "api.rate_limit_per_minute", value: originalLimit }] })
});
assert.equal(restored.response.ok, true, "限流回归未能恢复原配置");
}
if (clientId) {
withTransaction(() => {
dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]);
});
}
}
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
async function request(path, headers, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) }
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("json") ? await response.json().catch(() => ({})) : await response.text();
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.status, 200, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const systemAdmin = await login("producer@local.test");
const organizationAdmin = await login("producer2@local.test");
const ordinaryUser = await login("writer@local.test");
const globalList = await request("/api/audit?page=1&pageSize=2&result=ok", systemAdmin);
assert.equal(globalList.response.status, 200, "系统管理员读取审计列表失败");
assert.equal(globalList.payload.scope.mode, "global", "系统管理员应获得全局审计范围");
assert.equal(globalList.payload.auditLog.length, 2, "服务端分页未按 pageSize 返回");
assert.ok(globalList.payload.pagination.total > 2 && globalList.payload.pagination.hasMore, "审计分页元数据不完整");
const auditId = globalList.payload.auditLog[0]?.id;
assert.ok(auditId, "审计列表缺少事件 ID");
const detail = await request(`/api/audit/${encodeURIComponent(auditId)}`, systemAdmin);
assert.equal(detail.response.status, 200, "系统管理员读取审计详情失败");
assert.equal(detail.payload.event.id, auditId, "审计详情 ID 不一致");
assert.ok(Array.isArray(detail.payload.relatedAudit), "审计详情缺少关联审计列表");
assert.ok(Array.isArray(detail.payload.relatedSecurityEvents), "审计详情缺少安全事件列表");
const exported = await request("/api/audit/export?query=project&pageSize=5", systemAdmin);
assert.equal(exported.response.status, 200, "审计 JSON 导出失败");
assert.ok(Array.isArray(exported.payload.auditLog), "审计 JSON 导出缺少事件数组");
assert.ok(Number.isInteger(exported.payload.total), "审计 JSON 导出缺少总数");
const csv = await request("/api/audit/export?format=csv&pageSize=2", systemAdmin);
assert.equal(csv.response.status, 200, "审计 CSV 导出失败");
assert.match(csv.response.headers.get("content-type") || "", /text\/csv/);
assert.match(csv.response.headers.get("content-disposition") || "", /audit-/);
assert.match(csv.payload, /id,|"id"/);
const orgList = await request("/api/audit?page=1&pageSize=5", organizationAdmin);
assert.equal(orgList.response.status, 200, "组织管理员读取审计列表失败");
assert.equal(orgList.payload.scope.mode, "organization", "组织管理员应限制在组织范围");
assert.equal(orgList.payload.scope.organizationId, "org-northstar", "组织管理员组织范围错误");
const crossOrganization = await request("/api/audit?organizationId=org-studio-lab", organizationAdmin);
assert.equal(crossOrganization.response.status, 403, "组织管理员不应读取其他组织审计");
const denied = await request("/api/audit", ordinaryUser);
assert.equal(denied.response.status, 403, "普通用户不应读取审计中心");
console.log(`audit smoke passed: ${api}`);
+126
View File
@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const studioScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
const periodStart = "2025-01-01";
const periodEnd = "2025-01-31";
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function requestText(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, { ...options, headers: { ...headers, ...(options.headers || {}) } });
return { response, body: await response.text() };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assertOk(result, `${email} 登录`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerAuth = await login("producer@local.test");
const writerAuth = await login("writer@local.test");
const northstarAdminAuth = await login("producer2@local.test");
const ownerHeaders = { ...ownerAuth, ...studioScope };
const writerHeaders = { ...writerAuth, ...studioScope };
const northstarHeaders = { ...northstarAdminAuth, ...northstarScope };
let invoiceId = "";
try {
const writerList = await request("/api/organizations/org-studio-lab/invoices", writerHeaders);
assert.equal(writerList.response.status, 403, "普通成员不能读取账单台账");
const writerGenerate = await request("/api/organizations/org-studio-lab/invoices/generate", writerHeaders, {
method: "POST",
body: JSON.stringify({ periodStart, periodEnd })
});
assert.equal(writerGenerate.response.status, 403, "普通成员不能生成账单");
const generated = assertOk(await request("/api/organizations/org-studio-lab/invoices/generate", ownerHeaders, {
method: "POST",
body: JSON.stringify({ periodStart, periodEnd, taxRate: 6, dueDays: 15 })
}), "管理员生成账单");
assert.equal(generated.invoice.status, "draft", "新账单必须从草稿开始");
assert.equal(generated.invoice.taxRate, 6, "税率快照没有保存");
assert.ok(generated.lines.length >= 1, "账单必须包含套餐固定费或用量明细");
invoiceId = generated.invoice.id;
const duplicate = assertOk(await request("/api/organizations/org-studio-lab/invoices/generate", ownerHeaders, {
method: "POST",
body: JSON.stringify({ periodStart, periodEnd, taxRate: 99 })
}), "重复生成账单");
assert.equal(duplicate.idempotent, true, "重复生成必须幂等返回");
assert.equal(duplicate.invoice.id, invoiceId, "重复生成不能创建第二张账单");
assert.equal(duplicate.invoice.taxRate, 6, "幂等返回不能覆盖原账单快照");
const issued = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, {
method: "POST",
body: JSON.stringify({ status: "issued" })
}), "账单开票");
assert.equal(issued.invoice.status, "issued", "账单没有进入已开票状态");
assert.ok(issued.invoice.issuedAt, "开票必须记录 issuedAt");
const paid = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, {
method: "POST",
body: JSON.stringify({ status: "paid" })
}), "账单标记支付");
assert.equal(paid.invoice.status, "paid", "账单没有进入已支付状态");
assert.ok(paid.invoice.paidAt, "支付必须记录 paidAt");
const invalidTransition = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}/status`, ownerHeaders, {
method: "POST",
body: JSON.stringify({ status: "overdue" })
});
assert.equal(invalidTransition.response.status, 409, "已支付账单不能退回逾期");
assert.equal(invalidTransition.payload.error, "invoice_transition_invalid", "账单状态机错误码不稳定");
const detail = assertOk(await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, ownerHeaders), "管理员读取账单详情");
assert.equal(detail.invoice.status, "paid", "详情状态与状态流转不一致");
assert.ok(Array.isArray(detail.lines), "账单详情必须包含明细");
const writerDetail = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, writerHeaders);
assert.equal(writerDetail.response.status, 403, "普通成员不能读取账单详情");
const jsonExport = assertOk(await request(`/api/organizations/org-studio-lab/invoices/export?format=json&status=paid`, ownerHeaders), "导出账单 JSON");
assert.ok(jsonExport.invoices.some((invoice) => invoice.id === invoiceId), "JSON 导出缺少账单");
const csvExport = await requestText(`/api/organizations/org-studio-lab/invoices/export?format=csv&status=paid`, ownerHeaders);
assert.equal(csvExport.response.status, 200, "账单 CSV 导出应成功");
assert.match(csvExport.response.headers.get("content-type") || "", /text\/csv/, "账单 CSV content-type 不正确");
assert.match(csvExport.body, /invoice_number/, "账单 CSV 表头不完整");
assert.match(csvExport.body, new RegExp(invoiceId), "账单 CSV 缺少当前账单");
const crossOrganization = await request(`/api/organizations/org-studio-lab/invoices/${encodeURIComponent(invoiceId)}`, northstarHeaders);
assert.equal(crossOrganization.response.status, 403, "跨组织账单详情必须被阻断");
const northstarList = assertOk(await request("/api/organizations/org-northstar/invoices", northstarHeaders), "读取北辰组织账单");
assert.ok(!northstarList.invoices.some((invoice) => invoice.id === invoiceId), "北辰组织不能看到星河账单");
console.log(`billing ledger smoke passed: ${api}`);
} finally {
if (invoiceId) {
dbRun("DELETE FROM billing_account_events WHERE event_type LIKE 'invoice.%' AND next_json LIKE ?", [`%${invoiceId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_type = 'organization_invoice' AND target_id = ?", [invoiceId]);
dbRun("DELETE FROM organization_invoices WHERE id = ?", [invoiceId]);
}
}
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
async function request(path, headers, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.ok, true, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const owner = await login("producer@local.test");
const writer = await login("writer@local.test");
const createdEmail = `commercial-governance-${Date.now()}@local.test`;
let createdUserId = "";
let originalBilling = null;
let originalCostCenter = null;
try {
const deniedCreate = await request("/api/system/users", writer, { method: "POST", body: JSON.stringify({ displayName: "越权用户", email: `denied-${Date.now()}@local.test` }) });
assert.equal(deniedCreate.response.status, 403, "普通用户不应手动创建全局账号");
const created = await request("/api/system/users", owner, {
method: "POST",
body: JSON.stringify({ displayName: "治理回归用户", email: createdEmail, organizationId: "org-studio-lab", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" })
});
assert.equal(created.response.status, 201, `系统管理员创建用户失败:${JSON.stringify(created.payload)}`);
assert.ok(created.payload.temporaryPassword, "留空密码时必须返回一次性初始密码");
createdUserId = created.payload.user.id;
assert.ok(created.payload.user.organizations.some((item) => item.id === "org-studio-lab"), "创建用户时组织归属未落库");
assert.ok(created.payload.user.workspaces.some((item) => item.id === "ws-local-aidrama"), "创建用户时工作区归属未落库");
const resetPassword = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/reset-password`, owner, { method: "POST", body: "{}" });
assert.equal(resetPassword.response.ok, true, "管理员重置密码失败");
assert.ok(resetPassword.payload.temporaryPassword, "自动重置密码必须返回一次性密码");
const resetMfa = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/reset-mfa`, owner, { method: "POST", body: "{}" });
assert.equal(resetMfa.response.ok, true, "管理员重置 MFA 失败");
const memberships = await request(`/api/system/users/${encodeURIComponent(createdUserId)}/memberships`, owner, { method: "POST", body: JSON.stringify({ organizationId: "org-studio-lab", organizationRoleKey: "org_member", workspaceId: "ws-pilot", workspaceRoleKey: "producer", projectId: "template-original-manhua", projectRoleKey: "project_viewer" }) });
assert.equal(memberships.response.ok, true, "管理员调整用户归属失败");
assert.ok(memberships.payload.user.workspaces.some((item) => item.id === "ws-pilot"), "工作区归属调整未生效");
const commercial = await request("/api/organizations/org-studio-lab/commercial", owner);
assert.equal(commercial.response.ok, true, "读取商业运营数据失败");
assert.ok(commercial.payload.billing.billing_cycle && Array.isArray(commercial.payload.costCenters), "商业数据缺少账期或成本中心");
assert.ok(Array.isArray(commercial.payload.quotaWarnings) && Array.isArray(commercial.payload.billingHistory), "商业数据缺少预警或账单变更记录");
assert.ok(Array.isArray(commercial.payload.usageTrend), "商业数据缺少按日用量趋势");
assert.ok(commercial.payload.usageTrend.every((row) => row.day && Number.isFinite(row.units) && Number.isFinite(row.estimatedCost) && Number.isFinite(row.events)), "按日用量趋势字段不完整");
originalBilling = {
planName: commercial.payload.billing.plan_name,
billingCycle: commercial.payload.billing.billing_cycle,
currency: commercial.payload.billing.currency,
seatLimit: Number(commercial.payload.billing.seat_limit),
storageGb: Number(commercial.payload.billing.storage_gb),
monthlyClipQuota: Number(commercial.payload.billing.monthly_clip_quota),
quotaWarningPercent: Number(commercial.payload.billing.quota_warning_percent),
localRunnerOnly: Boolean(commercial.payload.billing.local_runner_only),
cloudConnectorsRequireApproval: Boolean(commercial.payload.billing.cloud_connectors_require_approval)
};
originalCostCenter = commercial.payload.costCenters[0];
const updatedBilling = await request("/api/organizations/org-studio-lab/billing", owner, { method: "PATCH", body: JSON.stringify({ ...originalBilling, billingCycle: "quarterly", quotaWarningPercent: 85 }) });
assert.equal(updatedBilling.response.ok, true, "账单周期更新失败");
assert.equal(updatedBilling.payload.billing.billing_cycle, "quarterly", "账单周期未保存");
assert.ok(Array.isArray(updatedBilling.payload.usageTrend), "保存套餐后没有返回用量趋势");
const updatedCenter = await request(`/api/organizations/org-studio-lab/cost-centers/${encodeURIComponent(originalCostCenter.id)}`, owner, { method: "PATCH", body: JSON.stringify({ monthlyBudget: Number(originalCostCenter.monthly_budget) + 1 }) });
assert.equal(updatedCenter.response.ok, true, "成本中心预算更新失败");
const exported = await request("/api/organizations/org-studio-lab/commercial/export", owner);
assert.equal(exported.response.ok, true, "商业运营数据导出失败");
assert.ok(exported.payload.costCenterDetail && exported.payload.billingHistory && Array.isArray(exported.payload.usageTrend), "导出数据不完整");
const workerStatus = await request("/api/system/worker", owner);
assert.equal(workerStatus.response.ok, true, "Worker 状态接口失败");
assert.ok(workerStatus.payload.worker.healthStatus, "Worker 状态缺少心跳健康字段");
const batch = await request("/api/admin/queue/batch", owner, { method: "POST", body: JSON.stringify({ action: "retry", jobIds: ["missing-governance-job"] }) });
assert.equal(batch.response.ok, true, "批量队列运维接口失败");
assert.equal(batch.payload.failureCount, 1, "批量队列应返回逐任务失败结果");
const deniedBatch = await request("/api/admin/queue/batch", writer, { method: "POST", body: JSON.stringify({ action: "retry", jobIds: ["missing-governance-job"] }) });
assert.equal(deniedBatch.response.status, 403, "普通用户不应执行批量队列运维");
console.log(`commercial governance smoke passed: ${api}`);
} finally {
if (originalBilling) {
await request("/api/organizations/org-studio-lab/billing", owner, { method: "PATCH", body: JSON.stringify(originalBilling) });
}
if (originalCostCenter) {
await request(`/api/organizations/org-studio-lab/cost-centers/${encodeURIComponent(originalCostCenter.id)}`, owner, { method: "PATCH", body: JSON.stringify({ monthlyBudget: Number(originalCostCenter.monthly_budget) }) });
}
if (createdUserId) {
dbRun("DELETE FROM users WHERE id = ?", [createdUserId]);
}
}
+197
View File
@@ -0,0 +1,197 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const studioScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function requestText(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { ...headers, ...(options.headers || {}) }
});
return { response, body: await response.text() };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function billingSnapshot(billing) {
return {
planName: billing.plan_name,
seatLimit: Number(billing.seat_limit),
storageGb: Number(billing.storage_gb),
monthlyClipQuota: Number(billing.monthly_clip_quota),
localRunnerOnly: Boolean(billing.local_runner_only),
cloudConnectorsRequireApproval: Boolean(billing.cloud_connectors_require_approval)
};
}
function quotaSnapshots(quotas) {
return (quotas || []).map((quota) => ({ id: quota.id, limitValue: Number(quota.limitValue ?? quota.limit_value) }));
}
function mutatedBilling(original, commercial) {
const reserved = Number(commercial.seat?.reserved || 0);
const clipUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "clip").map((quota) => Number(quota.usedValue || 0)), 0));
const storageUsed = Math.ceil(Math.max(...(commercial.quotas || []).filter((quota) => quota.metric === "storage").map((quota) => Number(quota.usedValue || 0)), 0));
return {
planName: `${original.planName} · Smoke`,
seatLimit: Math.max(original.seatLimit + 1, reserved + 1),
storageGb: Math.max(original.storageGb + 1, storageUsed + 1),
monthlyClipQuota: Math.max(original.monthlyClipQuota + 1, clipUsed + 1),
localRunnerOnly: !original.localRunnerOnly,
cloudConnectorsRequireApproval: !original.cloudConnectorsRequireApproval
};
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assertOk(result, `${email} 登录`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
async function patchBilling(headers, organizationId, body) {
return request(`/api/organizations/${encodeURIComponent(organizationId)}/billing`, headers, {
method: "PATCH",
body: JSON.stringify(body)
});
}
async function restoreOrganization(label, headers, organizationId, originalBilling, originalQuotas) {
const failures = [];
for (const quota of originalQuotas) {
const result = await request(`/api/organizations/${encodeURIComponent(organizationId)}/quotas/${encodeURIComponent(quota.id)}`, headers, {
method: "PATCH",
body: JSON.stringify({ limitValue: quota.limitValue })
});
if (!result.response.ok) failures.push(`${label} quota ${quota.id}: ${result.response.status} ${result.payload.error || ""}`);
}
const billingResult = await patchBilling(headers, organizationId, originalBilling);
if (!billingResult.response.ok) failures.push(`${label} billing: ${billingResult.response.status} ${billingResult.payload.error || ""}`);
if (failures.length) throw new Error(`恢复 ${label} 配置失败:${failures.join("; ")}`);
}
const ownerAuth = await login("producer@local.test");
const writerAuth = await login("writer@local.test");
const orgAdminAuth = await login("producer2@local.test");
const ownerHeaders = { ...ownerAuth, ...studioScope };
const writerHeaders = { ...writerAuth, ...studioScope };
const orgAdminHeaders = { ...orgAdminAuth, ...northstarScope };
let studioOriginal = null;
let studioOriginalQuotas = [];
let northstarOriginal = null;
let northstarOriginalQuotas = [];
try {
const studioBefore = assertOk(await request("/api/organizations/org-studio-lab/commercial", ownerHeaders), "系统管理员读取星河商业配置");
assert.ok(studioBefore.billing && studioBefore.seat && Array.isArray(studioBefore.quotas), "商业配置必须包含套餐、席位和工作区配额");
studioOriginal = billingSnapshot(studioBefore.billing);
studioOriginalQuotas = quotaSnapshots(studioBefore.quotas);
const writerCommercial = await request("/api/organizations/org-studio-lab/commercial", writerHeaders);
assert.equal(writerCommercial.response.status, 403, "普通编剧不应读取组织商业配置");
const writerBilling = await patchBilling(writerHeaders, "org-studio-lab", studioOriginal);
assert.equal(writerBilling.response.status, 403, "普通编剧不应修改组织套餐");
const usageBefore = assertOk(await request("/api/organizations/org-studio-lab/usage?from=2026-01-01&to=2026-12-31&page=1&pageSize=10", ownerHeaders), "系统管理员读取事件级用量");
assert.ok(Array.isArray(usageBefore.items), "用量明细必须返回事件列表");
assert.ok(usageBefore.pagination && Number.isInteger(usageBefore.pagination.total), "用量明细必须返回分页信息");
assert.ok(usageBefore.summary && Array.isArray(usageBefore.summary.byKind), "用量明细必须返回分类汇总");
assert.ok(usageBefore.facets && Array.isArray(usageBefore.facets.workspaces) && Array.isArray(usageBefore.facets.users), "用量明细必须返回筛选选项");
const workspaceUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?workspaceId=ws-local-aidrama&from=2026-01-01&to=2026-12-31", ownerHeaders), "按工作区筛选用量");
assert.ok(workspaceUsage.items.every((item) => item.workspaceId === "ws-local-aidrama"), "工作区筛选不能返回其他工作区事件");
const costCenterUsage = assertOk(await request("/api/organizations/org-studio-lab/usage?costCenter=local-gpu&from=2026-01-01&to=2026-12-31", ownerHeaders), "按成本中心筛选用量");
assert.ok(costCenterUsage.items.every((item) => item.costCenter === "local-gpu"), "成本中心筛选不能返回其他成本中心事件");
const writerUsage = await request("/api/organizations/org-studio-lab/usage", writerHeaders);
assert.equal(writerUsage.response.status, 403, "普通编剧不应读取组织用量明细");
const writerUsageExport = await request("/api/organizations/org-studio-lab/usage/export?format=json", writerHeaders);
assert.equal(writerUsageExport.response.status, 403, "普通编剧不应导出组织用量明细");
const usageJsonExport = assertOk(await request("/api/organizations/org-studio-lab/usage/export?format=json&from=2026-01-01&to=2026-12-31", ownerHeaders), "导出用量 JSON");
assert.ok(Array.isArray(usageJsonExport.items), "JSON 用量导出必须包含事件列表");
const usageCsvExport = await requestText("/api/organizations/org-studio-lab/usage/export?format=csv&from=2026-01-01&to=2026-12-31", ownerHeaders);
assert.equal(usageCsvExport.response.status, 200, "导出用量 CSV 应成功");
assert.match(usageCsvExport.response.headers.get("content-type") || "", /text\/csv/, "用量 CSV content-type 不正确");
assert.match(usageCsvExport.body, /"id","created_at","workspace_id"/, "用量 CSV 表头不完整");
const studioMutation = mutatedBilling(studioOriginal, studioBefore);
const studioAfterUpdate = assertOk(await patchBilling(ownerHeaders, "org-studio-lab", studioMutation), "系统管理员更新星河套餐");
assert.equal(studioAfterUpdate.billing.plan_name, studioMutation.planName, "套餐名称未保存");
assert.equal(Number(studioAfterUpdate.billing.seat_limit), studioMutation.seatLimit, "席位上限未保存");
assert.equal(Boolean(studioAfterUpdate.billing.local_runner_only), studioMutation.localRunnerOnly, "本地 Runner 策略未保存");
assert.equal(Boolean(studioAfterUpdate.billing.cloud_connectors_require_approval), studioMutation.cloudConnectorsRequireApproval, "外部连接器审批策略未保存");
const studioClipQuota = studioBefore.quotas.find((quota) => quota.metric === "clip");
assert.ok(studioClipQuota, "星河组织缺少片段配额");
const nextClipLimit = Math.max(Number(studioClipQuota.usedValue || 0), Math.min(studioMutation.monthlyClipQuota, Number(studioClipQuota.limitValue || 0) + 1));
const clipQuotaUpdate = assertOk(await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ limitValue: nextClipLimit })
}), "更新星河片段配额");
assert.equal(Number(clipQuotaUpdate.quotas.find((quota) => quota.id === studioClipQuota.id)?.limitValue), nextClipLimit, "片段配额未保存");
const overPlanQuota = await request(`/api/organizations/org-studio-lab/quotas/${encodeURIComponent(studioClipQuota.id)}`, ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ limitValue: studioMutation.monthlyClipQuota + 1 })
});
assert.equal(overPlanQuota.response.status, 409, "工作区片段配额不能超过组织套餐");
assert.equal(overPlanQuota.payload.error, "quota_above_plan", "超套餐配额错误码不稳定");
if (Number(studioBefore.seat?.reserved || 0) > 1) {
const belowReserved = await patchBilling(ownerHeaders, "org-studio-lab", { seatLimit: Number(studioBefore.seat.reserved) - 1 });
assert.equal(belowReserved.response.status, 409, "席位上限不能低于已占用和待处理邀请");
assert.equal(belowReserved.payload.error, "seat_limit_below_reserved", "席位冲突错误码不稳定");
}
const northstarBefore = assertOk(await request("/api/organizations/org-northstar/commercial", orgAdminHeaders), "组织管理员读取北辰商业配置");
assert.ok(northstarBefore.billing && northstarBefore.seat, "组织管理员商业配置返回不完整");
northstarOriginal = billingSnapshot(northstarBefore.billing);
northstarOriginalQuotas = quotaSnapshots(northstarBefore.quotas);
assert.ok((await request("/api/context", orgAdminHeaders)).payload.context.permissions.includes("billing:manage"), "组织管理员缺少套餐管理权限");
const northstarMutation = mutatedBilling(northstarOriginal, northstarBefore);
const northstarAfterUpdate = assertOk(await patchBilling(orgAdminHeaders, "org-northstar", northstarMutation), "组织管理员更新北辰套餐");
assert.equal(northstarAfterUpdate.billing.plan_name, northstarMutation.planName, "组织管理员套餐名称未保存");
assert.equal(Number(northstarAfterUpdate.billing.seat_limit), northstarMutation.seatLimit, "组织管理员席位上限未保存");
console.log(`commercial ops smoke passed: ${api}`);
} finally {
const failures = [];
if (studioOriginal) {
try {
await restoreOrganization("星河组织", ownerHeaders, "org-studio-lab", studioOriginal, studioOriginalQuotas);
} catch (error) {
failures.push(error.message);
}
}
if (northstarOriginal) {
try {
await restoreOrganization("北辰组织", orgAdminHeaders, "org-northstar", northstarOriginal, northstarOriginalQuotas);
} catch (error) {
failures.push(error.message);
}
}
if (failures.length) throw new Error(failures.join("; "));
}
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" };
const rootRelative = "storage/compositions/smoke-compose-evidence";
const rootAbsolute = resolve(projectRoot, rootRelative);
const clips = [`${rootRelative}/a.mp4`, `${rootRelative}/b.mp4`];
const outputPath = `${rootRelative}/final.mp4`;
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 };
}
const login = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) });
assert.equal(login.response.ok, true, "compose smoke login must pass");
const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope };
let compositionId = "";
try {
await mkdir(rootAbsolute, { recursive: true });
for (const [index, clip] of clips.entries()) {
execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", `color=c=${index ? "0x3d7f54" : "0x7f4b2f"}:s=320x568:d=0.6`, "-c:v", "libx264", "-pix_fmt", "yuv420p", resolve(projectRoot, clip)], { stdio: "pipe" });
}
const composed = await request("/api/production/compose", { method: "POST", headers, body: JSON.stringify({ version: "smoke-compose-evidence", clips, outputPath }) });
assert.equal(composed.response.ok, true, `compose failed: ${composed.payload.detail || ""}`);
compositionId = composed.payload.composition.id;
assert.equal(composed.payload.composition.status, "completed", "FFmpeg 合成必须完成");
assert.equal(composed.payload.composition.result.artifact.status, "inspected", "合成结果必须登记媒体证据");
assert.ok(composed.payload.composition.result.artifact.sha256, "合成结果必须登记 SHA-256");
assert.ok(composed.payload.composition.result.artifact.last_frame_path, "合成结果必须提取实际末帧");
await stat(resolve(projectRoot, outputPath));
console.log(`compose evidence smoke passed: ${outputPath} -> ${composed.payload.composition.result.artifact.last_frame_path}`);
} finally {
if (compositionId) {
dbRun("DELETE FROM media_artifacts WHERE composition_id = ?", [compositionId]);
dbRun("DELETE FROM media_compositions WHERE id = ?", [compositionId]);
}
await rm(rootAbsolute, { recursive: true, force: true });
}
+214
View File
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
const api = "http://127.0.0.1:8787";
const runId = Date.now();
let createdAssetId = "";
let uploadedAssetId = "";
let jobId = "";
let originalSeries = null;
let originalEpisode = null;
async function cleanup() {
const assetIds = [createdAssetId, uploadedAssetId].filter(Boolean);
const assetPaths = assetIds.length
? dbAll(`SELECT storage_path FROM asset_versions WHERE asset_id IN (${assetIds.map(() => "?").join(",")})`, assetIds)
: [];
withTransaction(() => {
if (assetIds.length) {
const placeholders = assetIds.map(() => "?").join(",");
dbRun(`DELETE FROM asset_bindings WHERE asset_id IN (${placeholders})`, assetIds);
dbRun(`DELETE FROM asset_versions WHERE asset_id IN (${placeholders})`, assetIds);
dbRun(`DELETE FROM assets WHERE id IN (${placeholders})`, assetIds);
}
if (jobId) {
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [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 usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
if (originalSeries?.id) {
dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ?", [originalSeries.title, originalSeries.logline, originalSeries.format, originalSeries.visual_style, originalSeries.continuity_rule, originalSeries.show_engine, originalSeries.updated_at, originalSeries.id]);
}
if (originalEpisode?.id) {
dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [originalEpisode.title, originalEpisode.status, originalEpisode.target_duration_sec, originalEpisode.hook, originalEpisode.cliffhanger, originalEpisode.updated_at, originalEpisode.id]);
}
});
await Promise.all(assetPaths.map((asset) => rm(resolve(import.meta.dirname, "..", asset.storage_path), { force: true }).catch(() => {})));
for (const assetId of assetIds) {
await rm(resolve(import.meta.dirname, "..", "storage", "assets", "thunder-mouth", assetId), { recursive: true, force: true });
}
}
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json();
assert.equal(response.ok, true, `${options.method || "GET"} ${path}: ${response.status} ${payload.detail || payload.error || ""}`);
return payload;
}
async function rawRequest(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
return { response, payload: await response.json().catch(() => ({})) };
}
try {
const login = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
const headers = {
authorization: `Bearer ${login.session.token}`,
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const reviewerLogin = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
});
const reviewerHeaders = {
authorization: `Bearer ${reviewerLogin.session.token}`,
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const reviewerAssets = await rawRequest("/api/assets", { headers: reviewerHeaders });
assert.equal(reviewerAssets.response.status, 403, "没有资产权限的审片用户不能读取资产库");
const reviewerUpload = await rawRequest("/api/assets/upload", {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ data: Buffer.from("reviewer-must-be-blocked").toString("base64"), fileName: "blocked.txt" })
});
assert.equal(reviewerUpload.response.status, 403, "没有资产权限的审片用户不能上传资产");
const assets = await request("/api/assets", { headers });
assert.ok(Array.isArray(assets.assets), "资产列表必须是数组");
assert.ok(assets.assets.length > 0, "示例项目必须有资产");
const invalidUpload = await rawRequest("/api/assets/upload", {
method: "POST",
headers,
body: JSON.stringify({ data: "not-base64@@", fileName: "invalid.txt" })
});
assert.equal(invalidUpload.response.status, 400, "非法 Base64 必须被服务端拒绝");
assert.equal(invalidUpload.payload.error, "asset_data_invalid", "非法 Base64 必须返回明确错误码");
const created = await request("/api/assets", {
method: "POST",
headers,
body: JSON.stringify({ name: `smoke-asset-${Date.now()}`, kind: "reference", subtitle: "smoke" })
});
assert.ok(created.asset?.id, "创建资产必须返回 id");
createdAssetId = created.asset.id;
const bound = await request(`/api/assets/${encodeURIComponent(created.asset.id)}/bindings`, {
method: "POST",
headers,
body: JSON.stringify({ shotId: "shot-01", usageRole: "reference" })
});
assert.equal(bound.asset.bindings.some((item) => item.shot_id === "shot-01"), true, "资产绑定必须写入镜头");
const uploaded = await request("/api/assets/upload", {
method: "POST",
headers,
body: JSON.stringify({ data: Buffer.from("creator-suite-smoke").toString("base64"), fileName: "smoke-reference.txt", kind: "reference" })
});
assert.ok(uploaded.asset?.currentVersion?.storage_path?.includes("storage/assets/"), "本地上传必须写入 storage/assets");
uploadedAssetId = uploaded.asset.id;
const firstHash = createHash("sha256").update("creator-suite-smoke").digest("hex");
assert.equal(uploaded.asset.currentVersion.contentSha256, firstHash, "上传资产必须登记 SHA-256");
const uploadedVersion = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/upload`, {
method: "POST",
headers,
body: JSON.stringify({ data: Buffer.from("creator-suite-smoke-v2").toString("base64"), fileName: "smoke-reference-v2.txt", mimeType: "text/plain", versionNote: "smoke file version" })
});
const secondHash = createHash("sha256").update("creator-suite-smoke-v2").digest("hex");
assert.equal(uploadedVersion.asset.currentVersion.version_number, 2, "文件上传版本必须递增");
assert.equal(uploadedVersion.asset.currentVersion.contentSha256, secondHash, "文件版本必须登记新的 SHA-256");
const verification = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/verify`, { method: "POST", headers, body: JSON.stringify({}) });
assert.equal(verification.verification.verified, true, "当前资产文件完整性校验必须通过");
const contentResponse = await fetch(`${api}/api/assets/${encodeURIComponent(uploaded.asset.id)}/content`, { headers });
assert.equal(contentResponse.status, 200, "资产内容读取必须成功");
assert.equal(contentResponse.headers.get("etag"), `"${secondHash}"`, "资产内容响应必须返回 ETag");
assert.equal(await contentResponse.text(), "creator-suite-smoke-v2", "资产内容必须与当前文件版本一致");
const versioned = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions`, {
method: "POST",
headers,
body: JSON.stringify({ versionNote: "smoke version" })
});
assert.equal(versioned.asset.currentVersion.version_number, 3, "资产版本必须递增");
const restored = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/${encodeURIComponent(versioned.asset.versions.find((version) => version.version_number === 1).id)}/restore`, {
method: "POST",
headers,
body: JSON.stringify({})
});
assert.equal(restored.asset.currentVersion.version_number, 1, "资产版本必须支持恢复到历史版本");
const locked = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/lock`, {
method: "POST",
headers,
body: JSON.stringify({ lockStatus: "locked" })
});
assert.equal(locked.asset.lockStatus, "locked", "资产锁定状态必须持久化");
const assistant = await request("/api/assistant/query", {
method: "POST",
headers,
body: JSON.stringify({ question: "检查连续性", actionId: "continuity" })
});
assert.ok(assistant.answer?.length > 10, "项目助手必须返回真实答案");
const originalGraph = await request("/api/production/graph", { headers });
originalSeries = originalGraph.graph.series;
originalEpisode = originalGraph.graph.episode;
const bibleUpdate = await request("/api/production/bible", {
method: "PATCH",
headers,
body: JSON.stringify({
logline: "smoke test:雷暴来临前的原创避险选择。",
continuityRule: "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。"
})
});
assert.equal(bibleUpdate.bible.series.logline, "smoke test:雷暴来临前的原创避险选择。", "系列 Bible 更新必须写入数据库");
assert.equal(bibleUpdate.bible.series.continuity_rule, "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。", "连续性规则必须写入数据库");
const graphAfterBibleUpdate = await request("/api/production/graph", { headers });
assert.equal(graphAfterBibleUpdate.graph.series.logline, "smoke test:雷暴来临前的原创避险选择。", "生产图谱必须读回最新系列 Bible");
const usageBeforeJob = await request("/api/usage", { headers });
const job = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: "local-tts", kind: "单句 TTS 试听", shotId: "shot-01", output: `voices/auditions/smoke-${runId}-line.wav` })
});
jobId = job.job?.id || "";
assert.ok(["queued", "blocked"].includes(job.job?.status), "单句试听必须写入可追踪任务");
if (job.job?.status === "blocked") assert.match(job.job.errorMessage || "", /连接器|planned|not-connected/i, "连接器未就绪时必须记录阻塞原因");
assert.ok(job.jobs.some((item) => item.output === `voices/auditions/smoke-${runId}-line.wav`), "任务输出路径必须可追踪");
const usageAfterJob = await request("/api/usage", { headers });
const beforeClip = usageBeforeJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0;
const afterClip = usageAfterJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0;
assert.ok(afterClip > beforeClip, "生成任务写入后必须递增片段额度用量");
console.log("creator-suite smoke passed");
} finally {
await cleanup();
}
+261
View File
@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { access, mkdir, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { dbGet, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = "thunder-mouth";
const runId = Date.now();
const sourceRoot = `storage/smoke-delivery-portal-${runId}`;
const sourcePath = `${sourceRoot}/clip.mp4`;
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
const manifestPath = `${sourceRoot}/manifest.json`;
const deliveryVersion = `portal-${runId}`;
const deliveryDraftVersion = `portal-draft-${runId}`;
const batchId = `smoke-portal-batch-${runId}`;
const deliveryLabel = `smoke-portal-delivery-${runId}`;
const draftDeliveryLabel = `smoke-portal-draft-${runId}`;
const auditTargetIds = new Set();
const accessLinkIds = [];
const releaseIds = [];
const deliveryIds = [];
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 requestBinary(path) {
const response = await fetch(`${api}${path}`);
return { response, body: Buffer.from(await response.arrayBuffer()), contentType: response.headers.get("content-type") || "" };
}
function expectStatus(result, status, label) {
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload?.detail || result.payload?.error || ""}`);
return result.payload;
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload?.detail || result.payload?.error || ""}`);
return result.payload;
}
function isoFromNow(days) {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
const producerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "producer login");
const reviewerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
}), "reviewer login");
const producerHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const reviewerHeaders = {
authorization: `Bearer ${reviewerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const crossOrganizationHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]);
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
assert.ok(projectShot?.id, "smoke project must have a starter shot");
assert.ok(producerUser?.id, "producer user must exist");
let channelId = "";
let publishedReleaseId = "";
let primaryToken = "";
let expiredToken = "";
let revokedToken = "";
let seeded = false;
try {
const channels = expectOk(await request("/api/production/delivery-channels", { headers: reviewerHeaders }), "reviewer can view delivery channels");
channelId = channels.channels.find((channel) => channel.kind === "local-file")?.id || "";
assert.ok(channelId, "workspace must expose a local-file delivery channel");
expectStatus(await request(`/api/production/releases/unknown/access-links`, { method: "POST", headers: reviewerHeaders, body: JSON.stringify({}) }), 403, "reviewer cannot create access link");
const draft = expectStatus(await request("/api/production/deliveries", {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ version: deliveryDraftVersion, channel: draftDeliveryLabel })
}), 201, "producer creates unpublished delivery");
deliveryIds.push(draft.delivery.id);
const draftRelease = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(draft.delivery.id)}/releases`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ channelId, submit: false })
}), 201, "producer creates unpublished release draft");
releaseIds.push(draftRelease.release.id);
const delivery = expectStatus(await request("/api/production/deliveries", {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ version: deliveryVersion, channel: deliveryLabel })
}), 201, "producer creates published delivery draft");
deliveryIds.push(delivery.delivery.id);
await mkdir(resolve(projectRoot, sourceRoot), { recursive: true });
await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence"));
await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence"));
await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-delivery-portal-manifest", deliveryId: delivery.delivery.id }, null, 2));
const timestamp = new Date().toISOString();
dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'smoke portal batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]);
dbRun("INSERT INTO delivery_batch_items(id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, '{}', ?)", [`smoke-portal-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
seeded = true;
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ channelId, submit: true, idempotencyKey: `portal-release-${runId}` })
}), 201, "producer submits portal release");
publishedReleaseId = release.release.id;
releaseIds.push(publishedReleaseId);
const approved = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/decision`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ status: "approved", note: "portal smoke approval" })
}), "producer approves portal release");
assert.equal(approved.release.status, "approved", "release must be approved before publishing");
const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/publish`, { method: "POST", headers: producerHeaders, body: "{}" }), "producer publishes portal release");
assert.equal(published.release.status, "published", "release must be published");
await access(resolve(projectRoot, published.release.output_path));
const unpublishedLinkAttempt = await request(`/api/production/releases/${encodeURIComponent(draftRelease.release.id)}/access-links`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ maxDownloads: 1 })
});
expectStatus(unpublishedLinkAttempt, 409, "unpublished release cannot be shared");
const reviewerList = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { headers: reviewerHeaders }), "reviewer can list access links");
assert.deepEqual(reviewerList.accessLinks, [], "new release must have no access links");
expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ maxDownloads: 1 })
}), 403, "reviewer cannot create access link");
const link = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ recipientName: "星河发行部", recipientEmail: "release@example.test", expiresAt: isoFromNow(2), maxDownloads: 2 })
}), 201, "producer creates primary access link");
primaryToken = link.token;
accessLinkIds.push(link.link.id);
auditTargetIds.add(link.link.id);
assert.ok(primaryToken, "create response must return plaintext token once");
assert.match(link.portalUrl, /\/portal\//, "create response must return portal URL");
const storedLink = dbGet("SELECT token_hash, token_hint FROM delivery_access_links WHERE id = ?", [link.link.id]);
assert.notEqual(storedLink.token_hash, primaryToken, "database must not store plaintext token");
assert.equal(storedLink.token_hash, createHash("sha256").update(primaryToken).digest("hex"), "stored token hash must match SHA-256");
assert.ok(storedLink.token_hint && !storedLink.token_hint.includes(primaryToken), "stored token hint must not contain the full token");
const publicPortal = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}`, { headers: { "user-agent": "portal-smoke" } }), "public portal metadata works");
assert.equal(publicPortal.delivery.version, deliveryVersion, "portal must identify the published delivery version");
assert.equal(publicPortal.portal.downloadsRemaining, 2, "portal must expose the initial download budget");
assert.equal(publicPortal.review.status, "pending", "portal must start in a pending client review state");
assert.ok(!JSON.stringify(publicPortal).includes("storage/"), "public metadata must not expose internal storage paths");
const changesRequested = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, {
method: "POST",
headers: { "user-agent": "portal-feedback-smoke" },
body: JSON.stringify({ decision: "changes_requested", reviewerName: "星河发行部", reviewerEmail: "release@example.test", message: "请复核第 02 镜头的字幕安全区。" })
}), "client can request delivery changes");
assert.equal(changesRequested.review.status, "changes_requested", "portal must expose the latest client change request");
assert.equal(changesRequested.review.count, 1, "portal must count client feedback submissions");
const approvedFeedback = expectOk(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, {
method: "POST",
headers: { "user-agent": "portal-feedback-smoke" },
body: JSON.stringify({ decision: "approved", reviewerName: "星河发行部", reviewerEmail: "release@example.test", message: "已确认当前交付版本。" })
}), "client can accept delivery");
assert.equal(approvedFeedback.review.status, "approved", "portal must expose the latest client acceptance");
assert.equal(approvedFeedback.review.count, 2, "portal must retain the append-only feedback count");
expectStatus(await request(`/api/public/delivery/${encodeURIComponent(primaryToken)}/feedback`, {
method: "POST",
headers: { "user-agent": "portal-feedback-smoke" },
body: JSON.stringify({ decision: "changes_requested", message: "" })
}), 400, "change request without a message must be rejected");
const internalFeedback = expectOk(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-feedback`, { headers: reviewerHeaders }), "internal users can inspect client feedback");
assert.equal(internalFeedback.feedback.length, 2, "internal feedback ledger must expose both client decisions");
assert.equal(internalFeedback.feedback[0].decision, "approved", "internal feedback must be newest first");
const releaseFile = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=release`);
assert.equal(releaseFile.response.status, 200, "public release file must download");
assert.match(releaseFile.contentType, /application\/json/, "release file must be JSON");
assert.match(releaseFile.body.toString("utf8"), /ai-drama-platform\.delivery-release\.v1/, "release JSON must be delivered");
const manifestFile = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=manifest`);
assert.equal(manifestFile.response.status, 200, "public manifest file must download");
assert.match(manifestFile.body.toString("utf8"), /smoke-delivery-portal-manifest/, "manifest JSON must be delivered");
const limitedDownload = await requestBinary(`/api/public/delivery/${encodeURIComponent(primaryToken)}/file?kind=release`);
assert.equal(limitedDownload.response.status, 429, "download budget must be enforced");
const expired = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ recipientName: "过期链接", expiresAt: isoFromNow(2), maxDownloads: 1 })
}), 201, "producer creates expiring access link");
expiredToken = expired.token;
accessLinkIds.push(expired.link.id);
auditTargetIds.add(expired.link.id);
dbRun("UPDATE delivery_access_links SET expires_at = ?, status = 'active' WHERE id = ?", ["2000-01-01T00:00:00.000Z", expired.link.id]);
expectStatus(await request(`/api/public/delivery/${encodeURIComponent(expiredToken)}`, { headers: { "user-agent": "portal-smoke" } }), 410, "expired link must return 410");
const revoked = expectStatus(await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ recipientName: "撤销链接", expiresAt: isoFromNow(2), maxDownloads: 1 })
}), 201, "producer creates revocable access link");
revokedToken = revoked.token;
accessLinkIds.push(revoked.link.id);
auditTargetIds.add(revoked.link.id);
expectStatus(await request(`/api/production/access-links/${encodeURIComponent(revoked.link.id)}/revoke`, { method: "POST", headers: reviewerHeaders, body: "{}" }), 403, "reviewer cannot revoke access link");
expectOk(await request(`/api/production/access-links/${encodeURIComponent(revoked.link.id)}/revoke`, { method: "POST", headers: producerHeaders, body: "{}" }), "producer revokes access link");
expectStatus(await request(`/api/public/delivery/${encodeURIComponent(revokedToken)}`, { headers: { "user-agent": "portal-smoke" } }), 404, "revoked link must return 404");
const crossOrganization = await request(`/api/production/releases/${encodeURIComponent(publishedReleaseId)}/access-links`, { headers: crossOrganizationHeaders });
assert.ok([403, 404].includes(crossOrganization.response.status), `cross organization access links must be hidden or denied: ${crossOrganization.response.status}`);
const eventCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_events WHERE link_id IN (?, ?, ?)", accessLinkIds);
assert.ok(Number(eventCount?.count || 0) >= 6, "portal view/download/rejection activity must be audited");
console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`);
} finally {
for (const targetId of auditTargetIds) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [targetId]);
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_events WHERE link_id = ?", [linkId]);
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_links WHERE id = ?", [linkId]);
for (const releaseId of releaseIds) {
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
dbRun("DELETE FROM delivery_releases WHERE id = ?", [releaseId]);
}
if (seeded) {
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]);
}
for (const deliveryId of deliveryIds) {
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [deliveryId]);
dbRun("DELETE FROM deliveries WHERE id = ?", [deliveryId]);
}
await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true });
}
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
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, deviceId, deviceLabel) {
const result = await request("/api/auth/login", {
method: "POST",
headers: { "x-device-id": deviceId, "x-device-label": deviceLabel },
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.status, 200, `${email} 登录失败`);
assert.ok(result.payload.session?.token, `${email} 未返回登录会话`);
return {
authorization: `Bearer ${result.payload.session.token}`,
session: result.payload.session
};
}
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const ownerDeviceId = `smoke-owner-device-${suffix}`;
const secondDeviceId = `smoke-owner-device-2-${suffix}`;
const ownerLabel = "Device Risk Smoke Browser";
const owner = await login("producer@local.test", ownerDeviceId, ownerLabel);
let clientId = "";
let clientKey = "";
try {
assert.equal(owner.session.riskLevel, "high", "首次出现的设备应该标记为高风险");
assert.ok(owner.session.riskScore >= 75, "首次设备风险分数不足");
assert.ok(!JSON.stringify(owner.session).includes(ownerDeviceId), "登录响应不能返回原始设备 ID");
const firstDevices = await request("/api/auth/devices", { headers: owner });
assert.equal(firstDevices.response.status, 200, "登录用户无法读取自己的设备列表");
assert.ok(Array.isArray(firstDevices.payload.devices), "设备接口必须返回 devices 数组");
const firstDevice = firstDevices.payload.devices.find((device) => device.label === ownerLabel);
assert.ok(firstDevice, "首次登录没有登记设备");
assert.equal(firstDevice.status, "known", "首次设备默认不能直接标记为信任");
assert.ok(!JSON.stringify(firstDevice).includes(ownerDeviceId), "设备接口不能返回原始设备 ID");
assert.ok(!Object.keys(firstDevice).some((key) => /(hash|token|secret|key)/i.test(key)), "设备接口不能返回设备摘要或令牌字段");
const storedDevice = dbGet("SELECT device_key_hash, fingerprint_hash FROM auth_devices WHERE id = ?", [firstDevice.id]);
assert.ok(storedDevice && /^[a-f0-9]{64}$/i.test(storedDevice.device_key_hash), "数据库必须保存设备 ID 的摘要");
assert.notEqual(storedDevice.device_key_hash, ownerDeviceId, "数据库不能保存原始设备 ID");
const secondLogin = await login("producer@local.test", ownerDeviceId, ownerLabel);
assert.ok(secondLogin.session.riskScore < owner.session.riskScore, "同设备再次登录的风险分数应该下降");
assert.equal(secondLogin.session.riskLevel, "medium", "已知但未信任的设备应该是中风险");
const trusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/trust`, { method: "POST", headers: owner, body: "{}" });
assert.equal(trusted.response.status, 200, "信任设备失败");
assert.equal(trusted.payload.device.status, "trusted", "设备信任状态没有落库");
assert.ok(trusted.payload.devices.some((device) => device.id === firstDevice.id && device.status === "trusted"), "设备列表没有返回信任状态");
const trustedLogin = await login("producer@local.test", ownerDeviceId, ownerLabel);
assert.equal(trustedLogin.session.riskLevel, "low", "信任设备再次登录应该是低风险");
assert.ok(trustedLogin.session.riskScore < secondLogin.session.riskScore, "信任设备风险分数应该继续下降");
const untrusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/untrust`, { method: "POST", headers: owner, body: "{}" });
assert.equal(untrusted.response.status, 200, "取消设备信任失败");
assert.equal(untrusted.payload.device.status, "known", "取消信任后设备状态不正确");
const secondDeviceLogin = await login("producer@local.test", secondDeviceId, "Second Smoke Browser");
assert.equal(secondDeviceLogin.session.riskLevel, "high", "新设备应该重新标记为高风险");
const ownerEvents = await request("/api/auth/security-events", { headers: owner });
assert.equal(ownerEvents.response.status, 200, "无法读取设备安全事件");
const eventTypes = new Set(ownerEvents.payload.events.map((event) => event.eventType));
assert.ok(eventTypes.has("device.first_seen"), "安全事件缺少 device.first_seen");
assert.ok(eventTypes.has("device.trusted"), "安全事件缺少 device.trusted");
assert.ok(eventTypes.has("device.untrusted"), "安全事件缺少 device.untrusted");
assert.ok(!JSON.stringify(ownerEvents.payload.events).includes(ownerDeviceId), "安全事件不能记录原始设备 ID");
const systemDetail = await request("/api/system/users/u-owner", { headers: owner });
assert.equal(systemDetail.response.status, 200, "系统管理员无法读取自己的设备风险详情");
assert.ok(Array.isArray(systemDetail.payload.devices), "系统用户详情缺少设备风险列表");
assert.ok(systemDetail.payload.devices.some((device) => device.latestRiskLevel === "high"), "系统用户详情没有显示高风险设备");
assert.ok(systemDetail.payload.sessions.some((session) => session.riskLevel && session.device), "系统用户详情会话缺少设备风险字段");
assert.ok(!JSON.stringify(systemDetail.payload).includes(ownerDeviceId), "系统用户详情不能返回原始设备 ID");
const deniedWriter = await login("writer@local.test", `smoke-writer-device-${suffix}`, "Writer Smoke Browser");
const ordinarySystemDenied = await request("/api/system/users/u-owner", { headers: deniedWriter });
assert.equal(ordinarySystemDenied.response.status, 403, "普通用户不能访问系统用户设备详情");
const createdClient = await request("/api/system/api-clients", {
method: "POST",
headers: {
authorization: owner.authorization,
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama"
},
body: JSON.stringify({ name: `Device Risk Smoke ${suffix}`, scopes: ["jobs:read"] })
});
assert.equal(createdClient.response.status, 201, "创建设备风险 smoke API Client 失败");
clientId = createdClient.payload.client.id;
clientKey = createdClient.payload.clientKey;
assert.ok(clientKey, "设备风险 smoke 没有拿到 API Client 密钥");
const apiClientDenied = await request("/api/auth/devices", { headers: { authorization: `Bearer ${clientKey}` } });
assert.equal(apiClientDenied.response.status, 403, "API Client 不能管理浏览器设备");
assert.equal(apiClientDenied.payload.error, "device_management_unavailable", "设备管理 API Client 错误码不稳定");
console.log(`device risk smoke passed: registration, trust boundary, risk scoring, and sensitive-field checks (${firstDevice.id})`);
} finally {
if (clientId) {
withTransaction(() => {
dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]);
});
}
}
+161
View File
@@ -0,0 +1,161 @@
import { createHmac } from "node:crypto";
import { dbRun } from "../server/db.mjs";
const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
async function request(path, options = {}) {
const response = await fetch(`${base}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function totp(secret, timestamp = Date.now()) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const normalized = String(secret).replace(/=+$/g, "").toUpperCase();
let buffer = 0;
let bits = 0;
const bytes = [];
for (const character of normalized) {
buffer = (buffer << 5) | alphabet.indexOf(character);
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((buffer >> bits) & 0xff);
}
}
const counter = Math.floor(timestamp / 30000);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", Buffer.from(bytes)).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
return String(value % 1000000).padStart(6, "0");
}
const ownerLogin = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
assert(ownerLogin.response.ok && ownerLogin.payload.session?.token, "identity smoke owner login failed");
const ownerHeaders = { authorization: `Bearer ${ownerLogin.payload.session.token}` };
const writerLogin = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" })
});
assert(writerLogin.response.ok && writerLogin.payload.session?.token, "identity smoke writer login failed");
const writerHeaders = { authorization: `Bearer ${writerLogin.payload.session.token}` };
const writerDenied = await request("/api/system/identity", { headers: writerHeaders });
assert(writerDenied.response.status === 403, "普通成员读取身份中心必须被后端拒绝");
const initial = await request("/api/system/identity", { headers: ownerHeaders });
assert(initial.response.ok && initial.payload.policy && Array.isArray(initial.payload.providers), "管理员读取身份中心失败");
const provider = await request("/api/system/identity/providers", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({
name: `Smoke OIDC ${Date.now()}`,
kind: "oidc",
issuerUrl: "http://127.0.0.1:8799/issuer",
clientId: "smoke-client",
clientSecretRef: "SMOKE_OIDC_CLIENT_SECRET",
scopes: ["openid", "profile", "email"],
enabled: true
})
});
assert(provider.response.status === 201 && provider.payload.providers.some((item) => item.enabled), "OIDC 提供商登记失败");
const smokeProvider = provider.payload.providers.at(-1);
const policy = await request("/api/system/identity/policy", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ ssoEnabled: true, localLoginFallback: true, mfaRequiredForAdmins: true })
});
assert(policy.response.ok && policy.payload.policy.ssoEnabled && policy.payload.policy.mfaRequiredForAdmins, "身份策略保存失败");
const enrollmentLogin = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
assert(enrollmentLogin.response.ok && enrollmentLogin.payload.mfaEnrollmentRequired && enrollmentLogin.payload.enrollmentToken, "强制管理员 MFA 必须进入首次绑定流程");
const enrollmentSetup = await request("/api/auth/mfa/enroll/setup", {
method: "POST",
body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken })
});
assert(enrollmentSetup.response.status === 201 && enrollmentSetup.payload.setup?.secret, "MFA enrollment setup failed");
const enrollmentComplete = await request("/api/auth/mfa/enroll/enable", {
method: "POST",
body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken, methodId: enrollmentSetup.payload.setup.methodId, code: totp(enrollmentSetup.payload.setup.secret) })
});
assert(enrollmentComplete.response.ok && enrollmentComplete.payload.session?.token, "MFA enrollment completion failed");
const publicProviders = await request("/api/auth/sso/providers");
assert(publicProviders.response.ok && publicProviders.payload.providers.some((item) => item.id === smokeProvider.id), "公开 SSO 提供商目录读取失败");
const directory = await request("/api/system/identity/directory-syncs", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ name: `Smoke SCIM ${Date.now()}`, syncMode: "provision-and-deprovision", schedule: "manual" })
});
assert(directory.response.status === 201 && directory.payload.token && directory.payload.directorySync?.endpointPath, "SCIM 目录创建失败");
const directoryId = directory.payload.directorySync.id;
const enabledDirectory = await request(`/api/system/identity/directory-syncs/${encodeURIComponent(directoryId)}`, {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ enabled: true })
});
assert(enabledDirectory.response.ok && enabledDirectory.payload.directorySyncs.some((item) => item.id === directoryId && item.enabled), "SCIM 目录启用失败");
const scimHeaders = { authorization: `Bearer ${directory.payload.token}` };
const scimList = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: scimHeaders });
assert(scimList.response.ok && Array.isArray(scimList.payload.Resources), "SCIM 用户列表读取失败");
const scimEmail = `scim-${Date.now()}@local.test`;
const scimCreate = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, {
method: "POST",
headers: scimHeaders,
body: JSON.stringify({ userName: scimEmail, displayName: "SCIM 测试成员", active: true })
});
assert(scimCreate.response.status === 201 && scimCreate.payload.userName === scimEmail, "SCIM 用户创建失败");
const scimUserId = scimCreate.payload.id;
const scimPatch = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users/${encodeURIComponent(scimUserId)}`, {
method: "PATCH",
headers: scimHeaders,
body: JSON.stringify({ Operations: [{ op: "Replace", path: "active", value: false }] })
});
assert(scimPatch.response.ok && scimPatch.payload.active === false, "SCIM 用户停用失败");
const invalidScim = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: { authorization: "Bearer invalid-token" } });
assert(invalidScim.response.status === 401, "无效 SCIM 令牌必须被拒绝");
const cleanupMfa = await request("/api/auth/mfa/disable", {
method: "POST",
headers: { authorization: `Bearer ${enrollmentComplete.payload.session.token}` },
body: JSON.stringify({ currentPassword: "Demo@123456", code: totp(enrollmentSetup.payload.setup.secret) })
});
assert(cleanupMfa.response.ok && cleanupMfa.payload.enabled === false, "identity smoke cleanup MFA failed");
await request(`/api/system/identity/providers/${encodeURIComponent(smokeProvider.id)}`, {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ enabled: false })
});
await request("/api/system/identity/policy", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ ssoEnabled: false, mfaRequiredForAdmins: false })
});
dbRun("DELETE FROM directory_sync_tokens WHERE directory_sync_id IN (SELECT id FROM directory_syncs WHERE name LIKE 'Smoke SCIM %')");
dbRun("DELETE FROM directory_syncs WHERE name LIKE 'Smoke SCIM %'");
dbRun("DELETE FROM identity_providers WHERE name LIKE 'Smoke OIDC %'");
dbRun("DELETE FROM users WHERE email = ?", [scimEmail]);
console.log(`identity smoke passed: ${base}`);
+114
View File
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
const base = 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"
};
async function request(path, options = {}) {
const response = await fetch(`${base}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assertOk(result, `${email} login`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerHeaders = { ...(await login("producer@local.test")), ...scope };
const writerHeaders = { ...(await login("writer@local.test")), ...scope };
const inviteEmail = `invitation-smoke-${Date.now()}@local.test`;
let invitationId = "";
let invitationState = "";
try {
const writerAttempt = await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers: writerHeaders,
body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" })
});
assert.equal(writerAttempt.response.status, 403, "普通编剧不应创建组织邀请");
assert.equal(writerAttempt.payload.error, "permission_denied", "普通编剧越权邀请需要稳定错误码");
const before = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取邀请前商业席位");
const created = assertOk(await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" })
}), "管理员创建邀请");
const firstInvitation = created.invitation;
invitationId = firstInvitation.id;
invitationState = firstInvitation.status;
assert.ok(firstInvitation.inviteToken, "创建邀请必须返回一次性令牌");
assert.ok(firstInvitation.acceptUrl?.startsWith("/register?invite="), "创建邀请必须返回注册链接");
const afterCreate = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取创建后商业席位");
assert.equal(Number(afterCreate.seat.pending), Number(before.seat.pending) + 1, "创建邀请后必须预留一个待入组席位");
const firstPreview = assertOk(await request(`/api/invitations/preview?token=${encodeURIComponent(firstInvitation.inviteToken)}`), "预览首次注册链接");
assert.equal(firstPreview.invitation.email, inviteEmail, "首次注册链接邮箱不匹配");
const duplicate = await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" })
});
assert.equal(duplicate.response.status, 409, "重复邀请必须被阻止");
assert.equal(duplicate.payload.error, "invitation_already_pending", "重复邀请错误码不稳定");
const resent = assertOk(await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/resend`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
}), "管理员重发邀请");
const secondInvitation = resent.invitation;
invitationState = secondInvitation.status;
assert.ok(secondInvitation.inviteToken, "重发邀请必须返回新令牌");
assert.notEqual(secondInvitation.inviteToken, firstInvitation.inviteToken, "重发不能复用旧令牌");
const oldPreview = await request(`/api/invitations/preview?token=${encodeURIComponent(firstInvitation.inviteToken)}`);
assert.equal(oldPreview.response.status, 404, "重发后旧注册链接必须失效");
const secondPreview = assertOk(await request(`/api/invitations/preview?token=${encodeURIComponent(secondInvitation.inviteToken)}`), "预览新注册链接");
assert.equal(secondPreview.invitation.email, inviteEmail, "新注册链接邮箱不匹配");
const revoked = assertOk(await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/revoke`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
}), "管理员撤销邀请");
invitationState = revoked.invitation.status;
assert.equal(invitationState, "revoked", "撤销后邀请状态必须为 revoked");
const revokedPreview = await request(`/api/invitations/preview?token=${encodeURIComponent(secondInvitation.inviteToken)}`);
assert.equal(revokedPreview.response.status, 404, "撤销后注册链接必须失效");
const afterRevoke = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取撤销后商业席位");
assert.equal(Number(afterRevoke.seat.pending), Number(before.seat.pending), "撤销邀请后必须释放待入组席位");
const listed = assertOk(await request("/api/organizations/org-studio-lab", { headers: ownerHeaders }), "读取组织邀请列表");
assert.ok(!listed.invitations.some((item) => item.id === invitationId), "撤销邀请不能继续出现在待处理列表");
} finally {
if (invitationId && invitationState === "pending") {
await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(invitationId)}/revoke`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
});
}
}
console.log(`invitations smoke passed: ${base}`);
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
import { registerJobArtifacts } from "../server/media-artifacts.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const orgId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = "thunder-mouth";
const shotId = "shot-01";
const shot = dbGet("SELECT * FROM shots WHERE id = ?", [shotId]);
assert.ok(shot, "smoke shot must exist");
const nextShot = dbGet("SELECT id, first_frame_path FROM shots WHERE episode_id = ? AND shot_number = ?", [shot.episode_id, Number(shot.shot_number) + 1]);
const originalLastFrame = shot.last_frame_path;
const originalNextFirstFrame = nextShot?.first_frame_path || "";
const jobId = `smoke-media-${Date.now()}`;
const relative = `storage/jobs/${jobId}/output.mp4`;
const absolute = resolve(projectRoot, relative);
const context = {
organization: { id: orgId },
workspace: { id: workspaceId },
project: { id: projectId },
user: { id: "u-owner" }
};
try {
await mkdir(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true });
execFileSync("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", "color=c=0x1f5f8b:s=320x568:d=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", absolute], { stdio: "pipe" });
const timestamp = new Date().toISOString();
dbRun("INSERT INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, output_path, qa_status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, '视频片段', 'owned-i2v', 'completed', ?, 'wait', 'u-owner', ?, ?)", [jobId, orgId, workspaceId, projectId, shot.episode_id, shotId, relative, timestamp, timestamp]);
const artifacts = await registerJobArtifacts(context, { id: jobId, episode_id: shot.episode_id, shot_id: shotId, kind: "视频片段", output_path: relative, created_by: "u-owner" }, { outputPath: relative });
const artifact = artifacts.find((item) => item.kind === "video");
assert.equal(artifact?.status, "inspected", "视频文件必须通过 FFprobe 检查");
assert.equal((artifact.sha256 || "").length, 64, "视频证据必须登记 SHA-256");
assert.ok(artifact.last_frame_path, "视频证据必须提取实际末帧路径");
await stat(resolve(projectRoot, artifact.last_frame_path));
assert.equal(dbGet("SELECT last_frame_path FROM shots WHERE id = ?", [shotId]).last_frame_path, artifact.last_frame_path, "镜头必须继承实际末帧");
if (nextShot) assert.equal(dbGet("SELECT first_frame_path FROM shots WHERE id = ?", [nextShot.id]).first_frame_path, artifact.last_frame_path, "下一镜头必须继承上一镜头实际末帧");
console.log(`media evidence smoke passed: ${artifact.path} -> ${artifact.last_frame_path}`);
} finally {
withTransaction(() => {
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
dbRun("UPDATE shots SET last_frame_path = ?, updated_at = ? WHERE id = ?", [originalLastFrame, new Date().toISOString(), shotId]);
if (nextShot) dbRun("UPDATE shots SET first_frame_path = ?, updated_at = ? WHERE id = ?", [originalNextFirstFrame, new Date().toISOString(), nextShot.id]);
});
await rm(resolve(projectRoot, `storage/jobs/${jobId}`), { recursive: true, force: true });
}
+140
View File
@@ -0,0 +1,140 @@
import { createHmac } from "node:crypto";
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scopeHeaders = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const runId = Date.now();
const email = `mfa-${runId}@local.test`;
function cleanup() {
const smokeUsers = dbAll("SELECT id FROM users WHERE email = ?", [email]);
withTransaction(() => {
for (const user of smokeUsers) {
dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [email, user.id]);
dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [user.id]);
dbRun("UPDATE usage_events SET user_id = NULL WHERE user_id = ?", [user.id]);
dbRun("DELETE FROM users WHERE id = ?", [user.id]);
}
dbRun("DELETE FROM invitations WHERE email = ?", [email]);
});
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function request(path, headers = {}, init = {}) {
const response = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function decodeBase32(input) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const normalized = String(input || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
let bits = 0;
let value = 0;
const bytes = [];
for (const character of normalized) {
value = (value << 5) | alphabet.indexOf(character);
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((value >> bits) & 0xff);
}
}
return Buffer.from(bytes);
}
function totp(secret, timestamp = Date.now()) {
const counter = Math.floor(timestamp / 30000);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
return String(value % 1000000).padStart(6, "0");
}
try {
const ownerLogin = await request("/api/auth/login", {}, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
assert(ownerLogin.response.ok, "系统管理员登录失败");
const ownerHeaders = { authorization: `Bearer ${ownerLogin.payload.session.token}`, ...scopeHeaders };
const invitation = await request("/api/organizations/org-studio-lab/invitations", ownerHeaders, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, roleKey: "writer", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" })
});
assert(invitation.response.status === 201 && invitation.payload.invitation.inviteToken, "MFA 测试用户邀请创建失败");
const registration = await request("/api/auth/register", {}, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inviteToken: invitation.payload.invitation.inviteToken, displayName: "MFA 测试用户", password: "MfaSmoke@123456" })
});
assert(registration.response.status === 201, "MFA 测试用户注册失败");
const userHeaders = { authorization: `Bearer ${registration.payload.session.token}`, ...scopeHeaders };
const initialStatus = await request("/api/auth/mfa", userHeaders);
assert(initialStatus.response.ok && initialStatus.payload.enabled === false, "新用户 MFA 初始状态必须是关闭");
const firstSetup = await request("/api/auth/mfa/setup", userHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } });
assert(firstSetup.response.status === 201 && firstSetup.payload.setup.methodId, "MFA 取消测试初始化失败");
const cancelledSetup = await request("/api/auth/mfa/setup/cancel", userHeaders, {
method: "POST",
body: JSON.stringify({ methodId: firstSetup.payload.setup.methodId }),
headers: { "content-type": "application/json" }
});
assert(cancelledSetup.response.ok && cancelledSetup.payload.enabled === false && cancelledSetup.payload.method === null, "取消 MFA 初始化必须清理未启用密钥");
const setup = await request("/api/auth/mfa/setup", userHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } });
assert(setup.response.status === 201 && setup.payload.setup.secret && setup.payload.setup.methodId, "MFA 初始化必须返回一次性密钥和方法 ID");
const secret = setup.payload.setup.secret;
const code = totp(secret);
const enabled = await request("/api/auth/mfa/enable", userHeaders, {
method: "POST",
body: JSON.stringify({ methodId: setup.payload.setup.methodId, code }),
headers: { "content-type": "application/json" }
});
assert(enabled.response.ok && enabled.payload.enabled === true, "MFA 启用失败");
const passwordLogin = await request("/api/auth/login", {}, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: "MfaSmoke@123456" })
});
assert(passwordLogin.response.ok && passwordLogin.payload.mfaRequired === true && passwordLogin.payload.challengeToken, "启用 MFA 后密码登录必须进入二次验证挑战");
const wrongCode = await request("/api/auth/login/mfa", {}, {
method: "POST",
body: JSON.stringify({ challengeToken: passwordLogin.payload.challengeToken, code: "000000" }),
headers: { "content-type": "application/json" }
});
assert(wrongCode.response.status === 401, "错误 MFA 验证码必须被拒绝");
const completedLogin = await request("/api/auth/login/mfa", {}, {
method: "POST",
body: JSON.stringify({ challengeToken: passwordLogin.payload.challengeToken, code: totp(secret) }),
headers: { "content-type": "application/json" }
});
assert(completedLogin.response.ok && completedLogin.payload.session?.token && completedLogin.payload.context?.currentUser?.email === email, "正确 MFA 验证码必须建立登录会话");
const activeHeaders = { authorization: `Bearer ${completedLogin.payload.session.token}`, ...scopeHeaders };
const activeStatus = await request("/api/auth/mfa", activeHeaders);
assert(activeStatus.response.ok && activeStatus.payload.enabled === true, "已登录用户应能读取 MFA 状态");
const disabled = await request("/api/auth/mfa/disable", activeHeaders, {
method: "POST",
body: JSON.stringify({ currentPassword: "MfaSmoke@123456", code: totp(secret) }),
headers: { "content-type": "application/json" }
});
assert(disabled.response.ok && disabled.payload.enabled === false, "MFA 关闭失败");
console.log(`mfa smoke passed: ${base}`);
} finally {
cleanup();
}
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { withTransaction, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const runnerPort = Number(process.env.AI_DRAMA_SMOKE_MODEL_PORT || 8792);
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.ok, true, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const runner = createServer((req, res) => {
if (req.url === "/v1/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise((resolve) => runner.listen(runnerPort, "127.0.0.1", resolve));
let modelId = "";
try {
const owner = { ...(await login("producer@local.test")), ...scope };
const writer = { ...(await login("writer@local.test")), ...scope };
const writerModels = await request("/api/platform/models", writer);
assert.equal(writerModels.response.status, 403, "普通用户不应访问模型中台");
const publicLocal = await request("/api/platform/models/register", owner, {
method: "POST",
body: JSON.stringify({ label: "smoke invalid local", endpoint: "https://example.com/v1", kind: "http-json", capability: ["image"], costMode: "local" })
});
assert.equal(publicLocal.response.status, 403, "local 连接器指向公网时必须被拒绝");
assert.equal(publicLocal.payload.error, "local_only_endpoint_required", "local 公网地址错误码不正确");
const externalWithoutApproval = await request("/api/platform/models/register", owner, {
method: "POST",
body: JSON.stringify({ label: "smoke invalid external", endpoint: "https://example.com/v1", kind: "openai-compatible", capability: ["image"], costMode: "mixed", approvalRequired: false })
});
assert.equal(externalWithoutApproval.response.status, 400, "混合连接器未开启审批时必须被拒绝");
assert.equal(externalWithoutApproval.payload.error, "external_connector_approval_required", "外部审批错误码不正确");
const created = await request("/api/platform/models/register", owner, {
method: "POST",
body: JSON.stringify({
label: `Smoke Editable Runner ${Date.now()}`,
endpoint: `http://127.0.0.1:${runnerPort}/v1`,
kind: "http-json",
capability: ["text-to-image", "single-frame"],
costMode: "local",
protocol: { healthRoute: "health" }
})
});
assert.equal(created.response.status, 201, "本地自定义 Runner 注册失败");
modelId = created.payload.model.id;
assert.equal(created.payload.model.approvalRequired, false, "本地连接器不应被强制标记为外部审批");
const edited = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, {
method: "PATCH",
body: JSON.stringify({ label: "Smoke Edited Local Runner", capability: ["text-to-image", "single-frame", "continuity-lock"], protocol: { healthRoute: "health" } })
});
assert.equal(edited.response.ok, true, "本地连接器编辑失败");
assert.equal(edited.payload.model.label, "Smoke Edited Local Runner", "连接器名称编辑未保存");
assert.ok(edited.payload.model.capability.includes("continuity-lock"), "连接器能力标签编辑未保存");
const invalidEditEndpoint = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, {
method: "PATCH",
body: JSON.stringify({ endpoint: "https://example.com/v1", costMode: "local" })
});
assert.equal(invalidEditEndpoint.response.status, 403, "编辑时 local 公网地址必须被拒绝");
const invalidEditApproval = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, {
method: "PATCH",
body: JSON.stringify({ endpoint: "https://example.com/v1", costMode: "mixed", approvalRequired: false })
});
assert.equal(invalidEditApproval.response.status, 400, "编辑时 mixed 未审批必须被拒绝");
const probed = await request(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, owner, { method: "POST", body: "{}" });
assert.equal(probed.response.ok, true, `本地连接器探活失败:${JSON.stringify(probed.payload)}`);
assert.equal(probed.payload.model.status, "ready", "本地连接器探活后必须进入 ready");
console.log(`model connector smoke passed: RBAC, local-only, approval gate, edit, probe (${modelId})`);
} finally {
await new Promise((resolve) => runner.close(resolve));
if (modelId) {
withTransaction(() => {
dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]);
});
}
}
+121
View File
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const localScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
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 };
}
function headers(token, scope) {
return { authorization: `Bearer ${token}`, ...scope };
}
async function login(email) {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.ok, true, `${email} login failed: ${JSON.stringify(result.payload)}`);
return result.payload.session.token;
}
const tokens = [];
try {
const anonymous = await request("/api/notifications");
assert.equal(anonymous.response.status, 401, "anonymous notifications must be rejected");
const ownerToken = await login("producer@local.test");
tokens.push(ownerToken);
const ownerHeaders = headers(ownerToken, localScope);
const preferences = await request("/api/notification-preferences", { headers: ownerHeaders });
assert.equal(preferences.response.ok, true, JSON.stringify(preferences.payload));
assert.equal(preferences.payload.preferences.length, 7, "notification preference catalog must be complete");
assert.equal(preferences.payload.preferences.find((item) => item.category === "billing")?.enabled, true, "billing preference must default to enabled");
const disabledPreferences = await request("/api/notification-preferences/billing", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ enabled: false })
});
assert.equal(disabledPreferences.response.ok, true, JSON.stringify(disabledPreferences.payload));
const preferenceMarker = `preference-${Date.now()}`;
const preferenceEvent = await request("/api/system/notifications/test", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ event: "quota.warning", payload: { targetId: preferenceMarker, detail: "通知偏好 smoke 事件" } })
});
assert.equal(preferenceEvent.response.ok, true, JSON.stringify(preferenceEvent.payload));
const ownerAfterPreference = await request("/api/notifications?limit=80", { headers: ownerHeaders });
assert.equal(ownerAfterPreference.response.ok, true, JSON.stringify(ownerAfterPreference.payload));
assert.ok(!ownerAfterPreference.payload.notifications.some((item) => item.targetId === preferenceMarker), "disabled category must not create a notification for that user");
const restoredPreferences = await request("/api/notification-preferences/billing", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ enabled: true })
});
assert.equal(restoredPreferences.response.ok, true, JSON.stringify(restoredPreferences.payload));
const marker = `smoke-${Date.now()}`;
const event = await request("/api/system/notifications/test", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ event: "system.changed", payload: { targetId: marker, detail: "通知 smoke 验证事件" } })
});
assert.equal(event.response.ok, true, `notification event failed: ${JSON.stringify(event.payload)}`);
assert.ok(event.payload.userNotifications?.length >= 1, "event must create in-app notifications for eligible recipients");
const ownerInbox = await request("/api/notifications?limit=40", { headers: ownerHeaders });
assert.equal(ownerInbox.response.ok, true, JSON.stringify(ownerInbox.payload));
const created = ownerInbox.payload.notifications.find((item) => item.targetId === marker);
assert.ok(created, "owner must see the notification in the current workspace");
assert.ok(ownerInbox.payload.unreadCount >= 1, "new notification must increase unread count");
const writerToken = await login("writer@local.test");
tokens.push(writerToken);
const writerHeaders = headers(writerToken, localScope);
const writerReadOwnerMessage = await request(`/api/notifications/${encodeURIComponent(created.id)}`, {
method: "PATCH",
headers: writerHeaders,
body: JSON.stringify({ read: true })
});
assert.equal(writerReadOwnerMessage.response.status, 404, "a different user cannot mark another user's notification read");
const ownerRead = await request(`/api/notifications/${encodeURIComponent(created.id)}`, {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ read: true })
});
assert.equal(ownerRead.response.ok, true, JSON.stringify(ownerRead.payload));
assert.ok(ownerRead.payload.notifications.some((item) => item.id === created.id && item.read), "single notification must become read");
const ownerReadAll = await request("/api/notifications/read-all", { method: "POST", headers: ownerHeaders, body: "{}" });
assert.equal(ownerReadAll.response.ok, true, JSON.stringify(ownerReadAll.payload));
assert.equal(ownerReadAll.payload.unreadCount, 0, "read-all must clear the current user's unread count");
const northstar = await request("/api/notifications?limit=40", { headers: headers(ownerToken, northstarScope) });
assert.equal(northstar.response.ok, true, JSON.stringify(northstar.payload));
assert.ok(northstar.payload.notifications.every((item) => item.organizationId === "org-northstar"), "cross-organization notifications must be isolated");
assert.ok(!northstar.payload.notifications.some((item) => item.targetId === marker), "northstar must not see local notification IDs");
const northstarPreferences = await request("/api/notification-preferences", { headers: headers(ownerToken, northstarScope) });
assert.equal(northstarPreferences.response.ok, true, JSON.stringify(northstarPreferences.payload));
assert.equal(northstarPreferences.payload.preferences.find((item) => item.category === "billing")?.enabled, true, "notification preferences must be organization scoped");
console.log(`notifications smoke passed: owner=${ownerInbox.payload.notifications.length}, preference-filtered=true, writer-cross-user=blocked, northstar=${northstar.payload.notifications.length}`);
} finally {
for (const token of tokens) {
await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${token}` } }).catch(() => {});
}
}
+254
View File
@@ -0,0 +1,254 @@
import { spawn } from "node:child_process";
import { createHmac, generateKeyPairSync, createSign } from "node:crypto";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const apiPort = 8797;
const issuerPort = 8798;
const api = `http://127.0.0.1:${apiPort}`;
const issuer = `http://127.0.0.1:${issuerPort}`;
const secret = `smoke-secret-${Date.now()}`;
const tempRoot = await mkdtemp(`${tmpdir()}/ai-drama-oidc-`);
const dbPath = resolve(tempRoot, "platform.sqlite");
const runId = Date.now();
const mockEmail = `sso-${runId}@local.test`;
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const publicJwk = { ...publicKey.export({ format: "jwk" }), kid: "smoke-key", use: "sig", alg: "RS256" };
let authorizationRequest = null;
let authorizationCode = null;
let apiProcess = null;
let providerServer = null;
let childLogs = "";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function json(res, status, payload) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify(payload));
}
function redirect(res, location) {
res.writeHead(302, { location, "cache-control": "no-store" });
res.end();
}
function base64url(value) {
return Buffer.from(value).toString("base64url");
}
function decodeBase32(value) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
let bits = 0;
let buffer = 0;
const output = [];
for (const character of normalized) {
buffer = (buffer << 5) | alphabet.indexOf(character);
bits += 5;
if (bits >= 8) {
bits -= 8;
output.push((buffer >> bits) & 0xff);
}
}
return Buffer.from(output);
}
function totp(secret, timestamp = Date.now()) {
const counter = Math.floor(timestamp / 30000);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
return String(value % 1000000).padStart(6, "0");
}
function signIdToken(claims) {
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: "smoke-key" }));
const payload = base64url(JSON.stringify(claims));
const input = `${header}.${payload}`;
const signer = createSign("RSA-SHA256");
signer.update(input);
signer.end();
return `${input}.${signer.sign(privateKey).toString("base64url")}`;
}
async function readRequestBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
async function startProvider() {
providerServer = createServer(async (req, res) => {
const url = new URL(req.url, issuer);
if (req.method === "GET" && url.pathname === "/.well-known/openid-configuration") {
return json(res, 200, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`
});
}
if (req.method === "GET" && url.pathname === "/jwks") return json(res, 200, { keys: [publicJwk] });
if (req.method === "GET" && url.pathname === "/authorize") {
authorizationRequest = Object.fromEntries(url.searchParams.entries());
authorizationCode = `smoke-code-${runId}`;
return redirect(res, `${api}/api/auth/sso/callback?code=${encodeURIComponent(authorizationCode)}&state=${encodeURIComponent(authorizationRequest.state)}`);
}
if (req.method === "POST" && url.pathname === "/token") {
const body = new URLSearchParams(await readRequestBody(req));
assert(body.get("code") === authorizationCode, "Mock OIDC code 不匹配");
assert(body.get("client_id") === "smoke-client", "Mock OIDC client_id 不匹配");
assert(body.get("code_verifier"), "Mock OIDC 缺少 PKCE verifier");
const now = Math.floor(Date.now() / 1000);
return json(res, 200, {
token_type: "Bearer",
access_token: `smoke-access-${runId}`,
id_token: signIdToken({
iss: issuer,
sub: `mock-sub-${runId}`,
aud: "smoke-client",
nonce: authorizationRequest.nonce,
email: mockEmail,
email_verified: true,
name: "本地 SSO 测试用户",
preferred_username: mockEmail,
iat: now,
exp: now + 300
})
});
}
if (req.method === "GET" && url.pathname === "/userinfo") return json(res, 200, { name: "本地 SSO 测试用户" });
return json(res, 404, { error: "not_found" });
});
await new Promise((resolvePromise, reject) => providerServer.listen(issuerPort, "127.0.0.1", resolvePromise).on("error", reject));
}
async function waitFor(url, timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
}
throw new Error(`等待服务超时:${url}`);
}
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, { ...options, redirect: "manual", headers: { "content-type": "application/json", ...(options.headers || {}) } });
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function main() {
await startProvider();
apiProcess = spawn(process.execPath, ["server/local-api.mjs"], {
cwd: root,
env: {
...process.env,
AI_DRAMA_API_PORT: String(apiPort),
AI_DRAMA_API_ORIGIN: api,
AI_DRAMA_FRONTEND_ORIGIN: "http://127.0.0.1:5173",
AI_DRAMA_DB_PATH: dbPath,
AI_DRAMA_OIDC_SMOKE_SECRET: secret
},
stdio: ["ignore", "pipe", "pipe"]
});
apiProcess.stdout.on("data", (chunk) => { childLogs += chunk.toString(); });
apiProcess.stderr.on("data", (chunk) => { childLogs += chunk.toString(); });
await waitFor(`${api}/api/health`);
const owner = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) });
assert(owner.response.ok && owner.payload.session?.token, "OIDC smoke 管理员登录失败");
const ownerHeaders = { authorization: `Bearer ${owner.payload.session.token}` };
const created = await request("/api/system/identity/providers", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({
name: `Smoke OIDC Runtime ${runId}`,
kind: "oidc",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
issuerUrl: issuer,
clientId: "smoke-client",
clientSecretRef: "AI_DRAMA_OIDC_SMOKE_SECRET",
autoProvision: true,
defaultRoleKey: "org_member",
defaultWorkspaceRoleKey: "writer",
enabled: true
})
});
assert(created.response.status === 201, `OIDC smoke 提供商登记失败:${JSON.stringify(created.payload)}`);
const provider = created.payload.providers.find((item) => item.name.includes(`Smoke OIDC Runtime ${runId}`));
assert(provider, "OIDC smoke 找不到新建提供商");
const probed = await request(`/api/system/identity/providers/${encodeURIComponent(provider.id)}/probe`, { method: "POST", headers: ownerHeaders, body: "{}" });
assert(probed.response.ok && probed.payload.provider?.status === "ready", `OIDC smoke discovery 探测失败:${JSON.stringify(probed.payload)}`);
const policy = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true }) });
assert(policy.response.ok, `OIDC smoke 启用 SSO 失败:${JSON.stringify(policy.payload)}`);
const start = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}&returnTo=%2F%23creator-home`);
assert(start.response.status === 302, `OIDC smoke 登录发起失败:${JSON.stringify(start.payload)}`);
const authorizeUrl = start.response.headers.get("location");
assert(authorizeUrl?.startsWith(`${issuer}/authorize`), "OIDC smoke 没有跳转到 Mock Provider");
const authorize = await fetch(authorizeUrl, { redirect: "manual" });
assert(authorize.status === 302, "Mock Provider authorize 没有返回 callback");
const callbackUrl = authorize.headers.get("location");
const callback = await fetch(callbackUrl, { redirect: "manual" });
assert(callback.status === 302, `OIDC callback 失败:${await callback.text()}`);
const frontendUrl = new URL(callback.headers.get("location"));
const ticket = frontendUrl.searchParams.get("sso_ticket");
assert(ticket, "OIDC callback 没有签发一次性票据");
const redeemed = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) });
assert(redeemed.response.ok && redeemed.payload.session?.token, `OIDC 票据兑换失败:${JSON.stringify(redeemed.payload)}`);
assert(redeemed.payload.user?.email === mockEmail, "OIDC 自动创建用户邮箱不匹配");
assert(redeemed.payload.context?.currentOrganization?.id === "org-studio-lab", "OIDC 用户没有进入绑定组织");
const replay = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) });
assert(replay.response.status === 401, "OIDC 票据重放没有被拒绝");
const enforced = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true, mfaRequiredForAll: true }) });
assert(enforced.response.ok, `OIDC smoke 启用全员 MFA 失败:${JSON.stringify(enforced.payload)}`);
const secondStart = await request(`/api/auth/sso/start?providerId=${encodeURIComponent(provider.id)}`);
const secondAuthorize = await fetch(secondStart.response.headers.get("location"), { redirect: "manual" });
const secondCallback = await fetch(secondAuthorize.headers.get("location"), { redirect: "manual" });
const secondTicket = new URL(secondCallback.headers.get("location")).searchParams.get("sso_ticket");
const enrollment = await request("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket: secondTicket }) });
assert(enrollment.response.ok && enrollment.payload.mfaEnrollmentRequired && enrollment.payload.enrollmentToken, "SSO 用户命中强制 MFA 时必须返回 enrollment challenge");
const setup = await request("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken }) });
assert(setup.response.status === 201 && setup.payload.setup?.methodId, "SSO MFA enrollment setup 失败");
const enrolled = await request("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollment.payload.enrollmentToken, methodId: setup.payload.setup.methodId, code: totp(setup.payload.setup.secret) }) });
assert(enrolled.response.ok && enrolled.payload.session?.token, "SSO MFA enrollment 完成后必须签发正式 session");
const invalid = await request(`/api/auth/sso/callback?format=json&code=bad&state=${"tampered"}`);
assert(invalid.response.status === 401, "OIDC 篡改 state 没有被拒绝");
console.log(`oidc smoke passed: ${api} user=${mockEmail}`);
await cleanup();
}
async function cleanup() {
if (providerServer) await new Promise((resolvePromise) => providerServer.close(resolvePromise));
if (apiProcess && !apiProcess.killed) apiProcess.kill("SIGTERM");
await rm(tempRoot, { recursive: true, force: true });
}
try {
await main();
} catch (error) {
console.error(error.message);
if (apiProcess) console.error(childLogs || "OIDC smoke API 没有输出日志");
await cleanup();
process.exitCode = 1;
}
+118
View File
@@ -0,0 +1,118 @@
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 runId = Date.now();
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const inviteEmail = `ops-${runId}@local.test`;
const outputPrefix = `qa/ops/${runId}/`;
function cleanup() {
const users = dbAll("SELECT id FROM users WHERE email = ?", [inviteEmail]);
const userIds = users.map((user) => user.id);
const jobs = dbAll("SELECT id FROM generation_jobs WHERE output_path LIKE ?", [`${outputPrefix}%`]);
const jobIds = jobs.map((job) => job.id);
withTransaction(() => {
if (jobIds.length) {
const placeholders = jobIds.map(() => "?").join(",");
dbRun(`DELETE FROM media_artifacts WHERE job_id IN (${placeholders})`, jobIds);
dbRun(`DELETE FROM job_dependencies WHERE job_id IN (${placeholders}) OR depends_on_job_id IN (${placeholders})`, [...jobIds, ...jobIds]);
dbRun(`DELETE FROM job_attempts WHERE job_id IN (${placeholders})`, jobIds);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${runId}%`]);
dbRun(`DELETE FROM audit_logs WHERE target_id IN (${placeholders})`, jobIds);
dbRun(`DELETE FROM generation_jobs WHERE id IN (${placeholders})`, jobIds);
}
dbRun("DELETE FROM notification_deliveries WHERE request_json LIKE ?", [`%\"runId\":${runId}%`]);
for (const userId of userIds) {
dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [inviteEmail, userId]);
dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [userId]);
dbRun("UPDATE usage_events SET user_id = NULL WHERE user_id = ?", [userId]);
dbRun("DELETE FROM users WHERE id = ?", [userId]);
}
dbRun("DELETE FROM invitations WHERE email = ?", [inviteEmail]);
});
}
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 };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = { authorization: `Bearer ${login.session.token}`, ...scope };
const invite = expectOk(await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers,
body: JSON.stringify({ email: inviteEmail, roleKey: "writer", workspaceId: "ws-local-aidrama" })
}), "create invitation");
assert.ok(invite.invitation?.inviteToken, "invitation must return a one-time registration token");
const preview = expectOk(await request(`/api/invitations/preview?token=${encodeURIComponent(invite.invitation.inviteToken)}`), "preview invitation");
assert.equal(preview.invitation.email, inviteEmail, "invitation preview email mismatch");
const registration = expectOk(await request("/api/auth/register", {
method: "POST",
body: JSON.stringify({ inviteToken: invite.invitation.inviteToken, displayName: "运营测试用户", password: "Ops@123456" })
}), "register invited user");
assert.ok(registration.session?.token, "registration must create a session");
assert.equal(registration.context.currentOrganization.id, "org-studio-lab", "registered user must enter invited organization");
const firstJob = expectOk(await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: "owned-image", kind: "依赖测试关键帧", shotId: "shot-01", output: `${outputPrefix}first.json` })
}), "create first job");
const dependentJob = expectOk(await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: "owned-image", kind: "依赖测试任务", shotId: "shot-02", dependsOnJobIds: [firstJob.job.id], output: `${outputPrefix}dependent.json` })
}), "create dependent job");
assert.equal(dependentJob.job.dependencies.length, 1, "dependent job must expose dependency records");
assert.equal(dependentJob.job.status, "blocked", "dependent job must wait for incomplete dependency");
const dependencyRun = await request(`/api/jobs/${encodeURIComponent(dependentJob.job.id)}/run`, { method: "POST", headers, body: "{}" });
assert.equal(dependencyRun.response.status, 409, "incomplete dependency must block execution");
assert.equal(dependencyRun.payload.error, "job_dependencies_unresolved", "dependency block must have a stable error code");
const storage = expectOk(await request("/api/usage/storage", { headers }), "storage usage");
assert.ok(Number.isFinite(storage.storage.usedBytes), "storage usage must return measured bytes");
assert.ok(Number.isFinite(storage.storage.limitBytes), "storage usage must return a byte quota");
const notificationTest = expectOk(await request("/api/system/notifications/test", {
method: "POST",
headers,
body: JSON.stringify({ event: "job.failed", payload: { source: "smoke-ops", runId } })
}), "notification test");
assert.ok(notificationTest.deliveries?.length >= 1, "notification test must create delivery records");
const compose = expectOk(await request("/api/production/compose", {
method: "POST",
headers,
body: JSON.stringify({ dryRun: true, clips: ["storage/jobs/missing-a.mp4", "storage/jobs/missing-b.mp4"], outputPath: `storage/compositions/${runId}.mp4` })
}), "compose dry-run");
assert.equal(compose.composition.dryRun, true, "compose dry-run must not claim a rendered video");
assert.equal(compose.composition.tool, "ffmpeg", "local composition must use ffmpeg");
console.log(`ops smoke passed: ${api}`);
} finally {
cleanup();
}
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
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"
};
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 };
}
const login = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
assert.equal(login.response.ok, true, "catalog smoke login failed");
const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope };
let seasonId = "";
let episodeId = "";
try {
const catalog = await request("/api/production/catalog", { headers });
assert.equal(catalog.response.ok, true, "production catalog must be readable");
assert.ok(catalog.payload.catalog.seasons.length >= 1, "catalog must include at least one season");
assert.ok(catalog.payload.catalog.seasons[0].episodes.length >= 1, "catalog must include at least one episode");
const createdSeason = await request("/api/production/seasons", {
method: "POST",
headers,
body: JSON.stringify({ title: `Smoke Season ${Date.now()}` })
});
assert.equal(createdSeason.response.status, 201, "season creation must return 201");
seasonId = createdSeason.payload.season.id;
const createdEpisode = await request("/api/production/episodes", {
method: "POST",
headers,
body: JSON.stringify({ seasonId, title: "Smoke Episode" })
});
assert.equal(createdEpisode.response.status, 201, "episode creation must return 201");
episodeId = createdEpisode.payload.episode.id;
assert.equal(createdEpisode.payload.episode.shotCount, 1, "new episode must initialize one shot draft");
const graph = await request(`/api/production/graph?episodeId=${encodeURIComponent(episodeId)}`, { headers });
assert.equal(graph.response.ok, true, "episode graph must be readable");
assert.equal(graph.payload.graph.episode.id, episodeId, "graph must switch to requested episode");
assert.equal(graph.payload.graph.shots.length, 1, "new episode graph must contain starter shot");
assert.equal(graph.payload.graph.catalog.activeEpisodeId, episodeId, "catalog must reflect active episode");
const updated = await request(`/api/production/episodes/${encodeURIComponent(episodeId)}`, {
method: "PATCH",
headers,
body: JSON.stringify({ status: "production", hook: "Smoke hook" })
});
assert.equal(updated.response.ok, true, "episode update must succeed");
assert.equal(updated.payload.episode.status, "production", "episode status must persist");
console.log("production catalog smoke passed");
} finally {
if (seasonId) {
withTransaction(() => {
dbRun("DELETE FROM review_comments WHERE review_id IN (SELECT id FROM reviews WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?))", [episodeId]);
dbRun("DELETE FROM reviews WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]);
dbRun("DELETE FROM voice_lines WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]);
dbRun("DELETE FROM shot_versions WHERE shot_id IN (SELECT id FROM shots WHERE episode_id = ?)", [episodeId]);
dbRun("DELETE FROM shots WHERE episode_id = ?", [episodeId]);
dbRun("DELETE FROM script_documents WHERE episode_id = ?", [episodeId]);
dbRun("DELETE FROM episodes WHERE id = ?", [episodeId]);
dbRun("DELETE FROM seasons WHERE id = ?", [seasonId]);
dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?)", [seasonId, episodeId]);
});
}
}
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
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"
};
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 };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "production controls login");
const headers = { authorization: `Bearer ${login.session.token}`, ...scope };
const graph = expectOk(await request("/api/production/graph", { headers }), "read production graph");
const shotId = graph.graph.shots[0]?.id;
assert.ok(shotId, "production graph must have a shot");
let createdVersionId = "";
let deliveryId = "";
const batchIds = [];
const manifestPaths = [];
try {
const beforeVersions = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/versions`, { headers }), "read shot versions");
const originalVersion = beforeVersions.versions.at(-1) || beforeVersions.versions[0];
assert.ok(originalVersion?.id, "shot version history must include a version");
const saved = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/prompt-versions`, {
method: "POST",
headers,
body: JSON.stringify({ imagePrompt: "smoke version prompt", negativePrompt: "no collage", videoPrompt: "smoke version motion" })
}), "create shot version");
createdVersionId = saved.shot.versionId;
assert.notEqual(createdVersionId, originalVersion.id, "saving a prompt must create a new current version");
const restored = expectOk(await request(`/api/production/shots/${encodeURIComponent(shotId)}/versions/${encodeURIComponent(originalVersion.id)}/restore`, {
method: "POST",
headers,
body: "{}"
}), "restore shot version");
assert.equal(restored.shot.versionId, originalVersion.id, "restore must move the current version pointer");
const createdDelivery = expectOk(await request("/api/production/deliveries", {
method: "POST",
headers,
body: JSON.stringify({ version: `smoke-${Date.now()}`, channel: "internal" })
}), "create delivery");
deliveryId = createdDelivery.delivery.id;
const firstBatch = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, {
method: "POST",
headers,
body: JSON.stringify({ label: "Smoke Batch 1" })
}), "create first delivery batch");
batchIds.push(firstBatch.batch.id);
manifestPaths.push(firstBatch.batch.manifest_path);
assert.equal(firstBatch.batch.result.manifestWritten, true, "delivery batch must write a real manifest");
assert.ok(firstBatch.batch.manifest_path.startsWith("storage/"), "delivery manifest must be inside storage");
const secondBatch = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, {
method: "POST",
headers,
body: JSON.stringify({ label: "Smoke Batch 2" })
}), "create second delivery batch");
batchIds.push(secondBatch.batch.id);
manifestPaths.push(secondBatch.batch.manifest_path);
assert.equal(secondBatch.batch.result.manifestWritten, true, "second delivery batch must write a real manifest");
expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(firstBatch.batch.id)}/activate`, { method: "POST", headers, body: "{}" }), "activate first delivery batch");
expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(secondBatch.batch.id)}/activate`, { method: "POST", headers, body: "{}" }), "activate second delivery batch");
const rolledBack = expectOk(await request(`/api/production/delivery-batches/${encodeURIComponent(secondBatch.batch.id)}/rollback`, { method: "POST", headers, body: "{}" }), "rollback second delivery batch");
assert.equal(rolledBack.restoredBatch.id, firstBatch.batch.id, "rollback must restore the previous active batch");
const mediaQa = expectOk(await request("/api/production/qa/media/run", { method: "POST", headers, body: "{}" }), "run media QA");
assert.ok(mediaQa.report?.totals && Number.isInteger(mediaQa.report.totals.gates), "media QA must return measurable gate totals");
console.log(`production controls smoke passed: ${shotId}, ${deliveryId}`);
} finally {
withTransaction(() => {
if (createdVersionId) dbRun("DELETE FROM shot_versions WHERE id = ?", [createdVersionId]);
if (shotId) dbRun("UPDATE shots SET current_version_id = (SELECT id FROM shot_versions WHERE shot_id = ? ORDER BY version_number ASC LIMIT 1) WHERE id = ?", [shotId, shotId]);
for (const batchId of batchIds) dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
for (const batchId of batchIds) dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]);
if (deliveryId) dbRun("DELETE FROM deliveries WHERE id = ?", [deliveryId]);
});
for (const manifestPath of manifestPaths) await rm(dirname(resolve(import.meta.dirname, "..", manifestPath)), { recursive: true, force: true });
}
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = `smoke-isolation-${Date.now()}`;
const projectName = `隔离验收项目 ${projectId.slice(-6)}`;
const emptyOrganizationId = `smoke-empty-org-${Date.now()}`;
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 };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
let created = false;
let emptyOrganizationCreated = false;
let isolatedAssetPath = "";
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = {
authorization: `Bearer ${login.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const original = expectOk(await request("/api/project", { headers }), "read original project");
const originalShotIds = new Set((original.project.shots || []).map((shot) => shot.id));
const originalCharacterIds = new Set((original.project.characters || []).map((asset) => asset.id));
const creation = expectOk(await request("/api/projects", {
method: "POST",
headers,
body: JSON.stringify({ id: projectId, name: projectName, type: "AI 漫剧" })
}), "create isolated project");
assert.equal(creation.project.id, projectId, "project factory must return the created project");
created = true;
const projectHeaders = { ...headers, "x-project-id": projectId };
const scoped = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project");
assert.equal(scoped.project.id, projectId, "project response must stay inside requested project");
assert.equal(scoped.project.series.title, projectName, "series title must come from the new project");
assert.notEqual(scoped.project.series.title, original.project.series.title, "new project must not inherit the seeded series title");
assert.equal(scoped.project.characters.length, 0, "new project must not inherit character locks");
assert.equal(scoped.project.locations.length, 0, "new project must not inherit location locks");
assert.equal(scoped.project.props.length, 0, "new project must not inherit prop locks");
assert.equal(scoped.project.productionJobs.length, 0, "new project must start with an empty job list");
assert.ok(scoped.project.episode.id, "new project must initialize a first episode");
assert.equal(scoped.project.shots.length, 1, "new project must initialize exactly one starter shot");
assert.ok(!scoped.project.shots.some((shot) => originalShotIds.has(shot.id)), "starter shot must not reuse another project's shot");
assert.ok(!scoped.project.characters.some((asset) => originalCharacterIds.has(asset.id)), "assets must not cross project boundaries");
const isolatedAsset = expectOk(await request("/api/assets/upload", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ data: Buffer.from(`isolated-asset-${projectId}`).toString("base64"), fileName: "isolated.txt", kind: "reference", mimeType: "text/plain" })
}), "upload isolated asset");
isolatedAssetPath = isolatedAsset.asset.currentVersion.storage_path;
const isolatedAssets = expectOk(await request("/api/assets", { headers: projectHeaders }), "read isolated assets");
assert.equal(isolatedAssets.assets.length, 1, "新项目资产列表只能包含本项目刚上传的资产");
const originalAssetContent = await request(`/api/assets/${encodeURIComponent([...originalCharacterIds][0])}/content`, { headers: projectHeaders });
assert.equal(originalAssetContent.response.status, 404, "跨项目读取资产内容必须返回 404");
const graph = expectOk(await request("/api/production/graph", { headers: projectHeaders }), "read isolated production graph");
assert.equal(graph.graph.series.title, projectName, "production graph must use the new series");
assert.equal(graph.graph.shots.length, 1, "production graph must contain the new starter shot only");
assert.equal(graph.graph.characters.length, 0, "production graph must not include seeded characters");
const job = expectOk(await request("/api/jobs", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: scoped.project.shots[0].id, output: `qa/${projectId}/frame.json` })
}), "create isolated job");
const afterJob = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project after job");
assert.equal(afterJob.jobs.length, 1, "new project job list must contain only its own job");
assert.equal(afterJob.jobs[0].id, job.job.id, "job must be scoped to the new project");
const exports = expectOk(await request("/api/exports/write", { method: "POST", headers: projectHeaders, body: "{}" }), "write isolated exports");
assert.ok(exports.exportRoot.endsWith(`/exports/${projectId}`), "exports must use a project-specific root");
assert.ok(exports.files.every((file) => file.startsWith(exports.exportRoot)), "every export file must stay in the project root");
const originalAfter = expectOk(await request("/api/project", { headers }), "read original project after isolation test");
assert.ok(originalAfter.project.characters.length > 0, "original project's assets must remain intact");
assert.ok(originalAfter.project.productionJobs.length >= original.project.productionJobs.length, "original project jobs must remain separate");
assert.ok(originalAfter.project.shots.some((shot) => originalShotIds.has(shot.id)), "original project's shots must remain intact");
const emptyOrganization = expectOk(await request("/api/organizations", {
method: "POST",
headers,
body: JSON.stringify({ id: emptyOrganizationId, name: `空租户 ${emptyOrganizationId.slice(-6)}`, workspaceName: "空生产空间" })
}), "create empty organization");
emptyOrganizationCreated = true;
const emptyScope = { authorization: headers.authorization, "x-organization-id": emptyOrganization.organization.id };
const emptyContext = expectOk(await request("/api/context", { headers: emptyScope }), "read empty organization context");
assert.equal(emptyContext.context.currentProject, null, "new organization must not invent a current project");
const emptyProject = expectOk(await request("/api/project", { headers: emptyScope }), "read empty organization project shell");
assert.equal(emptyProject.project.id, "", "empty organization must return an empty project shell");
assert.equal(emptyProject.project.episode.id, "", "empty organization must not invent an episode");
assert.equal(emptyProject.project.shots.length, 0, "empty organization must not inherit a starter shot");
assert.equal(emptyProject.project.characters.length, 0, "empty organization must not inherit character locks");
console.log(`project isolation smoke passed: ${projectId}`);
} finally {
if (created) {
dbRun("DELETE FROM projects WHERE id = ?", [projectId]);
await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true });
if (isolatedAssetPath) await rm(resolve(projectRoot, isolatedAssetPath), { force: true });
await rm(resolve(projectRoot, "storage", "assets", projectId), { recursive: true, force: true });
}
if (emptyOrganizationCreated) dbRun("DELETE FROM organizations WHERE id = ?", [emptyOrganizationId]);
}
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = `smoke-lifecycle-${Date.now()}`;
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 };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
let created = false;
let jobId = "";
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = {
authorization: `Bearer ${login.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const writerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" })
}), "writer login");
const creation = expectOk(await request("/api/projects", {
method: "POST",
headers,
body: JSON.stringify({ id: projectId, name: `生命周期验收 ${projectId.slice(-6)}`, type: "AI 漫剧" })
}), "create lifecycle project");
created = true;
assert.equal(creation.project.status, "draft", "new projects must begin in draft status");
const projectHeaders = { ...headers, "x-project-id": projectId };
const writerProjectHeaders = { authorization: `Bearer ${writerLogin.session.token}`, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
const writerArchive = await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: writerProjectHeaders, body: JSON.stringify({ action: "archive" }) });
assert.equal(writerArchive.response.status, 403, "ordinary writers must not change project lifecycle");
assert.equal(writerArchive.payload.error, "permission_denied", "lifecycle denial must be a backend permission denial");
const paused = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "pause" }) }), "pause project");
assert.equal(paused.project.status, "paused", "pause action must transition draft to paused");
const resumed = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "resume" }) }), "resume project");
assert.equal(resumed.project.status, "production", "resume action must transition to production");
const project = expectOk(await request("/api/project", { headers: projectHeaders }), "read lifecycle project");
const job = await request("/api/jobs", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ adapter: "owned-image", kind: "生命周期归档保护任务", shotId: project.project.shots[0].id, output: `qa/${projectId}/frame.json` })
});
assert.equal(job.response.status, 201, `job setup failed: ${job.payload.detail || job.payload.error || ""}`);
jobId = job.payload.job.id;
const blockedArchive = await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "archive" }) });
assert.equal(blockedArchive.response.status, 409, "project archive must reject active or blocked jobs");
assert.equal(blockedArchive.payload.error, "project_has_active_jobs", "archive rejection must identify active jobs");
dbRun("UPDATE generation_jobs SET status = 'cancelled', updated_at = ? WHERE id = ?", [new Date().toISOString(), jobId]);
dbRun("UPDATE job_attempts SET status = 'cancelled', finished_at = ? WHERE job_id = ? AND status IN ('blocked', 'queued', 'running')", [new Date().toISOString(), jobId]);
const archived = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "archive" }) }), "archive project");
assert.equal(archived.project.status, "archived", "archive action must persist archived status");
assert.ok(archived.project.archived_at, "archive action must persist archive timestamp");
const archivedRead = expectOk(await request("/api/project", { headers: projectHeaders }), "read archived project");
assert.equal(archivedRead.project.status, "archived", "archived project remains readable");
const archivedQa = await request("/api/qa", { headers: projectHeaders });
assert.equal(archivedQa.response.status, 200, "archived project QA history must remain readable");
const archivedCompliance = await request("/api/platform/compliance", { headers: projectHeaders });
assert.equal(archivedCompliance.response.status, 200, "archived project compliance evidence must remain readable");
const archivedQaRun = await request("/api/production/qa/run", { method: "POST", headers: projectHeaders, body: "{}" });
assert.equal(archivedQaRun.response.status, 409, "archived project must reject new QA writes");
const archivedJob = await request("/api/jobs", { method: "POST", headers: projectHeaders, body: JSON.stringify({ adapter: "owned-image", kind: "归档项目禁止生成", output: `qa/${projectId}/blocked.json` }) });
assert.equal(archivedJob.response.status, 409, "archived project must reject new jobs at the backend");
assert.equal(archivedJob.payload.error, "project_archived", "archived job rejection must be explicit");
const archivedAsset = await request("/api/assets", { method: "POST", headers: projectHeaders, body: JSON.stringify({ name: "归档项目禁止资产修改", kind: "reference" }) });
assert.equal(archivedAsset.response.status, 409, "archived project must reject asset mutations at the backend");
const restored = expectOk(await request(`/api/projects/${projectId}/lifecycle`, { method: "POST", headers: projectHeaders, body: JSON.stringify({ action: "restore" }) }), "restore project");
assert.equal(restored.project.status, "production", "restore must return to the pre-archive status");
assert.equal(restored.project.archived_at, null, "restore must clear archive timestamp");
console.log(`project lifecycle smoke passed: ${projectId}`);
} finally {
if (created) {
dbRun("DELETE FROM usage_events WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM projects WHERE id = ?", [projectId]);
await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true });
if (jobId) await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
}
}
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
async function request(path, headers, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.ok, true, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const owner = await login("producer@local.test");
const writer = await login("writer@local.test");
let createdRelativePath = "";
try {
const denied = await request("/api/system/readiness", writer);
assert.equal(denied.response.status, 403, "普通用户不应读取系统生产就绪度");
const readiness = await request("/api/system/readiness", owner);
assert.equal(readiness.response.ok, true, "系统管理员读取生产就绪度失败");
assert.ok(Array.isArray(readiness.payload.checks), "生产就绪度缺少检查项");
assert.equal(readiness.payload.activeRuntime.database, "node:sqlite", "当前业务数据库运行时记录不准确");
assert.ok(readiness.payload.checks.some((check) => check.key === "database-backup"), "生产就绪度缺少备份检查");
const created = await request("/api/system/backups", owner, { method: "POST", body: "{}" });
assert.equal(created.response.status, 201, `创建数据库快照失败:${JSON.stringify(created.payload)}`);
assert.ok(created.payload.backup?.relativePath, "数据库快照缺少相对路径");
assert.ok(Number(created.payload.backup.bytes) > 0, "数据库快照文件为空");
createdRelativePath = created.payload.backup.relativePath;
const backups = await request("/api/system/backups", owner);
assert.equal(backups.response.ok, true, "读取数据库快照列表失败");
assert.ok(backups.payload.backups.some((backup) => backup.relativePath === createdRelativePath), "数据库快照没有出现在列表中");
const readinessAfterBackup = await request("/api/system/readiness", owner);
assert.equal(readinessAfterBackup.response.ok, true, "创建快照后读取生产就绪度失败");
assert.ok(readinessAfterBackup.payload.backups.latest?.relativePath === createdRelativePath, "生产就绪度没有反映最近快照");
console.log(`readiness smoke passed: ${api}`);
} finally {
if (createdRelativePath) await unlink(resolve(process.cwd(), createdRelativePath)).catch(() => {});
}
+173
View File
@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = "thunder-mouth";
const runId = Date.now();
const sourceRoot = `storage/smoke-release-${runId}`;
const deliveryLabel = `smoke-release-delivery-${runId}`;
const batchId = `smoke-release-batch-${runId}`;
const channelName = `smoke-release-channel-${runId}`;
const version = `smoke-${runId}`;
const sourcePath = `${sourceRoot}/clip.mp4`;
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
const manifestPath = `${sourceRoot}/manifest.json`;
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 };
}
function expectStatus(result, status, label) {
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
const producerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "producer login");
const reviewerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
}), "reviewer login");
const producerHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const reviewerHeaders = {
authorization: `Bearer ${reviewerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]);
assert.ok(projectShot?.id, "smoke project must have a starter shot");
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
assert.ok(producerUser?.id, "producer user must exist");
const channelPath = `/api/production/delivery-channels`;
let channelId = "";
let releaseId = "";
let createdDeliveryId = "";
let seeded = false;
try {
const channels = expectOk(await request(channelPath, { headers: reviewerHeaders }), "reviewer can view channels");
assert.ok(channels.channels.some((channel) => channel.kind === "local-file"), "workspace must expose the default local-file channel");
expectStatus(await request(channelPath, {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ name: "reviewer must not create", kind: "local-file" })
}), 403, "reviewer cannot create a channel");
expectStatus(await request(channelPath, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ name: "public webhook must be rejected", kind: "local-webhook", endpoint: "https://example.com/release" })
}), 400, "public webhook is rejected");
const channel = expectStatus(await request(channelPath, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ name: channelName, kind: "local-file", endpoint: "storage/releases", requireApproval: true })
}), 201, "producer creates local channel");
channelId = channel.channel.id;
const delivery = expectStatus(await request("/api/production/deliveries", {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ version, channel: deliveryLabel })
}), 201, "producer creates delivery draft");
createdDeliveryId = delivery.delivery.id;
assert.equal(delivery.delivery.status, "draft", "new delivery must start as draft");
await mkdir(resolve(projectRoot, sourceRoot), { recursive: true });
await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence"));
await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence"));
await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-manifest", deliveryId: createdDeliveryId }, null, 2));
const timestamp = new Date().toISOString();
dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]);
dbRun("INSERT INTO delivery_batch_items(id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, '{}', ?)", [`smoke-release-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
seeded = true;
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ channelId, submit: true, idempotencyKey: `release-${runId}` })
}), 201, "producer submits release");
releaseId = release.release.id;
assert.equal(release.release.status, "submitted", "new release request must be submitted");
const reviewerReleases = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { headers: reviewerHeaders }), "reviewer can view release records");
assert.equal(reviewerReleases.releases.length, 1, "reviewer must see the submitted release");
expectStatus(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ status: "approved" })
}), 403, "reviewer cannot approve release");
const approved = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ status: "approved", note: "smoke approval" })
}), "producer approves release");
assert.equal(approved.release.status, "approved", "release must become approved");
const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
method: "POST",
headers: producerHeaders,
body: "{}"
}), "producer publishes release");
assert.equal(published.release.status, "published", "release must become published");
assert.ok(published.release.output_path, "published release must return an output path");
await access(resolve(projectRoot, published.release.output_path));
const publishedDocument = await readFile(resolve(projectRoot, published.release.output_path), "utf8");
assert.match(publishedDocument, /ai-drama-platform\.delivery-release\.v1/, "release.json must contain the release schema");
const repeated = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
method: "POST",
headers: producerHeaders,
body: "{}"
}), "repeated publish is idempotent");
assert.equal(repeated.idempotent, true, "published release retry must be idempotent");
const crossOrganization = await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
headers: { ...producerHeaders, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" }
});
assert.ok([403, 404].includes(crossOrganization.response.status), `cross organization release access must be hidden or denied: ${crossOrganization.response.status}`);
const audit = dbGet("SELECT COUNT(*) AS count FROM audit_logs WHERE target_id = ? AND action IN ('delivery.release.submitted', 'delivery.release.approved', 'delivery.release.published')", [releaseId]);
assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records");
console.log(`release workflow smoke passed: ${releaseId}`);
} finally {
if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
if (seeded) {
dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]);
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]);
dbRun("DELETE FROM deliveries WHERE id = ?", [createdDeliveryId]);
}
if (channelId) dbRun("DELETE FROM delivery_channels WHERE id = ?", [channelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [createdDeliveryId]);
await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true });
}
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
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, password = "Demo@123456") {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password })
});
assert.equal(result.response.status, 200, `${email} 登录失败`);
assert.ok(result.payload.session?.token, `${email} 未返回登录会话`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerHeaders = await login("producer@local.test");
const writerHeaders = await login("writer@local.test");
const badLogin = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "writer@local.test", password: "security-smoke-invalid-password" })
});
assert.equal(badLogin.response.status, 401, "错误密码必须被拒绝");
const ownEvents = await request("/api/auth/security-events", { headers: writerHeaders });
assert.equal(ownEvents.response.status, 200, "普通用户无法读取自己的安全事件");
assert.ok(Array.isArray(ownEvents.payload.events), "个人安全事件接口必须返回 events 数组");
assert.ok(ownEvents.payload.events.some((event) => event.eventType === "login.success"), "个人安全事件缺少登录成功记录");
assert.ok(ownEvents.payload.events.some((event) => event.eventType === "login.failure"), "个人安全事件缺少登录失败记录");
assert.ok(!JSON.stringify(ownEvents.payload.events).includes("security-smoke-invalid-password"), "安全事件不能记录密码");
const eventKeys = new Set();
for (const event of ownEvents.payload.events) {
for (const key of Object.keys(event.metadata || {})) eventKeys.add(key);
}
assert.ok(![...eventKeys].some((key) => /(sessionToken|challengeToken|password|otp|secret|api.?key)/i.test(key)), "安全事件不能返回敏感字段");
const ordinarySystemDenied = await request("/api/system/security-events", { headers: writerHeaders });
assert.equal(ordinarySystemDenied.response.status, 403, "普通用户不能查看全局安全事件");
assert.equal(ordinarySystemDenied.payload.error, "system_admin_required", "系统安全事件权限错误码不稳定");
const systemEvents = await request("/api/system/security-events?userId=u-writer", { headers: ownerHeaders });
assert.equal(systemEvents.response.status, 200, "系统管理员无法查看指定用户安全事件");
assert.ok(systemEvents.payload.events.some((event) => event.userId === "u-writer"), "系统安全事件未按指定用户过滤");
const detail = await request("/api/system/users/u-writer", { headers: ownerHeaders });
assert.equal(detail.response.status, 200, "系统管理员无法读取用户详情");
assert.ok(Array.isArray(detail.payload.securityEvents), "用户详情缺少账号安全事件台账");
console.log(`security events smoke passed: ${api}`);
+86
View File
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
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) {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.ok, true, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerHeaders = await login("producer@local.test");
const writerHeaders = await login("writer@local.test");
try {
const directory = await request("/api/system/users", { headers: ownerHeaders });
assert.equal(directory.response.ok, true, "系统管理员无法读取全局用户目录");
assert.ok(directory.payload.users.some((user) => user.id === "u-owner"), "用户目录缺少系统管理员");
assert.ok(directory.payload.users.every((user) => Array.isArray(user.organizations)), "用户目录缺少组织归属");
const detail = await request("/api/system/users/u-owner", { headers: ownerHeaders });
assert.equal(detail.response.ok, true, "系统管理员无法读取用户详情");
assert.ok(Array.isArray(detail.payload.sessions) && Array.isArray(detail.payload.recentAudit), "用户详情缺少会话或审计");
const ordinaryDenied = await request("/api/system/users", { headers: writerHeaders });
assert.equal(ordinaryDenied.response.status, 403, "普通用户不应访问全局用户目录");
assert.equal(ordinaryDenied.payload.error, "system_admin_required", "全局用户目录权限错误码不稳定");
const selfSuspend = await request("/api/system/users/u-owner", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ status: "suspended" })
});
assert.equal(selfSuspend.response.status, 400, "系统管理员不能停用自己");
assert.equal(selfSuspend.payload.error, "cannot_suspend_self", "自停用保护错误码不稳定");
const suspended = await request("/api/system/users/u-writer", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ status: "suspended" })
});
assert.equal(suspended.response.ok, true, "停用普通用户失败");
assert.equal(suspended.payload.user.status, "suspended", "用户停用状态未落库");
assert.ok(suspended.payload.revokedSessionCount >= 1, "停用用户必须撤销全部会话");
const staleSession = await request("/api/auth/session", { headers: writerHeaders });
assert.equal(staleSession.response.status, 401, "停用后的旧会话必须失效");
const reactivated = await request("/api/system/users/u-writer", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ status: "active" })
});
assert.equal(reactivated.response.ok, true, "恢复用户失败");
assert.equal(reactivated.payload.user.status, "active", "用户恢复状态未落库");
const newWriterHeaders = await login("writer@local.test");
const revoked = await request("/api/system/users/u-writer/revoke-sessions", {
method: "POST",
headers: ownerHeaders,
body: "{}"
});
assert.equal(revoked.response.ok, true, "系统管理员强制撤销用户会话失败");
assert.ok(revoked.payload.revokedSessionCount >= 1, "强制撤销必须返回撤销数量");
const revokedSession = await request("/api/auth/session", { headers: newWriterHeaders });
assert.equal(revokedSession.response.status, 401, "强制撤销后用户会话仍然有效");
console.log(`system users smoke passed: ${api}`);
} finally {
await request("/api/system/users/u-writer", {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({ status: "active" })
});
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { dbGet, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const localScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
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 };
}
function headers(token, scope = localScope) {
return { authorization: `Bearer ${token}`, ...scope };
}
async function login(email) {
const result = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.ok, true, `${email} login failed: ${JSON.stringify(result.payload)}`);
return result.payload.session.token;
}
const taskTitle = `collaboration-smoke-${Date.now()}`;
const shotId = dbGet("SELECT id FROM shots WHERE episode_id = 'episode-thunder-mouth-01' ORDER BY shot_number LIMIT 1")?.id;
assert.ok(shotId, "a seeded shot is required for task-link smoke");
let ownerToken = "";
let reviewerToken = "";
let taskId = "";
let commentId = "";
let linkId = "";
try {
const anonymous = await request("/api/project-activity");
assert.equal(anonymous.response.status, 401, "anonymous activity access must be rejected");
ownerToken = await login("producer@local.test");
reviewerToken = await login("review@local.test");
const created = await request("/api/tasks", {
method: "POST",
headers: headers(ownerToken),
body: JSON.stringify({ title: taskTitle, description: "验证商业协作详情、讨论、@通知、镜头关联和活动流", kind: "review", priority: "high", assigneeUserId: "u-review", targetTab: "qa" })
});
assert.equal(created.response.status, 201, JSON.stringify(created.payload));
taskId = created.payload.task.id;
const detail = await request(`/api/tasks/${encodeURIComponent(taskId)}/detail`, { headers: headers(ownerToken) });
assert.equal(detail.response.ok, true, JSON.stringify(detail.payload));
assert.equal(detail.payload.comments.length, 0, "new task should have no comments");
const comment = await request(`/api/tasks/${encodeURIComponent(taskId)}/comments`, {
method: "POST",
headers: headers(ownerToken),
body: JSON.stringify({ body: "请 @u-review 在审片中心确认实际末帧证据", mentionUserIds: ["u-review"] })
});
assert.equal(comment.response.status, 201, JSON.stringify(comment.payload));
commentId = comment.payload.comment.id;
assert.equal(comment.payload.comments.length, 1, "comment must be returned in task detail");
assert.equal(comment.payload.comments[0].mentions[0].id, "u-review", "mention must be resolved to a valid project member");
const reviewerNotifications = await request("/api/notifications?limit=200", { headers: headers(reviewerToken) });
assert.equal(reviewerNotifications.response.ok, true, JSON.stringify(reviewerNotifications.payload));
assert.ok(reviewerNotifications.payload.notifications.some((item) => item.eventKey === "task.commented" && item.targetId === taskId), "task comment must notify assignee");
const linked = await request(`/api/tasks/${encodeURIComponent(taskId)}/links`, {
method: "POST",
headers: headers(ownerToken),
body: JSON.stringify({ linkType: "shot", targetId: shotId })
});
assert.equal(linked.response.status, 201, JSON.stringify(linked.payload));
linkId = linked.payload.link.id;
assert.equal(linked.payload.links.length, 1, "shot link must be returned in task detail");
assert.equal(linked.payload.links[0].targetId, shotId, "shot link target mismatch");
const activity = await request("/api/project-activity?limit=100", { headers: headers(ownerToken) });
assert.equal(activity.response.ok, true, JSON.stringify(activity.payload));
assert.ok(activity.payload.activities.some((item) => item.action === "project.task.comment.created" && item.targetId === commentId), "comment must appear in project activity");
assert.ok(activity.payload.activities.some((item) => item.action === "project.task.link.created" && item.targetId === linkId), "link must appear in project activity");
const crossTenant = await request(`/api/tasks/${encodeURIComponent(taskId)}/detail`, { headers: headers(ownerToken, northstarScope) });
assert.equal(crossTenant.response.status, 404, "cross-organization task detail must be isolated");
const removed = await request(`/api/tasks/${encodeURIComponent(taskId)}/links/${encodeURIComponent(linkId)}`, { method: "DELETE", headers: headers(ownerToken) });
assert.equal(removed.response.ok, true, JSON.stringify(removed.payload));
assert.equal(removed.payload.links.length, 0, "task link must be removable");
console.log(`task collaboration smoke passed: task=${taskId}, comment=${commentId}, shot=${shotId}`);
} finally {
if (linkId) dbRun("DELETE FROM task_links WHERE id = ?", [linkId]);
if (commentId) dbRun("DELETE FROM task_comments WHERE id = ?", [commentId]);
if (taskId) dbRun("DELETE FROM project_tasks WHERE id = ?", [taskId]);
if (taskId) dbRun("DELETE FROM user_notifications WHERE target_id = ?", [taskId]);
if (taskId) dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [taskId, `%${taskId}%`]);
if (ownerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${ownerToken}` } }).catch(() => {});
if (reviewerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${reviewerToken}` } }).catch(() => {});
}
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { dbRun } 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"
};
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 };
}
function headers(token) {
return { authorization: `Bearer ${token}`, ...scope };
}
async function login(email) {
const result = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
assert.equal(result.response.ok, true, `${email} login failed`);
return result.payload.session.token;
}
const taskTitle = `smoke-task-${Date.now()}`;
let ownerToken = "";
let reviewerToken = "";
let taskId = "";
try {
const anonymous = await request("/api/tasks");
assert.equal(anonymous.response.status, 401, "anonymous task access must be rejected");
ownerToken = await login("producer@local.test");
reviewerToken = await login("review@local.test");
const created = await request("/api/tasks", {
method: "POST",
headers: headers(ownerToken),
body: JSON.stringify({
title: taskTitle,
description: "验证项目任务的负责人、状态、截止时间和审计链路",
kind: "review",
priority: "high",
assigneeUserId: "u-review",
targetTab: "qa",
dueAt: "2026-08-30T23:59:59.000Z"
})
});
assert.equal(created.response.status, 201, `task creation failed: ${JSON.stringify(created.payload)}`);
assert.equal(created.payload.task.title, taskTitle, "created task title mismatch");
assert.equal(created.payload.task.assignee.id, "u-review", "task assignee not persisted");
assert.equal(created.payload.task.priority, "high", "task priority not persisted");
taskId = created.payload.task.id;
const ownerList = await request("/api/tasks?status=all", { headers: headers(ownerToken) });
assert.equal(ownerList.response.ok, true, "owner task list failed");
assert.ok(ownerList.payload.tasks.some((task) => task.id === taskId), "owner should see created task");
assert.ok(ownerList.payload.summary.total >= 1, "task summary must include created task");
const reviewerList = await request("/api/tasks?assignedTo=me", { headers: headers(reviewerToken) });
assert.equal(reviewerList.response.ok, true, "reviewer assigned task list failed");
assert.ok(reviewerList.payload.tasks.some((task) => task.id === taskId), "assignee should see own task");
const reviewerNotifications = await request("/api/notifications?limit=200", { headers: headers(reviewerToken) });
assert.equal(reviewerNotifications.response.ok, true, "reviewer notification list failed");
assert.ok(reviewerNotifications.payload.notifications.some((notification) => notification.eventKey === "task.assigned" && notification.targetId === taskId), "task assignment must create an in-app notification");
const reviewerUpdate = await request(`/api/tasks/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: headers(reviewerToken),
body: JSON.stringify({ status: "in_progress" })
});
assert.equal(reviewerUpdate.response.ok, true, "assignee status update failed");
assert.equal(reviewerUpdate.payload.task.status, "in_progress", "assignee status was not saved");
const reviewerEscalation = await request(`/api/tasks/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: headers(reviewerToken),
body: JSON.stringify({ title: "越权修改标题" })
});
assert.equal(reviewerEscalation.response.status, 403, "assignee must not edit task metadata");
const ownerUpdate = await request(`/api/tasks/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: headers(ownerToken),
body: JSON.stringify({ status: "done", priority: "medium" })
});
assert.equal(ownerUpdate.response.ok, true, "manager task update failed");
assert.equal(ownerUpdate.payload.task.status, "done", "manager status update was not saved");
assert.ok(ownerUpdate.payload.task.completedAt, "completed task must have completedAt");
const northstar = await request("/api/tasks", {
headers: { authorization: `Bearer ${ownerToken}`, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" }
});
assert.equal(northstar.response.ok, true, "northstar task scope request failed");
assert.ok(northstar.payload.tasks.every((task) => task.id !== taskId), "cross-organization task must not leak");
console.log(`tasks smoke passed: ${taskId}`);
} finally {
if (taskId) dbRun("DELETE FROM project_tasks WHERE id = ?", [taskId]);
if (taskId) {
dbRun("DELETE FROM user_notifications WHERE target_id = ?", [taskId]);
dbRun("DELETE FROM audit_logs WHERE target_type = 'project_task' AND target_id = ?", [taskId]);
}
if (ownerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${ownerToken}` } }).catch(() => {});
if (reviewerToken) await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${reviewerToken}` } }).catch(() => {});
}
+197
View File
@@ -0,0 +1,197 @@
const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scopeHeaders = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, headers = ownerHeaders, init = {}) {
const response = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
const payload = await response.json();
return { response, payload };
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert(result.response.ok, `${email} 登录失败`);
assert(result.payload.user?.id && result.payload.user.id !== result.payload.session.id, `${email} 登录响应不能把 session id 当作 user id`);
assert(result.payload.user.id === result.payload.context.currentUser.id, `${email} 登录响应 user 与 context.currentUser 不一致`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerAuth = await login("producer@local.test");
const writerAuth = await login("writer@local.test");
const ownerHeaders = { ...ownerAuth, ...scopeHeaders };
const sessionTestAuth = await login("producer2@local.test");
const sessionTestHeaders = { ...sessionTestAuth };
const ownerSessions = await request("/api/auth/sessions", sessionTestHeaders);
assert(ownerSessions.response.ok && ownerSessions.payload.sessions.some((session) => session.current), "当前用户必须能看到当前登录会话");
const revokedOthers = await request("/api/auth/sessions/revoke-others", sessionTestHeaders, { method: "POST", body: "{}", headers: { "content-type": "application/json" } });
assert(revokedOthers.response.ok && revokedOthers.payload.sessions.some((session) => session.current), "撤销其他会话后当前会话必须保持有效");
const context = await request("/api/context", {});
assert(context.response.status === 401, "未登录请求不应访问 context");
const authenticatedContext = await request("/api/context", ownerHeaders);
assert(authenticatedContext.response.ok, "登录后的 context 不可访问");
assert(authenticatedContext.payload.platform.organizations.length >= 2, "没有多组织数据");
assert(authenticatedContext.payload.platform.workspaces.length >= 2, "没有多工作区数据");
assert(authenticatedContext.payload.context.permissions.includes("job:create"), "所有者缺少生成任务权限");
assert(authenticatedContext.payload.context.systemAdmin === true, "系统管理员身份未生效");
assert(authenticatedContext.payload.rolePermissions.some((role) => role.name === "组织所有者" && role.permissions.includes("organization:manage")), "管理员必须能读取服务端角色权限矩阵");
const ownerRolePolicies = await request("/api/organizations/org-studio-lab/role-policies", ownerHeaders);
assert(ownerRolePolicies.response.ok, "组织管理员无法读取角色权限策略");
assert(ownerRolePolicies.payload.roles.some((role) => role.key === "writer"), "角色策略目录缺少编剧角色");
assert(ownerRolePolicies.payload.permissions.some((permission) => permission.key === "voice:approve"), "角色策略目录缺少声音审批权限");
const writerRolePolicy = ownerRolePolicies.payload.roles.find((role) => role.key === "writer");
const policyPermissionKey = "voice:approve";
const originalWriterPermission = writerRolePolicy.permissions.includes(policyPermissionKey);
const writerRolePolicies = await request("/api/organizations/org-studio-lab/role-policies", { ...writerAuth, ...scopeHeaders });
assert(writerRolePolicies.response.status === 403, "普通用户不应读取组织角色权限策略");
try {
const grantedWriterPolicy = await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: true }),
headers: { "content-type": "application/json" }
});
assert(grantedWriterPolicy.response.ok && grantedWriterPolicy.payload.roles.find((role) => role.key === "writer")?.permissions.includes(policyPermissionKey), "管理员授予组织角色权限失败");
const writerAfterGrant = await request("/api/context", { ...writerAuth, ...scopeHeaders });
assert(writerAfterGrant.response.ok && writerAfterGrant.payload.context.permissions.includes(policyPermissionKey), "组织角色授权没有进入普通用户有效权限");
const revokedWriterPolicy = await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: false }),
headers: { "content-type": "application/json" }
});
assert(revokedWriterPolicy.response.ok && !revokedWriterPolicy.payload.roles.find((role) => role.key === "writer")?.permissions.includes(policyPermissionKey), "管理员撤销组织角色权限失败");
const writerAfterRevoke = await request("/api/context", { ...writerAuth, ...scopeHeaders });
assert(writerAfterRevoke.response.ok && !writerAfterRevoke.payload.context.permissions.includes(policyPermissionKey), "组织角色撤销没有从普通用户有效权限移除");
} finally {
await request("/api/organizations/org-studio-lab/role-policies/writer", ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: originalWriterPermission }),
headers: { "content-type": "application/json" }
});
}
const lockedOwnerPolicy = await request("/api/organizations/org-studio-lab/role-policies/org_owner", ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ permissionKey: policyPermissionKey, enabled: false }),
headers: { "content-type": "application/json" }
});
assert(lockedOwnerPolicy.response.status === 400 && lockedOwnerPolicy.payload.error === "owner_policy_locked", "组织所有者策略必须保持锁定");
const systemConfig = await request("/api/system/config", ownerHeaders);
assert(systemConfig.response.ok, "系统管理员无法访问系统配置");
const ownerModels = await request("/api/platform/models", ownerHeaders);
assert(ownerModels.response.ok, "系统管理员无法访问模型中台");
const ownerCosts = await request("/api/platform/costs", ownerHeaders);
assert(ownerCosts.response.ok, "系统管理员无法访问成本中心");
const ownerCompliance = await request("/api/platform/compliance", ownerHeaders);
assert(ownerCompliance.response.ok, "系统管理员无法访问合规中心");
const ownerOrganizationPath = await request("/api/organizations/org-northstar", ownerHeaders);
assert(ownerOrganizationPath.response.ok && ownerOrganizationPath.payload.organization.id === "org-northstar", "组织路径没有优先于旧上下文头");
const writerContext = await request("/api/context", { ...writerAuth, ...scopeHeaders });
assert(writerContext.response.ok, "普通用户 context 不可访问");
assert(!writerContext.payload.context.permissions.includes("system:settings:view"), "普通用户不应拥有系统配置权限");
assert(writerContext.payload.platform.members.length === 0, "普通用户不应收到组织成员明细");
assert(writerContext.payload.platform.workspaceMembers.length === 0, "普通用户不应收到工作区成员明细");
assert(writerContext.payload.platform.projectMembers.length === 0, "普通用户不应收到项目成员明细");
assert(writerContext.payload.platform.invitations.length === 0, "普通用户不应收到组织邀请明细");
assert(writerContext.payload.platform.billing === null, "普通用户不应收到计费账户");
assert(writerContext.payload.platform.usage === null, "普通用户不应收到用量明细");
assert(writerContext.payload.platform.auditLog.length === 0, "普通用户不应收到审计日志");
assert(writerContext.payload.platform.modelRegistry.length === 0, "普通用户不应收到模型连接器明细");
assert(writerContext.payload.platform.adapterCatalog.some((model) => model.id === "owned-i2v"), "有生成权限的普通用户必须收到可用适配器目录");
assert(writerContext.payload.platform.adapterCatalog.find((model) => model.id === "newapi-audio-production")?.approvalRequired === true, "混合音频连接器必须携带审批标记");
assert(writerContext.payload.platform.adapterCatalog.every((model) => !Object.prototype.hasOwnProperty.call(model, "endpoint")), "普通用户的适配器目录不应暴露连接器 Endpoint");
assert(writerContext.payload.platform.runnerHealth?.length === 0, "普通用户不应收到 Runner 健康明细");
const writerSystem = await request("/api/system/config", { ...writerAuth, ...scopeHeaders });
assert(writerSystem.response.status === 403, "普通用户不应访问系统配置");
const writerProjectPayload = await request("/api/project", { ...writerAuth, ...scopeHeaders });
assert(writerProjectPayload.response.ok, "普通用户应能读取当前项目生产数据");
assert((writerProjectPayload.payload.adapters || []).every((adapter) => !Object.prototype.hasOwnProperty.call(adapter, "endpoint") && !Object.prototype.hasOwnProperty.call(adapter, "baseUrl")), "项目接口不应向普通用户泄露连接器地址");
const writerModels = await request("/api/platform/models", { ...writerAuth, ...scopeHeaders });
assert(writerModels.response.status === 403, "普通用户不应访问模型中台");
const writerCosts = await request("/api/platform/costs", { ...writerAuth, ...scopeHeaders });
assert(writerCosts.response.status === 403, "普通用户不应访问成本中心");
const writerCompliance = await request("/api/platform/compliance", { ...writerAuth, ...scopeHeaders });
assert(writerCompliance.response.status === 403, "普通用户不应访问合规中心");
const writerMembers = await request("/api/organizations/org-studio-lab/members", { ...writerAuth, ...scopeHeaders });
assert(writerMembers.response.status === 403, "普通用户不应访问组织成员接口");
const writerQa = await request("/api/qa", { ...writerAuth, ...scopeHeaders });
assert(writerQa.response.status === 403, "普通用户不应执行审片接口");
const writerCreateOrganization = await request("/api/organizations", { ...writerAuth, ...scopeHeaders }, { method: "POST", body: JSON.stringify({ name: "blocked-org" }), headers: { "content-type": "application/json" } });
assert(writerCreateOrganization.response.status === 403, "普通用户不应创建组织");
const apiClientCreated = await request("/api/system/api-clients", ownerHeaders, {
method: "POST",
body: JSON.stringify({ name: `smoke-runner-${Date.now()}`, scopes: ["jobs:read", "jobs:write"] }),
headers: { "content-type": "application/json" }
});
assert(apiClientCreated.response.status === 201 && apiClientCreated.payload.clientKey, "系统 API 客户端必须返回一次性 client key");
const apiClientHeaders = { authorization: `Bearer ${apiClientCreated.payload.clientKey}`, ...scopeHeaders };
const apiClientContext = await request("/api/context", apiClientHeaders);
assert(apiClientContext.response.ok && apiClientContext.payload.context.systemAdmin === false, "API 客户端不应继承系统管理员身份");
assert(apiClientContext.payload.context.permissions.includes("job:create"), "API 客户端 jobs scope 未映射为生成权限");
const apiClientSystem = await request("/api/system/config", apiClientHeaders);
assert(apiClientSystem.response.status === 403, "API 客户端不应访问系统配置");
const revokeApiClient = await request(`/api/system/api-clients/${apiClientCreated.payload.client.id}`, ownerHeaders, {
method: "PATCH",
body: JSON.stringify({ status: "revoked" }),
headers: { "content-type": "application/json" }
});
assert(revokeApiClient.response.ok, "API 客户端撤销失败");
const revokedApiClient = await request("/api/context", apiClientHeaders);
assert(revokedApiClient.response.status === 401, "撤销 API 客户端后必须返回 401");
const externalJob = await request("/api/jobs", { ...writerAuth, ...scopeHeaders }, { method: "POST", body: JSON.stringify({ adapter: "newapi-audio-production", kind: "smoke external gate", shotId: "shot-01", output: "qa/smoke/external-gate.json" }), headers: { "content-type": "application/json" } });
assert(externalJob.response.status === 403 && externalJob.payload.error === "external_connector_requires_approval", "混合连接器未批准时必须由后端阻断");
const orgAdminAuth = await login("producer2@local.test");
const orgAdminHeaders = { ...orgAdminAuth, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" };
const orgAdminContext = await request("/api/context", orgAdminHeaders);
assert(orgAdminContext.response.ok, "组织管理员 context 不可访问");
assert(orgAdminContext.payload.context.systemAdmin === false, "组织管理员不应被识别为系统管理员");
assert(orgAdminContext.payload.context.permissions.includes("model:manage"), "组织管理员缺少模型管理权限");
const orgAdminModels = await request("/api/platform/models", orgAdminHeaders);
assert(orgAdminModels.response.ok, "组织管理员无法访问本组织模型中台");
const orgAdminQueue = await request("/api/admin/queue", orgAdminHeaders);
assert(orgAdminQueue.response.ok, "组织管理员无法访问本组织任务队列");
const orgAdminSystem = await request("/api/system/config", orgAdminHeaders);
assert(orgAdminSystem.response.status === 403, "组织管理员不应访问系统配置");
const northstar = await request("/api/context", {
...ownerAuth,
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
});
assert(northstar.response.ok, "组织切换失败");
assert(northstar.payload.context.currentOrganization.id === "org-northstar", "组织 scope 没有切换");
assert(northstar.payload.platform.modelRegistry.every((model) => model.id === "northstar-image"), "模型没有按组织隔离");
const writerInvite = await request("/api/organizations/org-studio-lab/invitations", {
...writerAuth,
...scopeHeaders
}, { method: "POST", body: JSON.stringify({ email: "blocked-smoke@local.test", roleKey: "writer" }), headers: { "content-type": "application/json" } });
assert(writerInvite.response.status === 403 && writerInvite.payload.error === "permission_denied", "编剧越权邀请未被拒绝");
const crossOrg = await request("/api/context", {
...writerAuth,
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main"
});
assert(crossOrg.response.status === 403 && crossOrg.payload.error === "organization_forbidden", "跨组织访问未被拒绝");
console.log(`tenant smoke passed: ${base}`);
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const localScope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const northstarScope = {
"x-organization-id": "org-northstar",
"x-workspace-id": "ws-northstar-main",
"x-project-id": "northstar-pilot"
};
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 };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
}), `${email} login`);
assert.ok(result.session?.token, `${email} login must return a session token`);
return result.session.token;
}
function scopedHeaders(token, scope) {
return { authorization: `Bearer ${token}`, ...scope };
}
function assertContract(payload, label) {
assert.ok(Array.isArray(payload.items), `${label} must return items[]`);
assert.ok(payload.summary && typeof payload.summary.total === "number", `${label} must return summary.total`);
assert.equal(payload.summary.total, payload.items.length, `${label} summary total must match visible items`);
for (const item of payload.items) {
assert.ok(item.id && item.kind && item.title && item.targetTab, `${label} work item must have identity and navigation`);
assert.ok(["high", "medium", "low"].includes(item.priority), `${label} work item priority must be normalized`);
assert.ok(item.updatedAt, `${label} work item must expose updatedAt`);
}
}
const tokens = [];
try {
const anonymous = await request("/api/work-items");
assert.equal(anonymous.response.status, 401, "anonymous work-items access must be rejected");
const ownerToken = await login("producer@local.test");
tokens.push(ownerToken);
const ownerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, localScope) }), "owner local work items");
assertContract(ownerPayload, "owner local");
assert.equal(ownerPayload.scope.organizationId, localScope["x-organization-id"], "owner scope must expose the requested organization");
assert.equal(ownerPayload.scope.workspaceId, localScope["x-workspace-id"], "owner scope must expose the requested workspace");
assert.equal(ownerPayload.scope.projectId, localScope["x-project-id"], "owner scope must expose the requested project");
assert.ok(ownerPayload.items.some((item) => item.kind === "review"), "owner should see current-project review work items");
assert.ok(ownerPayload.items.some((item) => item.kind === "job"), "owner should see current-project generation work items");
assert.ok(!JSON.stringify(ownerPayload).includes("endpoint"), "work-items must not leak model endpoint data");
const limited = expectOk(await request("/api/work-items?limit=1", { headers: scopedHeaders(ownerToken, localScope) }), "limited work items");
assert.equal(limited.items.length, 1, "work-items limit must be enforced");
const writerToken = await login("writer@local.test");
tokens.push(writerToken);
const writerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(writerToken, localScope) }), "writer local work items");
assertContract(writerPayload, "writer local");
assert.ok(writerPayload.items.some((item) => item.kind === "job"), "writer should see production jobs it can operate");
assert.ok(writerPayload.items.every((item) => ["job", "asset", "review", "invitation"].includes(item.kind)), "writer must not see delivery, billing, audit, or model work items");
assert.ok(!JSON.stringify(writerPayload).includes("billing"), "writer payload must not include billing data");
const reviewerToken = await login("review@local.test");
tokens.push(reviewerToken);
const reviewerPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(reviewerToken, localScope) }), "reviewer local work items");
assertContract(reviewerPayload, "reviewer local");
assert.ok(reviewerPayload.items.some((item) => item.kind === "review"), "reviewer should see QA work items");
assert.ok(reviewerPayload.items.every((item) => ["review", "asset", "delivery", "invitation"].includes(item.kind)), "reviewer must not see generation or model work items");
const northstarPayload = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, northstarScope) }), "owner northstar work items");
assertContract(northstarPayload, "owner northstar");
assert.ok(northstarPayload.items.some((item) => item.metadata?.shotId === "shot-northstar-pilot-001" || item.metadata?.deliveryId), "northstar scope should expose northstar production records");
assert.ok(northstarPayload.items.every((item) => !String(item.metadata?.shotId || "").startsWith("shot-01")), "northstar scope must not include local project shots");
const localAgain = expectOk(await request("/api/work-items", { headers: scopedHeaders(ownerToken, localScope) }), "owner local work items after scope switch");
assert.ok(localAgain.items.every((item) => !String(item.metadata?.shotId || "").startsWith("shot-northstar")), "switching back must not retain northstar work items");
console.log(`work-items smoke passed: owner=${ownerPayload.items.length}, writer=${writerPayload.items.length}, reviewer=${reviewerPayload.items.length}, northstar=${northstarPayload.items.length}`);
} finally {
for (const token of tokens) {
await request("/api/auth/logout", { method: "POST", headers: { authorization: `Bearer ${token}` } }).catch(() => {});
}
}
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const runnerPort = Number(process.env.AI_DRAMA_SMOKE_RUNNER_PORT || 8791);
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
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 };
}
const runnerRequests = [];
const runner = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString("utf8");
runnerRequests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null });
res.writeHead(200, { "content-type": "application/json" });
if (req.url === "/v1/images/generations") {
res.end(JSON.stringify({ created: Date.now(), data: [{ b64_json: "smoke-image" }] }));
return;
}
res.end(JSON.stringify({ outputPath: "storage/jobs/smoke-custom/output.json", data: [{ id: "single-output" }] }));
});
await new Promise((resolve) => runner.listen(runnerPort, "127.0.0.1", resolve));
let customModelId = "";
let openAiModelId = "";
let jobIds = [];
try {
const login = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
});
assert.equal(login.response.ok, true, "worker smoke login failed");
const headers = { authorization: `Bearer ${login.payload.session.token}`, ...scope };
const customModel = await request("/api/platform/models/register", {
method: "POST",
headers,
body: JSON.stringify({
label: `Smoke Custom Runner ${Date.now()}`,
endpoint: `http://127.0.0.1:${runnerPort}/custom/generate`,
kind: "http-json",
capability: ["text-to-image", "single-frame"],
costMode: "local",
protocol: { healthRoute: "custom/health" }
})
});
assert.equal(customModel.response.status, 201, "custom runner registration failed");
customModelId = customModel.payload.model.id;
await request(`/api/platform/models/${encodeURIComponent(customModelId)}`, {
method: "PATCH",
headers,
body: JSON.stringify({ status: "ready" })
});
const openAiModel = await request("/api/platform/models/register", {
method: "POST",
headers,
body: JSON.stringify({
label: `Smoke OpenAI Runner ${Date.now()}`,
endpoint: `http://127.0.0.1:${runnerPort}/v1`,
kind: "openai-compatible",
capability: ["text-to-image", "single-frame"],
costMode: "local",
protocol: { models: { image: "smoke-image" }, routes: { image: "images/generations" } }
})
});
assert.equal(openAiModel.response.status, 201, "OpenAI-compatible runner registration failed");
openAiModelId = openAiModel.payload.model.id;
await request(`/api/platform/models/${encodeURIComponent(openAiModelId)}`, {
method: "PATCH",
headers,
body: JSON.stringify({ status: "ready" })
});
const customJob = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: customModelId, kind: "自定义单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-custom/output.json" })
});
assert.equal(customJob.response.status, 201, "custom job creation failed");
assert.equal(customJob.payload.job.status, "queued", "custom job should be queued for local Worker");
const openAiJob = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: openAiModelId, kind: "OpenAI-compatible 单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-openai/output.png" })
});
assert.equal(openAiJob.response.status, 201, "OpenAI-compatible job creation failed");
assert.equal(openAiJob.payload.job.status, "queued", "OpenAI-compatible job should be queued");
jobIds = [customJob.payload.job.id, openAiJob.payload.job.id];
const deadline = Date.now() + 12_000;
const completed = new Map();
while (Date.now() < deadline && completed.size < jobIds.length) {
for (const jobId of jobIds) {
const result = await request(`/api/jobs/${encodeURIComponent(jobId)}`, { headers });
assert.equal(result.response.ok, true, `job ${jobId} detail request failed`);
if (result.payload.job.status === "completed") completed.set(jobId, result.payload.job);
if (result.payload.job.status === "failed") throw new Error(`${jobId} failed: ${result.payload.job.errorMessage}`);
}
if (completed.size < jobIds.length) await new Promise((resolve) => setTimeout(resolve, 400));
}
assert.equal(completed.size, jobIds.length, "local Worker did not complete all smoke jobs in time");
assert.ok(completed.get(customJob.payload.job.id).result?.data?.length === 1, "custom protocol must preserve exactly one image output");
assert.ok(completed.get(openAiJob.payload.job.id).result?.data?.length === 1, "OpenAI-compatible protocol must preserve exactly one image output");
assert.ok(runnerRequests.some((item) => item.url === "/v1/images/generations"), "OpenAI-compatible route was not called");
assert.ok(runnerRequests.some((item) => item.url === "/custom/generate"), "custom JSON route was not called");
assert.ok(completed.get(customJob.payload.job.id).leased_by == null, "completed job lease must be released");
console.log(`worker smoke passed: ${jobIds.length} jobs completed through local protocols`);
} finally {
await new Promise((resolve) => runner.close(resolve));
if (customModelId || openAiModelId || jobIds.length) {
withTransaction(() => {
for (const jobId of jobIds) {
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [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 usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
for (const modelId of [customModelId, openAiModelId].filter(Boolean)) {
dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]);
}
});
}
}
+42
View File
@@ -0,0 +1,42 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { sampleProject } from "../src/data/sampleProject.js";
import {
buildEditList,
buildPromptPack,
buildShotList,
buildVoiceTable
} from "../src/lib/exporters.js";
import { projectQa } from "../src/lib/qa.js";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const out = resolve(root, "exports/project-template");
const files = [
["bible/series-bible.json", sampleProject.series],
["shots/shot-list.json", buildShotList(sampleProject)],
["shots/prompt-pack.json", buildPromptPack(sampleProject)],
["voices/voice-lines.json", buildVoiceTable(sampleProject)],
["qa/qa-results.json", projectQa(sampleProject)],
["edit/edit-list.json", buildEditList(sampleProject)],
["characters/character-locks.json", sampleProject.characters],
["locations/location-locks.json", sampleProject.locations],
["props/prop-locks.json", sampleProject.props],
["final-video/delivery-manifest.json", {
schema: "ai-drama-platform.delivery-manifest.v1",
episodeId: sampleProject.episode.id,
expectedFinal: "final-video/E01_别往树下跑_master.mp4",
sourceEditList: "edit/edit-list.json",
subtitles: "edit/E01_别往树下跑.zh-CN.srt",
audioPolicy: "固定 TTS 声线替换随机原生音轨;环境声可独立保留。"
}]
];
for (const [name, value] of files) {
const target = resolve(out, name);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
console.log(`sample exports written to ${out}`);