- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列 - 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移 - 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts - 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口 - 工作台项目进度卡与未绑定 banner 排版修复 - 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
304 lines
9.8 KiB
JavaScript
304 lines
9.8 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",
|
||
);
|
||
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/);
|
||
});
|