2026-09-04 18:03:46 +08:00
|
|
|
|
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",
|
|
|
|
|
|
);
|
2026-09-17 15:21:24 +08:00
|
|
|
|
const ownerScope = readFileSync(
|
|
|
|
|
|
new URL("../lib/owner-scope.ts", import.meta.url),
|
|
|
|
|
|
"utf8",
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
|
|
|
|
|
|
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",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
"partner_bind_account",
|
|
|
|
|
|
"partner_create_from_account",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
"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() 拒绝任务负责人`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 15:21:24 +08:00
|
|
|
|
test("project-owner management actions gate on project ownership", () => {
|
|
|
|
|
|
for (const action of [
|
|
|
|
|
|
"project_detail",
|
|
|
|
|
|
"project_delete",
|
|
|
|
|
|
"project_ensure_main_chat",
|
|
|
|
|
|
"create_task",
|
2026-09-24 15:52:45 +08:00
|
|
|
|
"task_enable_recovery",
|
2026-09-17 15:21:24 +08:00
|
|
|
|
]) {
|
|
|
|
|
|
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),
|
|
|
|
|
|
"任务负责人本人不因此获得编辑权",
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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\(/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 15:21:24 +08:00
|
|
|
|
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} 保持严格任务负责人门,项目负责人不可代绑`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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\)\)/,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-23 11:13:17 +08:00
|
|
|
|
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\(/);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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"),
|
2026-09-23 11:13:17 +08:00
|
|
|
|
mvpDb.indexOf("export function uid(prefix: string)"),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
);
|
2026-09-17 15:21:24 +08:00
|
|
|
|
const occurrences =
|
|
|
|
|
|
dashboard.match(
|
|
|
|
|
|
/WHERE \(t\.owner_userid = \? OR \(t\.project_id != '' AND pj\.owner_userid = \?\)\)/g,
|
|
|
|
|
|
) ?? [];
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(
|
|
|
|
|
|
occurrences.length,
|
|
|
|
|
|
2,
|
2026-09-17 15:21:24 +08:00
|
|
|
|
"tasks 与 distributions 两条查询都要按 union scope 过滤(任务负责人 OR 项目负责人)",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
|
});
|
2026-09-23 11:13:17 +08:00
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 15:52:45 +08:00
|
|
|
|
test("no project_save payload carries parentProjectId(项目层级已移除)", () => {
|
2026-09-23 11:13:17 +08:00
|
|
|
|
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);
|
2026-09-24 15:52:45 +08:00
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/parentProjectId/.test(payload),
|
|
|
|
|
|
"project_save 载荷不应再带 parentProjectId(项目层级已移除)",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|