feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列 - 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移 - 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts - 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口 - 工作台项目进度卡与未绑定 banner 排版修复 - 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
This commit is contained in:
@@ -34,6 +34,10 @@ 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}"`);
|
||||
@@ -44,9 +48,6 @@ function section(source, action) {
|
||||
|
||||
test("manager-only actions gate on owner scope", () => {
|
||||
const managerOnly = [
|
||||
"inspect_feishu",
|
||||
"create_task",
|
||||
"create_screenshot_task",
|
||||
"claim",
|
||||
"save_collection_schedule",
|
||||
"run_due_collections",
|
||||
@@ -57,18 +58,9 @@ test("manager-only actions gate on owner scope", () => {
|
||||
"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",
|
||||
];
|
||||
@@ -82,6 +74,86 @@ test("manager-only actions gate on owner scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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\(\)/);
|
||||
@@ -122,6 +194,30 @@ test("task_report_list scopes reports by owner and drops the broken gate", () =>
|
||||
);
|
||||
});
|
||||
|
||||
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))"),
|
||||
@@ -181,11 +277,14 @@ test("getDashboardData filters tasks and distributions by owner", () => {
|
||||
mvpDb.indexOf("export async function getDashboardData"),
|
||||
mvpDb.indexOf("export type TaskReportRow"),
|
||||
);
|
||||
const occurrences = dashboard.match(/WHERE t\.owner_userid = \?/g) ?? [];
|
||||
const occurrences =
|
||||
dashboard.match(
|
||||
/WHERE \(t\.owner_userid = \? OR \(t\.project_id != '' AND pj\.owner_userid = \?\)\)/g,
|
||||
) ?? [];
|
||||
assert.equal(
|
||||
occurrences.length,
|
||||
2,
|
||||
"tasks 与 distributions 两条查询都要按 owner 过滤",
|
||||
"tasks 与 distributions 两条查询都要按 union scope 过滤(任务负责人 OR 项目负责人)",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,29 @@ function captureDb(rowsByPattern) {
|
||||
return { db, captured };
|
||||
}
|
||||
|
||||
test("isTaskOwner matches task owner_userid binding", async () => {
|
||||
test("isTaskOwner union-matches task owner or project owner", async () => {
|
||||
const { db, captured } = captureDb([
|
||||
[
|
||||
/FROM tasks t\s+LEFT JOIN projects p ON p\.id = t\.project_id/,
|
||||
(values) =>
|
||||
values[0] === "task-1" &&
|
||||
values[1] === "zhangsan" &&
|
||||
values[2] === "zhangsan"
|
||||
? { id: "task-1" }
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true);
|
||||
assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false);
|
||||
assert.equal(captured.length, 2);
|
||||
assert.match(
|
||||
captured[0].sql,
|
||||
/t\.owner_userid = \? OR p\.owner_userid = \?/,
|
||||
);
|
||||
assert.deepEqual(captured[0].values, ["task-1", "zhangsan", "zhangsan"]);
|
||||
});
|
||||
|
||||
test("isTaskOwner strict mode only matches task owner_userid", async () => {
|
||||
const { db, captured } = captureDb([
|
||||
[
|
||||
/FROM tasks WHERE id = \? AND owner_userid = \?/,
|
||||
@@ -50,9 +72,18 @@ test("isTaskOwner matches task owner_userid binding", async () => {
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true);
|
||||
assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false);
|
||||
assert.equal(await isTaskOwner(db, "task-1", "zhangsan", { strict: true }), true);
|
||||
assert.equal(
|
||||
await isTaskOwner(db, "task-1", "project-owner", { strict: true }),
|
||||
false,
|
||||
"strict 门不允许项目负责人代操作任务负责人的客户群",
|
||||
);
|
||||
assert.equal(captured.length, 2);
|
||||
assert.ok(
|
||||
!/LEFT JOIN projects/.test(captured[0].sql),
|
||||
"strict 查询不应 JOIN projects",
|
||||
);
|
||||
assert.deepEqual(captured[0].values, ["task-1", "zhangsan"]);
|
||||
});
|
||||
|
||||
test("isTaskOwner guards empty taskId / wecomUserId without querying", async () => {
|
||||
@@ -63,12 +94,14 @@ test("isTaskOwner guards empty taskId / wecomUserId without querying", async ()
|
||||
assert.equal(captured.length, 0);
|
||||
});
|
||||
|
||||
test("isDistributionOwner resolves distribution → task owner", async () => {
|
||||
const { db } = captureDb([
|
||||
test("isDistributionOwner union-matches via task or project owner", async () => {
|
||||
const { db, captured } = captureDb([
|
||||
[
|
||||
/FROM distributions d\s+JOIN tasks t ON t\.id = d\.task_id/,
|
||||
/FROM distributions d\s+JOIN tasks t ON t\.id = d\.task_id\s+LEFT JOIN projects p ON p\.id = t\.project_id/,
|
||||
(values) =>
|
||||
values[0] === "dist-1" && values[1] === "zhangsan"
|
||||
values[0] === "dist-1" &&
|
||||
values[1] === "zhangsan" &&
|
||||
values[2] === "zhangsan"
|
||||
? { id: "dist-1" }
|
||||
: null,
|
||||
],
|
||||
@@ -76,6 +109,11 @@ test("isDistributionOwner resolves distribution → task owner", async () => {
|
||||
assert.equal(await isDistributionOwner(db, "dist-1", "zhangsan"), true);
|
||||
assert.equal(await isDistributionOwner(db, "dist-9", "zhangsan"), false);
|
||||
assert.equal(await isDistributionOwner(db, "dist-1", ""), false);
|
||||
assert.match(
|
||||
captured[0].sql,
|
||||
/t\.owner_userid = \? OR p\.owner_userid = \?/,
|
||||
);
|
||||
assert.deepEqual(captured[0].values, ["dist-1", "zhangsan", "zhangsan"]);
|
||||
});
|
||||
|
||||
test("ownerScopeUserid maps scope kinds to filter value", () => {
|
||||
@@ -88,8 +126,11 @@ test("getTaskReportData filters by owner when ownerUserid given", async () => {
|
||||
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
|
||||
await getTaskReportData(db, "zhangsan");
|
||||
assert.equal(captured.length, 1);
|
||||
assert.match(captured[0].sql, /WHERE t\.owner_userid = \?/);
|
||||
assert.deepEqual(captured[0].values, ["zhangsan"]);
|
||||
assert.match(
|
||||
captured[0].sql,
|
||||
/WHERE \(t\.owner_userid = \? OR p\.owner_userid = \?\)/,
|
||||
);
|
||||
assert.deepEqual(captured[0].values, ["zhangsan", "zhangsan"]);
|
||||
});
|
||||
|
||||
test("getTaskReportData keeps global aggregation without ownerUserid", async () => {
|
||||
|
||||
@@ -190,8 +190,12 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
async all() {
|
||||
executed.push({ sql, params });
|
||||
if (/FROM projects p/.test(sql)) {
|
||||
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
|
||||
const rows = ownerFiltered
|
||||
? projects.filter((item) => item.owner_userid === params[0])
|
||||
: projects;
|
||||
return {
|
||||
results: projects.map((item) => ({
|
||||
results: rows.map((item) => ({
|
||||
...item,
|
||||
task_count: tasks.filter((task) => task.project_id === item.id).length,
|
||||
})),
|
||||
@@ -360,6 +364,29 @@ test("listProjects aggregates task counts and exposes mainChatId", async () => {
|
||||
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
|
||||
});
|
||||
|
||||
test("listProjects filters by project owner when ownerUserid given", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({ id: "proj-1", owner_userid: "owner-a" }),
|
||||
projectRow({ id: "proj-2", owner_userid: "owner-b" }),
|
||||
projectRow({ id: "proj-3", owner_userid: "" }),
|
||||
],
|
||||
});
|
||||
const mine = await listProjects(db, "owner-a");
|
||||
assert.deepEqual(
|
||||
mine.map((item) => item.id),
|
||||
["proj-1"],
|
||||
);
|
||||
const unbound = await listProjects(db, "");
|
||||
assert.deepEqual(
|
||||
unbound,
|
||||
[],
|
||||
"已登录未绑定企微的空范围不应漏出无主项目",
|
||||
);
|
||||
const all = await listProjects(db);
|
||||
assert.equal(all.length, 3);
|
||||
});
|
||||
|
||||
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
|
||||
@@ -32,6 +32,11 @@ const reportRow = {
|
||||
latest_comments: 40,
|
||||
latest_collects: 60,
|
||||
latest_shares: 10,
|
||||
related_task_id: "",
|
||||
related_task_name: null,
|
||||
reverse_related_task_id: null,
|
||||
reverse_related_task_name: null,
|
||||
submitted_count: 0,
|
||||
};
|
||||
|
||||
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
||||
@@ -71,6 +76,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
||||
"最新评论",
|
||||
"最新收藏",
|
||||
"最新转发",
|
||||
"关联任务",
|
||||
"截图提交数",
|
||||
].join(","),
|
||||
);
|
||||
assert.equal(
|
||||
@@ -105,6 +112,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
||||
40,
|
||||
60,
|
||||
10,
|
||||
"",
|
||||
"",
|
||||
].join(","),
|
||||
);
|
||||
});
|
||||
@@ -136,9 +145,26 @@ test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () =>
|
||||
const rowLine = csv.slice(1).split("\r\n")[1];
|
||||
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
|
||||
const cells = rowLine.split(",");
|
||||
assert.equal(cells.at(-1), "", "null latest_shares becomes empty");
|
||||
assert.equal(cells.at(-18), "", "no claims means empty completion rate");
|
||||
assert.equal(cells.at(-16), "2026-08-01 10:00:00");
|
||||
assert.equal(cells.at(-1), "", "content publish row leaves screenshot count empty");
|
||||
assert.equal(cells.at(-2), "", "no related task leaves empty");
|
||||
assert.equal(cells.at(-20), "", "no claims means empty completion rate");
|
||||
assert.equal(cells.at(-18), "2026-08-01 10:00:00");
|
||||
});
|
||||
|
||||
test("buildTaskReportCsv writes related batch and screenshot submission for collect tasks", () => {
|
||||
const csv = buildTaskReportCsv([
|
||||
{
|
||||
...reportRow,
|
||||
task_type: "screenshot_collect",
|
||||
related_task_id: "task-B",
|
||||
related_task_name: "夏日种草计划",
|
||||
submitted_count: 7,
|
||||
claimed_count: 12,
|
||||
},
|
||||
]);
|
||||
const cells = csv.slice(1).split("\r\n")[1].split(",");
|
||||
assert.equal(cells.at(-2), "夏日种草计划");
|
||||
assert.equal(cells.at(-1), "7");
|
||||
});
|
||||
|
||||
test("getTaskReportData aggregates per task via distributions join", async () => {
|
||||
@@ -161,4 +187,6 @@ test("getTaskReportData aggregates per task via distributions join", async () =>
|
||||
assert.match(capturedSql, /GROUP BY t\.id/);
|
||||
assert.match(capturedSql, /publish_url/);
|
||||
assert.match(capturedSql, /latest_shares/);
|
||||
assert.match(capturedSql, /related_task_id/);
|
||||
assert.match(capturedSql, /result_submitted_at/);
|
||||
});
|
||||
|
||||
92
tests/wecom-bind.test.mjs
Normal file
92
tests/wecom-bind.test.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
signWecomBindState,
|
||||
verifyWecomBindState,
|
||||
signWecomIdentityTicket,
|
||||
WECOM_BIND_STATE_TTL_MS,
|
||||
} from "../lib/wecom-oauth-service.ts";
|
||||
|
||||
const SECRET = "internal-token-test";
|
||||
|
||||
const bindRoute = readFileSync(
|
||||
new URL("../app/api/wecom-bind/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const adminApp = readFileSync(
|
||||
new URL("../app/admin-app.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("bind state sign → verify roundtrip", async () => {
|
||||
const state = await signWecomBindState("user-abc", { secret: SECRET });
|
||||
const verified = await verifyWecomBindState(state, { secret: SECRET });
|
||||
assert.deepEqual(verified, { userId: "user-abc" });
|
||||
});
|
||||
|
||||
test("bind state rejects expiry, tampering, wrong secret", async () => {
|
||||
const state = await signWecomBindState("user-abc", {
|
||||
secret: SECRET,
|
||||
nowMs: Date.now() - WECOM_BIND_STATE_TTL_MS - 120_000,
|
||||
});
|
||||
assert.equal(await verifyWecomBindState(state, { secret: SECRET }), null);
|
||||
|
||||
const fresh = await signWecomBindState("user-abc", { secret: SECRET });
|
||||
const [payload, signature] = fresh.split(".");
|
||||
assert.ok(payload.length > 8);
|
||||
const flip = payload[5] === "a" ? "b" : "a";
|
||||
const tampered = `${payload.slice(0, 5)}${flip}${payload.slice(6)}.${signature}`;
|
||||
assert.notEqual(tampered, fresh);
|
||||
assert.equal(await verifyWecomBindState(tampered, { secret: SECRET }), null);
|
||||
assert.equal(
|
||||
await verifyWecomBindState(fresh, { secret: "other-secret" }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await verifyWecomBindState("garbage", { secret: SECRET }), null);
|
||||
assert.equal(await verifyWecomBindState("", { secret: SECRET }), null);
|
||||
});
|
||||
|
||||
test("identity ticket cannot be replayed as bind state", async () => {
|
||||
const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
|
||||
secret: SECRET,
|
||||
});
|
||||
assert.equal(await verifyWecomBindState(ticket, { secret: SECRET }), null);
|
||||
});
|
||||
|
||||
test("bind state requires a user id", async () => {
|
||||
await assert.rejects(
|
||||
() => signWecomBindState("", { secret: SECRET }),
|
||||
/后台账号 ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test("bind route: start redirects logged-in users to wecom oauth", () => {
|
||||
assert.ok(bindRoute.includes("buildWecomOAuthUrl"));
|
||||
assert.ok(bindRoute.includes("signWecomBindState"));
|
||||
assert.ok(bindRoute.includes("redirectUri"));
|
||||
assert.ok(/APP_ORIGIN/.test(bindRoute));
|
||||
assert.ok(/principal\.kind !== "user"/.test(bindRoute));
|
||||
});
|
||||
|
||||
test("bind route: callback verifies state, session and conflicts", () => {
|
||||
assert.ok(bindRoute.includes("verifyWecomBindState"));
|
||||
assert.ok(/principal\.user\.id !== verified\.userId/.test(bindRoute));
|
||||
assert.ok(bindRoute.includes("exchangeWecomOAuthCode"));
|
||||
assert.ok(bindRoute.includes("!identity.userId"));
|
||||
assert.ok(
|
||||
/SELECT id, username FROM users WHERE wecom_userid = \? AND id <> \?/.test(
|
||||
bindRoute,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
/UPDATE users SET wecom_userid = \?, updated_at = CURRENT_TIMESTAMP WHERE id = \?/.test(
|
||||
bindRoute,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("admin banner exposes self-service bind entry", () => {
|
||||
assert.ok(adminApp.includes('href="/api/wecom-bind"'));
|
||||
assert.ok(adminApp.includes("owner-bind-button"));
|
||||
});
|
||||
Reference in New Issue
Block a user