205 lines
6.5 KiB
JavaScript
205 lines
6.5 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 taskReportExportRoute = readFileSync(
|
|||
|
|
new URL("../app/api/task-report-export/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",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
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 = [
|
|||
|
|
"inspect_feishu",
|
|||
|
|
"create_task",
|
|||
|
|
"create_screenshot_task",
|
|||
|
|
"claim",
|
|||
|
|
"save_collection_schedule",
|
|||
|
|
"run_due_collections",
|
|||
|
|
"retry_failed_collections",
|
|||
|
|
"backfill_account_profiles",
|
|||
|
|
"set_public_account_ids",
|
|||
|
|
"bind_wecom_external_id",
|
|||
|
|
"send_test_wecom",
|
|||
|
|
"trigger_wecom_overdue_escalation",
|
|||
|
|
"wecom_list_external_contacts",
|
|||
|
|
"wecom_list_internal_contacts",
|
|||
|
|
"wecom_sync_group_chats",
|
|||
|
|
"project_list",
|
|||
|
|
"project_detail",
|
|||
|
|
"project_save",
|
|||
|
|
"project_delete",
|
|||
|
|
"project_ensure_main_chat",
|
|||
|
|
"task_set_owner",
|
|||
|
|
"task_update",
|
|||
|
|
"partner_save",
|
|||
|
|
"partner_group_owners",
|
|||
|
|
"project_push_task",
|
|||
|
|
"refresh_account_fields",
|
|||
|
|
"account_field_refresh_status",
|
|||
|
|
];
|
|||
|
|
for (const action of managerOnly) {
|
|||
|
|
const body = section(actionRoute, action);
|
|||
|
|
assert.match(
|
|||
|
|
body,
|
|||
|
|
/managerGate\(\)/,
|
|||
|
|
`${action} 必须 managerGate() 拒绝任务负责人`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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("task_report_list scopes reports by owner and drops the broken gate", () => {
|
|||
|
|
const body = section(actionRoute, "task_report_list");
|
|||
|
|
assert.match(body, /getTaskReportData\(db, ownerScopeUserid\(scope\)\)/);
|
|||
|
|
assert.ok(
|
|||
|
|
!/isManagerRequest/.test(body),
|
|||
|
|
"task_report_list 不再是管理员专属(负责人按 scope 过滤)",
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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 require a task owner before binding", () => {
|
|||
|
|
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
|
|||
|
|
const body = section(actionRoute, action);
|
|||
|
|
assert.match(
|
|||
|
|
body,
|
|||
|
|
/请先设置任务负责人/,
|
|||
|
|
`${action} 必须在无负责人时明确报错`,
|
|||
|
|
);
|
|||
|
|
assert.match(body, /Response\.json\(/);
|
|||
|
|
assert.ok(
|
|||
|
|
!/getDashboardData/.test(body),
|
|||
|
|
`${action} 显式返回,不能落兜底 dashboard`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const bind = section(actionRoute, "task_bind_customer_chat");
|
|||
|
|
assert.match(bind, /bindTaskCustomerChat\(/);
|
|||
|
|
assert.match(bind, /unbindTaskCustomerChat\(/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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 type TaskReportRow"),
|
|||
|
|
);
|
|||
|
|
const occurrences = dashboard.match(/WHERE t\.owner_userid = \?/g) ?? [];
|
|||
|
|
assert.equal(
|
|||
|
|
occurrences.length,
|
|||
|
|
2,
|
|||
|
|
"tasks 与 distributions 两条查询都要按 owner 过滤",
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("export routes enforce owner scope", () => {
|
|||
|
|
assert.match(taskReportExportRoute, /getOwnerScope\(request\)/);
|
|||
|
|
assert.match(taskReportExportRoute, /ownerScopeUserid\(await getOwnerScope\(request\)\)/);
|
|||
|
|
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/);
|
|||
|
|
});
|