import assert from "node:assert/strict"; import { dbRun, withTransaction } from "../server/db.mjs"; 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`; const projectInviteEmail = `project-invitation-smoke-${Date.now()}@local.test`; let invitationId = ""; let invitationState = ""; let projectInvitationId = ""; let projectInvitationState = ""; let projectUserId = ""; function cleanupProjectUser() { if (!projectUserId) return; withTransaction(() => { dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [projectInviteEmail, projectUserId]); dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [projectUserId]); dbRun("UPDATE system_settings SET updated_by = NULL WHERE updated_by = ?", [projectUserId]); dbRun("UPDATE feature_flags SET updated_by = NULL WHERE updated_by = ?", [projectUserId]); dbRun("UPDATE notification_channels SET created_by = NULL WHERE created_by = ?", [projectUserId]); dbRun("UPDATE api_clients SET created_by = NULL WHERE created_by = ?", [projectUserId]); dbRun("DELETE FROM users WHERE id = ?", [projectUserId]); }); } 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 mismatchedRole = await request("/api/organizations/org-studio-lab/invitations", { method: "POST", headers: ownerHeaders, body: JSON.stringify({ email: `mismatched-role-${Date.now()}@local.test`, roleKey: "project_viewer", workspaceId: "ws-local-aidrama" }) }); assert.equal(mismatchedRole.response.status, 400, "项目角色不能用于工作区级邀请"); assert.equal(mismatchedRole.payload.error, "role_invalid", "邀请层级角色不匹配需要稳定错误码"); const projectInvite = assertOk(await request("/api/organizations/org-studio-lab/invitations", { method: "POST", headers: ownerHeaders, body: JSON.stringify({ email: projectInviteEmail, roleKey: "project_viewer", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" }) }), "创建项目查看者邀请"); projectInvitationId = projectInvite.invitation.id; projectInvitationState = projectInvite.invitation.status; assert.equal(projectInvite.invitation.role_key, "project_viewer", "项目邀请必须保留项目查看者角色"); assert.equal(projectInvite.invitation.project_id, "thunder-mouth", "项目邀请必须绑定目标项目"); const projectRegistration = assertOk(await request("/api/auth/register", { method: "POST", body: JSON.stringify({ inviteToken: projectInvite.invitation.inviteToken, displayName: "项目访客测试用户", password: "ProjectInvite@123456" }) }), "注册项目查看者"); projectUserId = projectRegistration.user.id; assert.equal(projectRegistration.context.currentWorkspace.id, "ws-local-aidrama", "项目邀请用户必须进入目标工作区"); assert.equal(projectRegistration.context.currentProject.id, "thunder-mouth", "项目邀请用户必须进入目标项目"); assert.equal(projectRegistration.context.workspaceRole, "project_guest", "项目邀请用户必须使用受限工作区角色"); assert.equal(projectRegistration.context.projectRole, "project_viewer", "项目邀请用户必须保留项目查看者角色"); assert.equal(projectRegistration.context.permissions.includes("job:create"), false, "项目查看者不能继承生成权限"); projectInvitationState = "accepted"; const projectUserProjects = assertOk(await request("/api/projects", { headers: { authorization: `Bearer ${projectRegistration.session.token}`, "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama" } }), "读取项目查看者可访问项目"); assert.deepEqual(projectUserProjects.projects.map((item) => item.id), ["thunder-mouth"], "项目查看者不能继承工作区其他项目"); 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: "{}" }); } if (projectInvitationId && projectInvitationState === "pending") { await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(projectInvitationId)}/revoke`, { method: "POST", headers: ownerHeaders, body: "{}" }); } cleanupProjectUser(); } console.log(`invitations smoke passed: ${base}`);