Files
koc-loop/tests/owner-permissions.test.mjs
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

205 lines
6.5 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 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/);
});