Files
koc-loop/tests/owner-permissions.test.mjs
ABAPPLO e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00

339 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const actionRoute = readFileSync(
new URL("../app/api/action/route.ts", import.meta.url),
"utf8",
);
const bootstrapRoute = readFileSync(
new URL("../app/api/bootstrap/route.ts", import.meta.url),
"utf8",
);
const recoveryExportRoute = readFileSync(
new URL("../app/api/recovery-export/route.ts", import.meta.url),
"utf8",
);
const screenshotTaskExportRoute = readFileSync(
new URL("../app/api/screenshot-task-export/route.ts", import.meta.url),
"utf8",
);
const taskResultImageRoute = readFileSync(
new URL("../app/api/task-result-image/route.ts", import.meta.url),
"utf8",
);
const usersRoute = readFileSync(
new URL("../app/api/users/route.ts", import.meta.url),
"utf8",
);
const mvpDb = readFileSync(
new URL("../lib/mvp-db.ts", import.meta.url),
"utf8",
);
const ownerScope = readFileSync(
new URL("../lib/owner-scope.ts", import.meta.url),
"utf8",
);
function section(source, action) {
const start = source.indexOf(`"${action}"`);
assert.ok(start >= 0, `action ${action} 不存在于路由源码`);
const next = source.indexOf("} else if (body.action ===", start);
return source.slice(start, next === -1 ? undefined : next);
}
test("manager-only actions gate on owner scope", () => {
const managerOnly = [
"claim",
"save_collection_schedule",
"run_due_collections",
"retry_failed_collections",
"backfill_account_profiles",
"set_public_account_ids",
"bind_wecom_external_id",
"partner_bind_account",
"partner_create_from_account",
"send_test_wecom",
"trigger_wecom_overdue_escalation",
"wecom_list_external_contacts",
"wecom_sync_group_chats",
"partner_save",
"partner_group_owners",
"refresh_account_fields",
"account_field_refresh_status",
];
for (const action of managerOnly) {
const body = section(actionRoute, action);
assert.match(
body,
/managerGate\(\)/,
`${action} 必须 managerGate() 拒绝任务负责人`,
);
}
});
test("project-owner management actions gate on project ownership", () => {
for (const action of [
"project_detail",
"project_delete",
"project_ensure_main_chat",
"create_task",
"task_enable_recovery",
]) {
const body = section(actionRoute, action);
assert.match(
body,
/projectOwnerGate\(/,
`${action} 必须校验项目负责人归属(owner 空 projectId 也 403)`,
);
}
for (const action of ["task_set_owner", "task_update", "project_push_task"]) {
const body = section(actionRoute, action);
assert.match(
body,
/taskProjectOwnerGate\(/,
`${action} 必须按任务→项目链校验项目负责人归属`,
);
}
});
test("project_save blocks creation and ownership transfer for project owners", () => {
const body = section(actionRoute, "project_save");
assert.match(body, /managerForbidden\(\)/, "项目负责人不可新建项目");
assert.match(body, /projectOwnerGate\(/, "项目负责人只能保存自己负责的项目");
assert.match(
body,
/SELECT owner_userid FROM projects WHERE id = \?/,
"owner 路径必须回填 DB 现值,堵死 ownerUserid 转移",
);
assert.match(body, /nextOwnerUserid/);
});
test("task_update blocks project migration by project owners", () => {
const body = section(actionRoute, "task_update");
assert.match(
body,
/projectOwnerGate\(nextProjectId\)/,
"迁移目标项目也必须本人负责",
);
});
test("read-only dependencies open to any logged-in user", () => {
for (const action of ["inspect_feishu", "wecom_list_internal_contacts"]) {
const body = section(actionRoute, action);
assert.ok(
!/managerGate\(\)/.test(body),
`${action} 不再是管理员专属(项目负责人建任务/选人依赖)`,
);
}
});
test("owner-scope exposes strict project ownership helpers", () => {
assert.match(ownerScope, /export async function isProjectOwner\(/);
const projectOwner = ownerScope.slice(
ownerScope.indexOf("export async function isProjectOwner"),
ownerScope.indexOf("export async function isTaskProjectOwner"),
);
assert.match(
projectOwner,
/SELECT id FROM projects WHERE id = \? AND owner_userid = \? LIMIT 1/,
);
assert.match(ownerScope, /export async function isTaskProjectOwner\(/);
const taskProjectOwner = ownerScope.slice(
ownerScope.indexOf("export async function isTaskProjectOwner"),
ownerScope.indexOf("export function ownerForbidden"),
);
assert.match(taskProjectOwner, /JOIN projects p/);
assert.ok(!/LEFT JOIN/.test(taskProjectOwner), "无项目归属的任务不放行");
assert.ok(
!/t\.owner_userid = \?/.test(taskProjectOwner),
"任务负责人本人不因此获得编辑权",
);
});
test("collect actions stay manager-only", () => {
const body = section(actionRoute, "collect_now");
assert.match(body, /managerGate\(\)/);
});
test("task-scoped actions verify ownership for task owners", () => {
for (const action of [
"wecom_push_task_to_groups",
"task_group_links",
"task_group_push",
"task_customer_chats",
"task_bind_customer_chat",
]) {
const body = section(actionRoute, action);
assert.match(
body,
/taskOwnerGate\(/,
`${action} 必须校验任务归属`,
);
}
const dueSoon = section(actionRoute, "trigger_wecom_due_soon");
assert.match(dueSoon, /taskOwnerGate\(/);
assert.match(dueSoon, /runDueSoonWecomNotifications\(\s*db,\s*wecomConfig,\s*undefined,\s*undefined,\s*taskId \|\| undefined,/);
const pushes = section(actionRoute, "wecom_task_group_pushes");
assert.match(pushes, /taskOwnerGate\(/);
const manual = section(actionRoute, "manual_metrics");
assert.match(manual, /distributionOwnerGate\(/);
});
test("project_list opens to owner scope with server-side filtering", () => {
const body = section(actionRoute, "project_list");
assert.match(
body,
/listProjects\(db, ownerScopeUserid\(scope\)\)/,
"project_list 按负责人范围过滤,不再 managerGate",
);
assert.ok(
!/managerGate\(\)/.test(body),
"project_list 已放开给项目负责人(只读)",
);
});
test("customer chat actions keep strict task-owner gate", () => {
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
const body = section(actionRoute, action);
assert.match(
body,
/strict: true/,
`${action} 保持严格任务负责人门,项目负责人不可代绑`,
);
}
});
test("no unawaited isManagerRequest gates remain", () => {
assert.ok(
!actionRoute.includes("if (!isManagerRequest(request))"),
"isManagerRequest 返回 Promise,缺 await 的门形同虚设",
);
});
test("action fallthrough dashboard is owner-scoped", () => {
assert.match(
actionRoute,
/getDashboardData\(ownerScopeUserid\(scope\)\)/,
);
});
test("customer chat actions list all owners' groups without owner prerequisite", () => {
const list = section(actionRoute, "task_customer_chats");
assert.match(
list,
/listGroupChatRows\(db\)/,
"task_customer_chats 读全量群缓存(所有群主的外部群)",
);
assert.match(
list,
/syncGroupChats\(db, wecomConfig\)/,
"缓存为空(或要求刷新)时自动全量同步",
);
assert.ok(
!/请先设置任务负责人/.test(list),
"列表不再要求任务先设负责人",
);
const bind = section(actionRoute, "task_bind_customer_chat");
assert.ok(
!/请先设置任务负责人/.test(bind),
"绑定活码用应用身份,不依赖任务负责人",
);
assert.match(bind, /bindTaskCustomerChat\(/);
assert.match(bind, /unbindTaskCustomerChat\(/);
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
const body = section(actionRoute, action);
assert.match(body, /Response\.json\(/);
assert.ok(
!/getDashboardData/.test(body),
`${action} 显式返回,不能落兜底 dashboard`,
);
}
});
test("partner claim detail exposes join qr only for claimed tasks", () => {
const partnerRoute = readFileSync(
new URL("../app/api/partner/route.ts", import.meta.url),
"utf8",
);
assert.match(partnerRoute, /join_qr_code FROM tasks/);
assert.match(partnerRoute, /joinQrCode,/);
const portalPage = readFileSync(
new URL("../koc-portal/app/page.tsx", import.meta.url),
"utf8",
);
assert.match(portalPage, /joinQrCode\?: string/);
assert.match(portalPage, /join-qr-card/);
assert.match(portalPage, /isClaimOwner && payload\.claim\?\.joinQrCode/);
});
test("bootstrap scopes dashboard data by owner scope", () => {
assert.match(bootstrapRoute, /getOwnerScope\(request\)/);
assert.match(bootstrapRoute, /getDashboardData\(ownerScopeUserid\(scope\)\)/);
});
test("getDashboardData filters tasks and distributions by owner", () => {
const dashboard = mvpDb.slice(
mvpDb.indexOf("export async function getDashboardData"),
mvpDb.indexOf("export function uid(prefix: string)"),
);
const occurrences =
dashboard.match(
/WHERE \(t\.owner_userid = \? OR \(t\.project_id != '' AND pj\.owner_userid = \?\)\)/g,
) ?? [];
assert.equal(
occurrences.length,
2,
"tasks 与 distributions 两条查询都要按 union scope 过滤(任务负责人 OR 项目负责人)",
);
});
test("export routes enforce owner scope", () => {
assert.match(recoveryExportRoute, /isTaskOwner\(/);
assert.match(screenshotTaskExportRoute, /isTaskOwner\(/);
assert.match(taskResultImageRoute, /isDistributionOwner\(/);
});
test("users route supports wecom binding lifecycle", () => {
assert.match(usersRoute, /action === "set_wecom"/);
assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/);
assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/);
});
test("project detail exposes inline main-chat member management", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const globalsCss = readFileSync(
new URL("../app/globals.css", import.meta.url),
"utf8",
);
assert.match(adminApp, /memberPickerOpen/);
assert.match(adminApp, /mainGroupMemberIds: nextMembers/);
assert.match(adminApp, /project-member-remove/);
assert.match(adminApp, /userid !== detail\.project\.ownerUserid/);
assert.match(globalsCss, /\.project-member-remove/);
});
test("no project_save payload carries parentProjectId(项目层级已移除)", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const starts = [...adminApp.matchAll(/action: "project_save"/g)].map(
(match) => match.index,
);
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
for (const start of starts) {
const payload = adminApp.slice(start, start + 600);
assert.ok(
!/parentProjectId/.test(payload),
"project_save 载荷不应再带 parentProjectId(项目层级已移除)",
);
}
});