Files
koc-loop/tests/owner-permissions.test.mjs
ABAPPLO 8f1ebf7697 feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列
- 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移
- 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts
- 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口
- 工作台项目进度卡与未绑定 banner 排版修复
- 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
2026-09-17 15:21:24 +08:00

304 lines
9.8 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",
);
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",
"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",
"create_screenshot_task",
]) {
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("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("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 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 = \? 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(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/);
});