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(项目层级已移除)", ); } });