feat: harden tenant access and production operations
This commit is contained in:
@@ -30,6 +30,7 @@ const scripts = [
|
||||
"smoke:work-items",
|
||||
"smoke:tasks",
|
||||
"smoke:notifications",
|
||||
"smoke:search",
|
||||
"smoke:project-isolation",
|
||||
"smoke:project-lifecycle"
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 = {
|
||||
@@ -33,8 +34,25 @@ async function login(email) {
|
||||
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", {
|
||||
@@ -99,6 +117,40 @@ try {
|
||||
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 {
|
||||
@@ -109,6 +161,14 @@ try {
|
||||
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}`);
|
||||
|
||||
@@ -72,7 +72,7 @@ try {
|
||||
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" })
|
||||
body: JSON.stringify({ email, roleKey: "project_editor", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" })
|
||||
});
|
||||
assert(invitation.response.status === 201 && invitation.payload.invitation.inviteToken, "MFA 测试用户邀请创建失败");
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async function login(email) {
|
||||
const result = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||
});
|
||||
assert.equal(result.response.status, 200, `${email} 登录失败`);
|
||||
return { authorization: `Bearer ${result.payload.session.token}` };
|
||||
}
|
||||
|
||||
const ownerHeaders = { ...(await login("producer@local.test")), ...scope };
|
||||
const writerHeaders = { ...(await login("writer@local.test")), ...scope };
|
||||
|
||||
const unauthenticated = await request("/api/search?q=%E9%9B%B7%E9%9B%A8", { headers: scope });
|
||||
assert.equal(unauthenticated.response.status, 401, "未登录搜索必须返回 401");
|
||||
|
||||
const ownerSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=workspace", { headers: ownerHeaders });
|
||||
assert.equal(ownerSearch.response.status, 200, "组织所有者搜索失败");
|
||||
assert.ok(ownerSearch.payload.total >= 3, "搜索结果应覆盖项目、镜头和资产等多个生产对象");
|
||||
assert.ok(ownerSearch.payload.results.some((item) => item.type === "project" && item.projectId === "thunder-mouth"), "搜索必须返回当前项目");
|
||||
assert.ok(ownerSearch.payload.results.some((item) => item.type === "shot" && item.targetTab === "director"), "搜索必须返回可跳转的镜头结果");
|
||||
assert.ok(ownerSearch.payload.results.every((item) => item.organizationId === "org-studio-lab"), "搜索结果必须返回当前组织作用域");
|
||||
|
||||
const projectSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=project", { headers: ownerHeaders });
|
||||
assert.equal(projectSearch.response.status, 200, "当前项目搜索失败");
|
||||
assert.ok(projectSearch.payload.results.length > 0 && projectSearch.payload.results.every((item) => item.projectId === "thunder-mouth"), "当前项目搜索不能返回其他项目对象");
|
||||
|
||||
const crossOrganization = await request("/api/search?q=%E5%B1%B1%E6%B5%B7&scope=workspace", { headers: ownerHeaders });
|
||||
assert.equal(crossOrganization.response.status, 200, "跨组织关键词搜索不应报错");
|
||||
assert.equal(crossOrganization.payload.results.length, 0, "当前组织搜索不得泄露北辰组织项目");
|
||||
|
||||
const writerSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=workspace", { headers: writerHeaders });
|
||||
assert.equal(writerSearch.response.status, 200, "普通成员应能搜索自己可访问的生产内容");
|
||||
assert.ok(writerSearch.payload.results.length > 0, "普通成员应能搜索当前项目内容");
|
||||
assert.ok(writerSearch.payload.results.every((item) => item.projectId === "thunder-mouth"), "普通成员搜索不能越过项目边界");
|
||||
assert.ok(writerSearch.payload.results.every((item) => item.organizationId === "org-studio-lab"), "普通成员搜索必须返回当前组织作用域");
|
||||
|
||||
console.log(`search smoke passed: owner=${ownerSearch.payload.total}, project=${projectSearch.payload.total}, writer=${writerSearch.payload.total}`);
|
||||
Reference in New Issue
Block a user