WARCH2 增加企微审批历史只读查询

这个提交包含在:
2026-05-20 10:23:23 +08:00
父节点 7a32399ca9
当前提交 123ebc9c95
共修改 23 个文件,包含 1358 行新增和 36 行删除
+2
查看文件
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth");
const wecomAuthRoutes = require("./routes/wecomAuth");
const wecomApprovalArchiveRoutes = require("./routes/wecomApprovalArchive");
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
@@ -337,6 +338,7 @@ async function buildApp() {
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
await app.register(eventsRoutes, { prefix: "/api" });
await app.register(meRoutes, { prefix: "/api" });
+66
查看文件
@@ -0,0 +1,66 @@
const { getApprovalArchiveDetail, listApprovalArchives } = require("../services/integrations/wecomApprovalArchive");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const ARCHIVE_VIEW_PERMISSIONS = new Set([
"leave.view_own",
"attendance.view_own",
"leave.view_all",
"attendance.view_all",
"*",
]);
function notFound() {
return new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
function hasArchiveViewPermission(employee = {}) {
const roleCodes = employee.roleCodes || [];
const permissionCodes = employee.permissionCodes || [];
return roleCodes.includes("admin")
|| roleCodes.includes("hr")
|| permissionCodes.some((code) => ARCHIVE_VIEW_PERMISSIONS.has(code));
}
function ensureArchiveViewPermission(request) {
if (hasArchiveViewPermission(request.employee)) {
return;
}
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: "leave.view_own|attendance.view_own" },
});
}
async function wecomApprovalArchiveRoutes(fastify) {
fastify.get("/wecom/approval-archive", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureArchiveViewPermission(request);
const result = await listApprovalArchives({
prisma: fastify.prisma,
actor: request.employee,
query: request.query || {},
});
return sendData(reply, request, result);
});
fastify.get("/wecom/approval-archive/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureArchiveViewPermission(request);
const item = await getApprovalArchiveDetail({
prisma: fastify.prisma,
actor: request.employee,
archiveId: request.params?.id,
});
if (!item) {
throw notFound();
}
return sendData(reply, request, { item });
});
}
module.exports = wecomApprovalArchiveRoutes;
@@ -334,6 +334,314 @@ function publicArchiveSummary(detailOrRecord) {
};
}
function toIso(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function safeDateInput(value) {
const raw = toSafeString(value, 80);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? null : date;
}
function safePositiveInt(value, fallback, { min = 1, max = 100 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(numeric)));
}
function safeEnum(value, allowed, fallback) {
const raw = toSafeString(value, 80);
return allowed.includes(raw) ? raw : fallback;
}
function safeEmployeeSummary(employee) {
if (!employee) return null;
return {
id: employee.id,
employeeNo: employee.employee_no || null,
name: employee.name || "",
};
}
function isArchiveAdmin(actor = {}) {
const roleCodes = actor.roleCodes || [];
const permissionCodes = actor.permissionCodes || [];
return roleCodes.includes("admin") || roleCodes.includes("hr") || permissionCodes.includes("*");
}
function buildSelfArchiveWhere(employeeId) {
return {
OR: [
{ applicant_employee_id: employeeId },
{
AND: [
{ applicant_employee_id: { not: null } },
{ participants: { some: { employee_id: employeeId } } },
],
},
{
AND: [
{ applicant_employee_id: { not: null } },
{ actions: { some: { operator_employee_id: employeeId } } },
],
},
],
};
}
function impossibleWhere() {
return { id: "__not_accessible__" };
}
async function resolveDirectReportIds(prisma, actor) {
if (!prisma?.employee || !actor?.id) return [];
const rows = await prisma.employee.findMany({
where: { status: "active", direct_manager_id: actor.id },
select: { id: true },
});
return rows.map((item) => item.id).filter(Boolean);
}
function normalizeArchiveQuery(query = {}, actor = {}, directReportIds = []) {
const requestedScope = safeEnum(query.scope, ["self", "team", "all"], "self");
const canAll = isArchiveAdmin(actor);
const scope = requestedScope === "all" && !canAll ? "self" : requestedScope;
const page = safePositiveInt(query.page, 1, { min: 1, max: 10000 });
const pageSize = safePositiveInt(query.pageSize ?? query.limit, 20, { min: 1, max: 100 });
const type = safeEnum(query.type, ["leave", "missed_punch"], "");
const status = safeEnum(query.status, ["pending", "approved", "rejected", "revoked", "terminated", "changed", "unknown"], "");
const finding = safeEnum(query.finding, ["open", "resolved", "any"], "");
const employeeId = toSafeString(query.employeeId, 80);
const from = safeDateInput(query.from || query.startTime || query.start_time);
const to = safeDateInput(query.to || query.endTime || query.end_time);
const allowedApplicantIds = new Set([actor.id, ...directReportIds].filter(Boolean));
return {
scope,
requestedScope,
canAll,
page,
pageSize,
type,
status,
finding,
employeeId,
from,
to,
allowedApplicantIds,
directReportIds,
};
}
function buildArchiveAccessWhere({ actor, normalized }) {
if (!actor?.id) return impossibleWhere();
const selfWhere = buildSelfArchiveWhere(actor.id);
if (normalized.scope === "all" && normalized.canAll) {
return {};
}
if (normalized.scope === "team") {
const teamApplicantWhere = normalized.directReportIds.length
? { applicant_employee_id: { in: normalized.directReportIds } }
: impossibleWhere();
return { OR: [selfWhere, teamApplicantWhere] };
}
return selfWhere;
}
function buildArchiveFilterWhere({ actor, normalized }) {
const AND = [buildArchiveAccessWhere({ actor, normalized })];
if (normalized.type) AND.push({ template_type: normalized.type });
if (normalized.status) AND.push({ normalized_status: normalized.status });
if (normalized.from || normalized.to) {
AND.push({
OR: [
{ status_observed_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
{ applied_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
{ submitted_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
],
});
}
if (normalized.employeeId) {
if (!normalized.canAll && !normalized.allowedApplicantIds.has(normalized.employeeId)) {
AND.push(impossibleWhere());
} else {
AND.push({ applicant_employee_id: normalized.employeeId });
}
}
if (normalized.finding === "open") {
AND.push({ reconcile_findings: { some: { status: "open" } } });
} else if (normalized.finding === "resolved") {
AND.push({ reconcile_findings: { some: { status: "resolved" } } });
} else if (normalized.finding === "any") {
AND.push({ reconcile_findings: { some: {} } });
}
return AND.length === 1 ? AND[0] : { AND };
}
const ARCHIVE_QUERY_INCLUDE = {
applicant: { select: { id: true, employee_no: true, name: true } },
participants: {
orderBy: [{ node_index: "asc" }, { role: "asc" }],
select: {
id: true,
role: true,
node_index: true,
employee_id: true,
is_current: true,
mapping_status: true,
employee: { select: { id: true, employee_no: true, name: true } },
},
},
actions: {
orderBy: [{ action_time: "asc" }, { node_index: "asc" }],
select: {
id: true,
action_type: true,
operator_employee_id: true,
action_time: true,
node_index: true,
source: true,
operator: { select: { id: true, employee_no: true, name: true } },
},
},
field_summaries: {
orderBy: [{ field_index: "asc" }],
select: {
id: true,
field_key: true,
field_index: true,
field_name: true,
field_type: true,
sensitive_level: true,
display_allowed: true,
},
},
reconcile_findings: {
orderBy: [{ detected_at: "desc" }],
select: {
id: true,
finding_type: true,
status: true,
severity: true,
source: true,
detected_at: true,
resolved_at: true,
},
},
};
function mapArchiveListItem(row) {
return {
id: row.id,
spNoPrefix: maskApprovalIdentifier(row.sp_no),
templateIdPrefix: maskApprovalIdentifier(row.template_id),
templateType: row.template_type || "unknown",
spName: row.sp_name || null,
normalizedStatus: row.normalized_status || "unknown",
applicant: safeEmployeeSummary(row.applicant),
appliedAt: toIso(row.applied_at),
submittedAt: toIso(row.submitted_at),
completedAt: toIso(row.completed_at),
statusObservedAt: toIso(row.status_observed_at),
lastSource: row.last_source || null,
lastCallbackAt: toIso(row.last_callback_at),
lastCompensationAt: toIso(row.last_compensation_at),
readabilityStatus: row.readability_status || null,
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
participantCount: row.participants?.length || 0,
actionCount: row.actions?.length || 0,
fieldCount: row.field_summaries?.length || 0,
findingCount: row.reconcile_findings?.length || 0,
openFindingCount: row.reconcile_findings?.filter((item) => item.status === "open").length || 0,
};
}
function mapArchiveDetail(row) {
return {
...mapArchiveListItem(row),
participants: (row.participants || []).map((item) => ({
id: item.id,
role: item.role,
nodeIndex: item.node_index,
employee: safeEmployeeSummary(item.employee),
isCurrent: Boolean(item.is_current),
mappingStatus: item.employee_id ? "mapped" : item.mapping_status || "unknown",
})),
actions: (row.actions || []).map((item) => ({
id: item.id,
actionType: item.action_type,
operator: safeEmployeeSummary(item.operator),
actionTime: toIso(item.action_time),
nodeIndex: item.node_index,
source: item.source,
})),
fields: (row.field_summaries || []).map((item) => ({
id: item.id,
fieldKey: item.field_key,
fieldIndex: item.field_index,
fieldName: item.field_name || null,
fieldType: item.field_type || null,
sensitiveLevel: item.sensitive_level || "normal",
displayAllowed: Boolean(item.display_allowed),
})),
findings: (row.reconcile_findings || []).map((item) => ({
id: item.id,
findingType: item.finding_type,
status: item.status,
severity: item.severity,
source: item.source,
detectedAt: toIso(item.detected_at),
resolvedAt: toIso(item.resolved_at),
})),
};
}
async function listApprovalArchives({ prisma, actor, query = {} }) {
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
const directReportIds = await resolveDirectReportIds(prisma, actor);
const normalized = normalizeArchiveQuery(query, actor, directReportIds);
const where = buildArchiveFilterWhere({ actor, normalized });
const skip = (normalized.page - 1) * normalized.pageSize;
const [total, rows] = await Promise.all([
prisma.wecomApprovalArchive.count({ where }),
prisma.wecomApprovalArchive.findMany({
where,
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }, { id: "desc" }],
skip,
take: normalized.pageSize,
include: ARCHIVE_QUERY_INCLUDE,
}),
]);
return {
scope: normalized.scope,
requestedScope: normalized.requestedScope,
canViewAll: normalized.canAll,
page: normalized.page,
pageSize: normalized.pageSize,
total,
status: total > 0 ? "OK" : "PARTIAL_ARCHIVE_NEEDS_DATA",
items: rows.map(mapArchiveListItem),
};
}
async function getApprovalArchiveDetail({ prisma, actor, archiveId }) {
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
const id = toSafeString(archiveId, 80);
if (!id) return null;
const directReportIds = await resolveDirectReportIds(prisma, actor);
const normalized = normalizeArchiveQuery({ scope: isArchiveAdmin(actor) ? "all" : "team" }, actor, directReportIds);
const accessWhere = buildArchiveAccessWhere({ actor, normalized });
const row = await prisma.wecomApprovalArchive.findFirst({
where: { AND: [{ id }, accessWhere] },
include: ARCHIVE_QUERY_INCLUDE,
});
return row ? mapArchiveDetail(row) : null;
}
async function mapEmployeeIdByWecomUserId(tx, externalUserId) {
const userId = toSafeString(externalUserId, 128);
if (!userId || !tx?.wecomIdentityBinding) return null;
@@ -769,6 +1077,8 @@ module.exports = {
digestObject,
finishCompensationRun,
isTerminalStatus,
getApprovalArchiveDetail,
listApprovalArchives,
normalizeApprovalDetailPayload,
normalizeWecomApprovalStatus,
persistApprovalDetail,
@@ -0,0 +1,358 @@
const Fastify = require("fastify");
const wecomApprovalArchiveRoutes = require("../../../src/routes/wecomApprovalArchive");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
const MANAGER_ID = "33333333-3333-4333-8333-333333333333";
const REPORT_ID = "44444444-4444-4444-8444-444444444444";
const PEER_ID = "55555555-5555-4555-8555-555555555555";
const FINANCE_ID = "66666666-6666-4666-8666-666666666666";
const HR_ID = "77777777-7777-4777-8777-777777777777";
const FULL_SP_NO = "SPWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_TEMPLATE_ID = "TPLWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_USER_ID = "wecom_full_userid_should_not_leak_warch2";
const FULL_PHONE = "13900001111";
const FULL_EMAIL = "[email protected]";
const FULL_FIELD_VALUE = "完整请假原因不应该出现在查询响应中";
const FULL_COMMENT = "审批评论不应该出现在查询响应中";
const FULL_SOURCE = "raw payload must not leak";
const FULL_ERROR = "internal processing error must not leak";
function employee(id, name, overrides = {}) {
return {
id,
employee_no: `NO-${id.slice(0, 4)}`,
name,
status: "active",
direct_manager_id: null,
department_id: "department-a",
...overrides,
};
}
const EMPLOYEES = [
employee(EMPLOYEE_ID, "员工本人"),
employee(OTHER_ID, "其他员工"),
employee(MANAGER_ID, "直属主管"),
employee(REPORT_ID, "直属下属", { direct_manager_id: MANAGER_ID }),
employee(PEER_ID, "同部门非直属"),
employee(FINANCE_ID, "财务"),
employee(HR_ID, "HR"),
];
function archiveRow(overrides = {}) {
const applicantId = overrides.applicant_employee_id === undefined ? EMPLOYEE_ID : overrides.applicant_employee_id;
const applicant = applicantId ? EMPLOYEES.find((item) => item.id === applicantId) : null;
return {
id: overrides.id || `archive-${Math.random().toString(36).slice(2)}`,
sp_no: overrides.sp_no || FULL_SP_NO,
template_id: overrides.template_id || FULL_TEMPLATE_ID,
template_type: overrides.template_type || "leave",
sp_name: overrides.sp_name || "OA-POC-请假",
normalized_status: overrides.normalized_status || "approved",
applicant_employee_id: applicantId,
applicant_external_user_id: overrides.applicant_external_user_id || FULL_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: FULL_SOURCE },
processing_error_message: FULL_ERROR,
error_message: FULL_ERROR,
applicant,
participants: overrides.participants || [],
actions: overrides.actions || [],
field_summaries: overrides.field_summaries || [{
id: `field-${overrides.id || "1"}`,
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: FULL_FIELD_VALUE,
value_preview: FULL_FIELD_VALUE.slice(0, 10),
sensitive_level: "restricted",
display_allowed: false,
}],
attachment_summaries: [{
id: `attach-${overrides.id || "1"}`,
external_ref_summary: "attachment raw ref should not leak",
}],
reconcile_findings: overrides.reconcile_findings || [],
...overrides,
};
}
function participant(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `participant-${employeeId || "external"}`,
role: overrides.role || "approver",
node_index: overrides.node_index ?? 1,
external_user_id: overrides.external_user_id || FULL_USER_ID,
employee_id: employeeId,
is_current: false,
mapping_status: employeeId ? "mapped_or_external" : "external_only",
display_summary: FULL_USER_ID,
employee: linked,
...overrides,
};
}
function action(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `action-${employeeId || "external"}`,
action_type: overrides.action_type || "approve",
operator_external_user_id: FULL_USER_ID,
operator_employee_id: employeeId,
action_time: new Date("2026-05-19T09:00:00.000Z"),
node_index: 1,
comment_summary: FULL_COMMENT,
source: "compensation_pull",
operator: linked,
...overrides,
};
}
function matchesScalar(actual, condition) {
if (condition && typeof condition === "object" && !Array.isArray(condition)) {
if (condition.in) return condition.in.includes(actual);
if (Object.prototype.hasOwnProperty.call(condition, "not") && actual === condition.not) return false;
if (condition.gte && actual < condition.gte) return false;
if (condition.lte && actual > condition.lte) return false;
return true;
}
return actual === condition;
}
function matchesWhere(row, where) {
if (!where || !Object.keys(where).length) return true;
if (where.AND) return where.AND.every((item) => matchesWhere(row, item));
if (where.OR) return where.OR.some((item) => matchesWhere(row, item));
return Object.entries(where).every(([key, condition]) => {
if (key === "participants") {
return row.participants.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "actions") {
return row.actions.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "reconcile_findings") {
return row.reconcile_findings.some((item) => matchesWhere(item, condition.some || {}));
}
return matchesScalar(row[key], condition);
});
}
function buildPrisma(rows) {
return {
employee: {
findMany: vi.fn(async ({ where }) => EMPLOYEES.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.direct_manager_id && item.direct_manager_id !== where.direct_manager_id) return false;
return true;
}).map((item) => ({ id: item.id }))),
},
wecomApprovalArchive: {
count: vi.fn(async ({ where }) => rows.filter((row) => matchesWhere(row, where)).length),
findMany: vi.fn(async ({ where, skip = 0, take = 20 }) => rows.filter((row) => matchesWhere(row, where)).slice(skip, skip + take)),
findFirst: vi.fn(async ({ where }) => rows.find((row) => matchesWhere(row, where)) || null),
},
};
}
async function buildApp({ actor, rows }) {
const app = Fastify();
app.decorate("prisma", buildPrisma(rows));
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: error.message || "服务器内部错误", details: {} },
});
});
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
return app;
}
function actor(id, roleCodes = ["employee"], permissionCodes = ["leave.view_own", "attendance.view_own"]) {
return {
id,
roleCodes,
permissionCodes,
department_id: "department-a",
};
}
function responseText(response) {
return JSON.stringify(response.json());
}
describe("wecomApprovalArchive routes", () => {
beforeEach(() => {
global.fetch = vi.fn(async () => {
throw new Error("WeCom API must not be called by archive query");
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lets normal employees see only self applicant, participant or action histories", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "participant-related", applicant_employee_id: OTHER_ID, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "action-related", applicant_employee_id: OTHER_ID, actions: [action(EMPLOYEE_ID)] }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
archiveRow({ id: "unmapped-applicant-participant", applicant_employee_id: null, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "unmapped-applicant-action", applicant_employee_id: null, actions: [action(EMPLOYEE_ID)] }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
expect(response.statusCode).toBe(200);
const ids = response.json().data.items.map((item) => item.id);
expect(ids).toEqual(["self-applicant", "participant-related", "action-related"]);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("requires an archive view permission even for self-related histories", async () => {
const rows = [archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID })];
const app = await buildApp({ actor: actor(EMPLOYEE_ID, ["employee"], []), rows });
const listResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/self-applicant" });
expect(listResponse.statusCode).toBe(403);
expect(detailResponse.statusCode).toBe(403);
await app.close();
});
it("limits managers to direct-report applicant histories plus own related histories", async () => {
const rows = [
archiveRow({ id: "direct-report", applicant_employee_id: REPORT_ID }),
archiveRow({ id: "manager-related", applicant_employee_id: OTHER_ID, participants: [participant(MANAGER_ID)] }),
archiveRow({ id: "same-department-peer", applicant_employee_id: PEER_ID }),
archiveRow({ id: "unrelated", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(MANAGER_ID, ["manager"], ["leave.view_all", "attendance.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=team" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["direct-report", "manager-related"]);
await app.close();
});
it("does not treat finance leave or attendance view_all as global WeCom archive access", async () => {
const rows = [
archiveRow({ id: "finance-self", applicant_employee_id: FINANCE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({
actor: actor(FINANCE_ID, ["finance"], ["leave.view_all", "attendance.view_all"]),
rows,
});
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.scope).toBe("self");
expect(response.json().data.items.map((item) => item.id)).toEqual(["finance-self"]);
await app.close();
});
it("allows HR/admin all-scope access including callback-only or unmapped records", async () => {
const rows = [
archiveRow({ id: "normal", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
];
const app = await buildApp({ actor: actor(HR_ID, ["hr"], ["leave.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["normal", "callback-only"]);
await app.close();
});
it("prevents employeeId filters and detail id guessing from bypassing authorization", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const listResponse = await app.inject({ method: "GET", url: `/api/wecom/approval-archive?employeeId=${OTHER_ID}` });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/other-applicant" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([]);
expect(detailResponse.statusCode).toBe(404);
await app.close();
});
it("returns whitelist-only redacted detail responses", async () => {
const rows = [
archiveRow({
id: "sensitive-detail",
applicant_employee_id: EMPLOYEE_ID,
participants: [participant(EMPLOYEE_ID)],
actions: [action(EMPLOYEE_ID)],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: FULL_FIELD_VALUE,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
}),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/sensitive-detail" });
expect(response.statusCode).toBe(200);
const text = responseText(response);
expect(text).not.toContain(FULL_SP_NO);
expect(text).not.toContain(FULL_TEMPLATE_ID);
expect(text).not.toContain(FULL_USER_ID);
expect(text).not.toContain(FULL_PHONE);
expect(text).not.toContain(FULL_EMAIL);
expect(text).not.toContain(FULL_FIELD_VALUE);
expect(text).not.toContain(FULL_COMMENT);
expect(text).not.toContain(FULL_SOURCE);
expect(text).not.toContain(FULL_ERROR);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("comment_summary");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("external_ref_summary");
expect(text).not.toContain("processing_error_message");
expect(text).not.toContain("error_message");
await app.close();
});
});
@@ -1,5 +1,6 @@
const {
normalizeApprovalDetailPayload,
listApprovalArchives,
persistApprovalDetail,
publicArchiveSummary,
recordApprovalCallbackEvent,
@@ -132,6 +133,78 @@ describe("wecomApprovalArchive", () => {
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("lists query archives with whitelist-only DTO fields", async () => {
const archive = {
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
sp_name: "OA-POC-请假",
normalized_status: "approved",
applicant_employee_id: null,
applicant_external_user_id: SENSITIVE_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: "raw payload should not leak" },
applicant: null,
participants: [],
actions: [],
field_summaries: [{
id: "field-1",
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: SENSITIVE_REASON,
value_preview: SENSITIVE_REASON.slice(0, 8),
sensitive_level: "restricted",
display_allowed: false,
}],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: SENSITIVE_REASON,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
};
const prisma = {
employee: { findMany: vi.fn(async () => []) },
wecomApprovalArchive: {
count: vi.fn(async () => 1),
findMany: vi.fn(async () => [archive]),
},
};
const result = await listApprovalArchives({
prisma,
actor: { id: "hr-1", roleCodes: ["hr"], permissionCodes: [] },
query: { scope: "all" },
});
expect(result.items).toHaveLength(1);
const text = JSON.stringify(result);
expect(text).not.toContain(SENSITIVE_SP_NO);
expect(text).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(text).not.toContain(SENSITIVE_USER_ID);
expect(text).not.toContain(SENSITIVE_REASON);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("description");
});
it("persists full keys internally but returns redacted output", async () => {
const prisma = buildMockPrisma();
const result = await persistApprovalDetail({