const { normalizeApprovalDetailPayload, listApprovalArchives, persistApprovalDetail, publicArchiveSummary, recordApprovalCallbackEvent, recordReconcileFinding, } = require("../../../src/services/integrations/wecomApprovalArchive"); const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval"); const { buildEncryptedApprovalEventSample, decryptWecomMessage, extractEncryptedFromXml, normalizeApprovalCallbackEvent, } = require("../../../src/services/integrations/wecomCallbackCrypto"); const SENSITIVE_SP_NO = "SPARCHIVEFULLIDENTIFIERSHOULDNOTLEAK"; const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEFULLIDENTIFIERSHOULDNOTLEAK"; const SENSITIVE_USER_ID = "wecom_archive_userid_full_should_not_leak"; const SENSITIVE_REASON = "这里是请假原因摘要,允许入库但默认报告不得输出全文"; const TOKEN = "callback-token"; const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const CORP_ID = "ww-archive-corp"; function approvalPayload(overrides = {}) { return { errcode: 0, info: { sp_no: SENSITIVE_SP_NO, template_id: SENSITIVE_TEMPLATE_ID, sp_name: "OA-POC-请假-2026-05-19", sp_status: "2", apply_time: "1779148800", applyer: { userid: SENSITIVE_USER_ID }, apply_data: { contents: [ { id: "leave_type", control: "Text", title: "请假类型", value: "事假" }, { id: "reason", control: "Textarea", title: "请假原因", value: SENSITIVE_REASON }, ], }, sp_record: [{ details: [{ userid: "approver_full_userid_should_not_leak", sp_status: "2", speech_time: "1779149800", speech: "同意", }], }], ...overrides, }, }; } function buildMockPrisma() { const prisma = { wecomIdentityBinding: { findUnique: vi.fn(async () => null), }, wecomApprovalArchive: { findUnique: vi.fn(async () => null), upsert: vi.fn(async (args) => ({ id: "archive-1", ...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-1" })), }, wecomApprovalCompensationItem: { upsert: vi.fn(async () => ({ id: "item-1" })), }, wecomApprovalCallbackEvent: { upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })), }, wecomApprovalReconcileFinding: { upsert: vi.fn(async () => ({ id: "finding-1" })), create: vi.fn(async () => ({ id: "finding-1" })), }, }; prisma.$transaction = vi.fn(async (callback) => callback(prisma)); return prisma; } describe("wecomApprovalArchive", () => { it("normalizes approval detail while marking reason fields restricted", () => { const detail = normalizeApprovalDetailPayload(approvalPayload(), { source: "compensation_pull", target: "leave", }); expect(detail).toMatchObject({ sp_no: SENSITIVE_SP_NO, template_id: SENSITIVE_TEMPLATE_ID, template_type: "leave", normalized_status: "approved", applicant_external_user_id: SENSITIVE_USER_ID, }); expect(detail.fields).toHaveLength(2); expect(detail.fields.find((item) => item.field_key.startsWith("reason"))).toMatchObject({ sensitive_level: "restricted", display_allowed: false, }); expect(detail.detail_digest).toMatch(/^[0-9a-f]{64}$/); }); it("returns only masked public archive summaries", () => { const summary = publicArchiveSummary({ id: "archive-1", sp_no: SENSITIVE_SP_NO, template_id: SENSITIVE_TEMPLATE_ID, template_type: "leave", normalized_status: "approved", last_source: "compensation_pull", }); expect(summary).toEqual({ archiveId: "archive-1", spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO), templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), templateType: "leave", normalizedStatus: "approved", source: "compensation_pull", }); expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO); 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({ prisma, config: { wecomCorpId: CORP_ID }, payload: approvalPayload(), spNo: SENSITIVE_SP_NO, target: "leave", source: "compensation_pull", compensationRunId: "run-1", }); expect(prisma.wecomApprovalArchive.upsert).toHaveBeenCalled(); expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe(SENSITIVE_SP_NO); expect(prisma.wecomApprovalFieldSummary.createMany.mock.calls[0][0].data.some((item) => item.normalized_value === SENSITIVE_REASON)).toBe(true); expect(result.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO)); expect(JSON.stringify(result)).not.toContain(SENSITIVE_SP_NO); expect(JSON.stringify(result)).not.toContain(SENSITIVE_TEMPLATE_ID); expect(JSON.stringify(result)).not.toContain(SENSITIVE_REASON); }); it("keeps callback full identifiers non-enumerable for internal persistence", () => { const sample = buildEncryptedApprovalEventSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", spNo: SENSITIVE_SP_NO, templateId: SENSITIVE_TEMPLATE_ID, }); const decrypted = decryptWecomMessage({ encrypted: extractEncryptedFromXml(sample.encryptedXml), encodingAesKey: AES_KEY, receiveId: CORP_ID, }); const summary = normalizeApprovalCallbackEvent(decrypted.plainXml); expect(summary.spNo).toBe(SENSITIVE_SP_NO); expect(summary.templateId).toBe(SENSITIVE_TEMPLATE_ID); expect(Object.keys(summary)).not.toContain("spNo"); expect(Object.keys(summary)).not.toContain("templateId"); expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO); expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID); }); it("does not erase detail children when recording callback evidence", async () => { const prisma = buildMockPrisma(); prisma.wecomApprovalArchive.findUnique.mockResolvedValueOnce({ id: "archive-1", corp_id_hash: "hash", sp_no: SENSITIVE_SP_NO, template_id: SENSITIVE_TEMPLATE_ID, template_type: "leave", applicant_external_user_id: SENSITIVE_USER_ID, normalized_status: "approved", status_observed_at: new Date("2026-05-19T10:00:00.000Z"), last_detail_digest: "existing-detail-digest", }); const sample = buildEncryptedApprovalEventSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", spNo: SENSITIVE_SP_NO, templateId: SENSITIVE_TEMPLATE_ID, }); const decrypted = decryptWecomMessage({ encrypted: extractEncryptedFromXml(sample.encryptedXml), encodingAesKey: AES_KEY, receiveId: CORP_ID, }); const summary = normalizeApprovalCallbackEvent(decrypted.plainXml); await recordApprovalCallbackEvent({ prisma, config: { wecomCorpId: CORP_ID }, summary, }); expect(prisma.wecomApprovalFieldSummary.deleteMany).not.toHaveBeenCalled(); expect(prisma.wecomApprovalParticipant.deleteMany).not.toHaveBeenCalled(); expect(prisma.wecomApprovalAction.upsert).toHaveBeenCalledTimes(1); const update = prisma.wecomApprovalArchive.upsert.mock.calls[0][0].update; expect(update).not.toHaveProperty("applicant_external_user_id", null); expect(update).not.toHaveProperty("last_detail_digest"); expect(update.last_callback_at).toBeInstanceOf(Date); }); it("upserts run-scoped reconcile findings without requiring archive id", async () => { const prisma = buildMockPrisma(); await recordReconcileFinding({ prisma, runId: "run-1", archiveId: null, findingType: "callback_missing_for_pulled_detail", evidence: { spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO) }, }); expect(prisma.wecomApprovalReconcileFinding.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { run_id_finding_type_evidence_digest: expect.objectContaining({ run_id: "run-1", finding_type: "callback_missing_for_pulled_detail", }), }, })); }); });