WARCH1 实现企微审批最小历史留存

这个提交包含在:
2026-05-20 00:22:27 +08:00
父节点 56a8e2bb0c
当前提交 5cd960cf90
共修改 28 个文件,包含 3081 行新增和 261 行删除
@@ -0,0 +1,164 @@
const {
executeArchiveCheck,
parseArgs,
sanitizeArchiveRow,
} = require("../../../src/scripts/check-wecom-approval-archive");
const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval");
const SENSITIVE_SP_NO = "SPARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_USER_ID = "archive_read_userid_full_should_not_leak";
const SENSITIVE_REASON = "原因全文不得进入离线报告";
function archiveRow(overrides = {}) {
return {
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
submitted_at: new Date("2026-05-19T01:00:00.000Z"),
completed_at: new Date("2026-05-19T02:00:00.000Z"),
status_observed_at: new Date("2026-05-19T02:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
participants: [{
role: "applicant",
node_index: 0,
external_user_id: SENSITIVE_USER_ID,
mapping_status: "mapped_or_external",
is_current: false,
}],
actions: [{
action_type: "approve",
operator_external_user_id: "approver_full_userid_should_not_leak",
action_time: new Date("2026-05-19T02:00:00.000Z"),
node_index: 1,
source: "compensation_pull",
comment_summary: SENSITIVE_REASON,
}],
field_summaries: [{
field_name: "请假原因",
field_type: "Textarea",
sensitive_level: "restricted",
display_allowed: false,
normalized_value: SENSITIVE_REASON,
}],
reconcile_findings: [],
_count: {
participants: 1,
actions: 1,
field_summaries: 1,
attachment_summaries: 0,
callback_events: 1,
compensation_items: 1,
reconcile_findings: 0,
},
...overrides,
};
}
describe("check-wecom-approval-archive", () => {
it("parses offline archive filters", () => {
const options = parseArgs([
"--offline",
"--format=json",
"--template=leave",
"--status=approved",
"--limit=50",
]);
expect(options).toMatchObject({
offline: true,
format: "json",
templates: ["leave"],
status: "approved",
limit: 50,
});
expect(() => parseArgs(["--template=outing"])).toThrow(/Unsupported --template/);
expect(() => parseArgs(["--limit=101"])).toThrow(/--limit/);
});
it("sanitizes archive rows without leaking full identifiers or restricted values", () => {
const sanitized = sanitizeArchiveRow(archiveRow());
const serialized = JSON.stringify(sanitized);
expect(sanitized.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO));
expect(sanitized.templateIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID));
expect(sanitized.participants[0].userIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_USER_ID));
expect(sanitized.fieldSummaries[0]).toMatchObject({
fieldName: null,
fieldType: "Textarea",
sensitiveLevel: "restricted",
displayAllowed: false,
});
expect(serialized).not.toContain(SENSITIVE_SP_NO);
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
expect(serialized).not.toContain(SENSITIVE_REASON);
});
it("passes when OA DB archived history can be read offline", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline", "--format=json"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => [archiveRow()]),
});
expect(report.status).toBe("PASS_ARCHIVE_HISTORY_READABLE");
expect(report.scope.wecomApiCalls).toBe(false);
expect(report.checks.archiveRead.status).toBe("OK");
expect(report.checks.archiveRead.count).toBe(1);
});
it("returns PASS_WITH_FINDINGS when open reconcile findings exist", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => [archiveRow({
reconcile_findings: [{
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
detected_at: new Date("2026-05-19T03:00:00.000Z"),
}],
})]),
});
expect(report.status).toBe("PASS_WITH_FINDINGS");
expect(report.checks.archives[0].openFindingCounts).toMatchObject({
callback_missing_for_pulled_detail: 1,
});
});
it("returns PARTIAL_ARCHIVE_NEEDS_DATA when no archive rows exist", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PARTIAL_ARCHIVE_NEEDS_DATA");
expect(report.configGaps.map((item) => item.id)).toContain("archive_history_data");
});
it("classifies DB read errors as external blockers", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => {
throw new Error("database down with secret-like-value-should-be-redacted");
}),
});
expect(report.status).toBe("SKIP");
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
expect(report.mainBlockers[0]).toMatchObject({
id: "archive_db_read",
reason: "oa_archive_db_read_failed",
});
});
});
@@ -98,12 +98,14 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(options.size).toBe(50);
expect(options.discoverTemplateIds).toBe(true);
expect(options.discoverApplicantEmployeeNo).toBe("SMOKE002");
expect(options.persistToDb).toBe(false);
expect(options.templates).toEqual({
leave: "tpl-leave",
missed_punch: "tpl-missed",
});
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
expect(() => parseArgs(["--size=101"])).toThrow(/--size/);
expect(parseArgs(["--persist-to-db"]).persistToDb).toBe(true);
});
it("builds a default 72-hour time window", () => {
@@ -396,6 +398,195 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2);
expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2);
expect(report.checks.callbackCompare.findings).toEqual([]);
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === false)).toBe(true);
expect(report.checks.archivePersistence.status).toBe("SKIPPED");
});
it("requires both --persist-to-db and WECOM_ARCHIVE_WRITE=1 before OA archive writes", async () => {
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
},
generatedAt: "2026-05-20T00:00:00.000Z",
fetchApprovalInfoListImpl: vi.fn(async () => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: { errcode: 0, spNoCount: 0 },
spNos: [],
nextCursor: "",
})),
fetchApprovalDetailImpl: vi.fn(),
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
expect(report.checks.archivePersistence).toMatchObject({
status: "NEEDS_CONFIG",
reason: "missing_archive_write_switch",
});
expect(report.configGaps.map((item) => item.id)).toContain("archive_persistence");
});
it("persists pulled details to OA archive only when the double switch is present", async () => {
const sensitiveLeaveSpNo = `${SENSITIVE_TEMPLATE_ID}-sp`;
const sensitiveMissedSpNo = "tpl-missed-punch-sp";
const mockPrisma = buildArchiveMockPrisma();
mockPrisma.wecomApprovalCallbackEvent.findMany.mockResolvedValueOnce([
{
callback_kind: "approval_status_change",
approval_type: "leave",
wecom_status: "2",
sp_no: sensitiveLeaveSpNo,
template_id: SENSITIVE_TEMPLATE_ID,
received_at: new Date("2026-05-20T00:00:00.000Z"),
},
{
callback_kind: "approval_status_change",
approval_type: "missed_punch",
wecom_status: "2",
sp_no: sensitiveMissedSpNo,
template_id: "tpl-missed-punch",
received_at: new Date("2026-05-20T00:00:01.000Z"),
},
]);
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: {
errcode: 0,
spNoCount: 1,
spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)],
},
spNos: [`${filters[0].value}-sp`],
nextCursor: "",
}));
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, {
spStatus: "2",
templateIdPrefix: spNo === sensitiveLeaveSpNo
? maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID)
: maskApprovalIdentifier("tpl-missed-punch"),
}),
rawPayload: includeRawPayload ? {
errcode: 0,
info: {
sp_no: spNo,
template_id: spNo === sensitiveLeaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
sp_name: spNo === sensitiveLeaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: { contents: [] },
sp_record: [],
},
} : undefined,
response: { errcode: 0, spStatus: "2" },
}));
const readCallbackEvidenceImpl = vi.fn(async () => []);
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
WECOM_ARCHIVE_WRITE: "1",
},
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: mockPrisma,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl,
});
expect(report.status).toBe("PASS_COMPENSATION_POC");
expect(report.checks.callbackEvidence.source).toBe("db");
expect(readCallbackEvidenceImpl).not.toHaveBeenCalled();
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === true)).toBe(true);
expect(mockPrisma.wecomApprovalCompensationRun.create).toHaveBeenCalledTimes(1);
expect(mockPrisma.wecomApprovalArchive.upsert).toHaveBeenCalledTimes(2);
expect(mockPrisma.wecomApprovalCompensationRun.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "run-archive-1" },
data: expect.objectContaining({
status: "completed",
item_count: 2,
detail_count: 2,
}),
}));
expect(report.checks.archivePersistence).toMatchObject({
status: "OK",
storedCount: 2,
failedCount: 0,
});
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(SENSITIVE_SP_NO);
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
});
it("records compare findings on the correct archive when masked prefixes collide", async () => {
const leaveSpNo = "20260519-leave-approval-full-id";
const missedSpNo = "20260519-missed-punch-sp";
const mockPrisma = buildArchiveMockPrisma();
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: { errcode: 0, spNoCount: 1 },
spNos: [filters[0].value === SENSITIVE_TEMPLATE_ID ? leaveSpNo : missedSpNo],
nextCursor: "",
}));
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, { spStatus: "2" }),
rawPayload: includeRawPayload ? {
errcode: 0,
info: {
sp_no: spNo,
template_id: spNo === leaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
sp_name: spNo === leaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: { contents: [] },
sp_record: [],
},
} : undefined,
response: { errcode: 0, spStatus: "2" },
}));
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
WECOM_ARCHIVE_WRITE: "1",
},
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: mockPrisma,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PASS_WITH_FINDINGS");
expect(report.checks.callbackCompare.findings).toHaveLength(2);
expect(report.checks.callbackCompare.findings.every((item) => item.spNoPrefix === "20260519***")).toBe(true);
const archiveIds = mockPrisma.wecomApprovalReconcileFinding.upsert.mock.calls
.map((call) => call[0].create.archive_id)
.sort();
expect(archiveIds).toEqual(["archive-leave", "archive-missed"]);
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(leaveSpNo);
expect(serialized).not.toContain(missedSpNo);
});
it("returns PASS_WITH_FINDINGS for missing callback and status mismatch", async () => {
@@ -561,3 +752,47 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(statusLabelFromSpStatus("2")).toBe("已通过");
});
});
function buildArchiveMockPrisma() {
const prisma = {
wecomIdentityBinding: {
findUnique: vi.fn(async () => null),
},
wecomApprovalCompensationRun: {
create: vi.fn(async () => ({
id: "run-archive-1",
started_at: new Date("2026-05-20T00:00:00.000Z"),
})),
update: vi.fn(async (args) => ({ id: args.where.id, ...args.data })),
},
wecomApprovalArchive: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async (args) => ({
id: args.create.sp_no.endsWith("missed-punch-sp") ? "archive-missed" : "archive-leave",
...args.create,
})),
},
wecomApprovalFieldSummary: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalParticipant: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalAction: {
upsert: vi.fn(async () => ({ id: "action-archive" })),
},
wecomApprovalCompensationItem: {
upsert: vi.fn(async () => ({ id: "item-archive" })),
},
wecomApprovalCallbackEvent: {
findMany: vi.fn(async () => []),
},
wecomApprovalReconcileFinding: {
upsert: vi.fn(async () => ({ id: "finding-archive" })),
},
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
return prisma;
}