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