const Fastify = require("fastify"); const wecomApprovalCallbackRoutes = require("../../../src/routes/wecomApprovalCallback"); const { buildEncryptedApprovalEventSample, buildUrlVerificationSample, } = require("../../../src/services/integrations/wecomCallbackCrypto"); const TOKEN = "callback-token"; const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; const CORP_ID = "ww-callback-corp"; async function buildApp({ config = {}, evidenceStore, prisma } = {}) { const app = Fastify(); app.decorate("config", { wecomCorpId: CORP_ID, wecomCallbackToken: TOKEN, wecomCallbackEncodingAesKey: AES_KEY, ...config, }); if (prisma) { app.decorate("prisma", prisma); } await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore }); return app; } function urlQuery(sample) { return `msg_signature=${encodeURIComponent(sample.signature)}×tamp=${encodeURIComponent(sample.timestamp)}&nonce=${encodeURIComponent(sample.nonce)}`; } describe("wecomApprovalCallback routes", () => { it("verifies callback URL and returns decrypted echostr", async () => { const app = await buildApp(); const sample = buildUrlVerificationSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", plainText: "wcbk1-url-verify-ok", }); const response = await app.inject({ method: "GET", url: `/api/wecom/approval/callback?${urlQuery(sample)}&echostr=${encodeURIComponent(sample.encrypted)}`, }); expect(response.statusCode).toBe(200); expect(response.body).toBe("wcbk1-url-verify-ok"); await app.close(); }); it("rejects invalid URL verification signatures", async () => { const app = await buildApp(); const sample = buildUrlVerificationSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", plainText: "wcbk1-url-verify-ok", }); const response = await app.inject({ method: "GET", url: `/api/wecom/approval/callback?msg_signature=bad×tamp=${sample.timestamp}&nonce=${sample.nonce}&echostr=${encodeURIComponent(sample.encrypted)}`, }); expect(response.statusCode).toBe(403); expect(response.body).toBe("invalid signature"); await app.close(); }); it("accepts approval callback POST and records only normalized evidence", async () => { const evidenceStore = { record: vi.fn().mockResolvedValue({ recorded: true, duplicate: false }), }; const app = await buildApp({ evidenceStore }); const sample = buildEncryptedApprovalEventSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK", templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK", }); const response = await app.inject({ method: "POST", url: `/api/wecom/approval/callback?${urlQuery(sample)}`, headers: { "content-type": "text/xml" }, payload: sample.encryptedXml, }); expect(response.statusCode).toBe(200); expect(response.body).toBe("success"); expect(evidenceStore.record).toHaveBeenCalledTimes(1); const summary = evidenceStore.record.mock.calls[0][0]; expect(summary).toMatchObject({ eventType: "open_approval_change", callbackKind: "approval_status_change", approvalType: "leave", statusLabel: "已通过", }); expect(JSON.stringify(summary)).not.toContain("SPFULLIDENTIFIERSHOULDNOTLEAK"); expect(JSON.stringify(summary)).not.toContain("TPLFULLIDENTIFIERSHOULDNOTLEAK"); await app.close(); }); it("uses the default DB evidence store when prisma is decorated", async () => { const prisma = buildArchiveMockPrisma(); const app = await buildApp({ prisma }); const sample = buildEncryptedApprovalEventSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK", templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK", }); const response = await app.inject({ method: "POST", url: `/api/wecom/approval/callback?${urlQuery(sample)}`, headers: { "content-type": "text/xml" }, payload: sample.encryptedXml, }); expect(response.statusCode).toBe(200); expect(prisma.wecomApprovalCallbackEvent.upsert).toHaveBeenCalledTimes(1); expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe("SPFULLIDENTIFIERSHOULDNOTLEAK"); await app.close(); }); it("returns non-200 when default DB evidence persistence fails so WeCom can retry", async () => { const prisma = { $transaction: vi.fn(async () => { throw new Error("db unavailable"); }), }; const app = await buildApp({ prisma }); const sample = buildEncryptedApprovalEventSample({ token: TOKEN, encodingAesKey: AES_KEY, receiveId: CORP_ID, timestamp: "1770000000", nonce: "nonce", spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK", templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK", }); const response = await app.inject({ method: "POST", url: `/api/wecom/approval/callback?${urlQuery(sample)}`, headers: { "content-type": "text/xml" }, payload: sample.encryptedXml, }); expect(response.statusCode).toBe(500); expect(response.body).toBe("callback error"); await app.close(); }); it("reports missing callback config without treating it as pass", async () => { const app = await buildApp({ config: { wecomCallbackToken: "" } }); const response = await app.inject({ method: "GET", url: "/api/wecom/approval/callback?msg_signature=x×tamp=1&nonce=2&echostr=3", }); expect(response.statusCode).toBe(503); expect(response.body).toBe("callback config missing"); await app.close(); }); }); function buildArchiveMockPrisma() { 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" })), }, wecomApprovalCallbackEvent: { upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })), }, }; prisma.$transaction = vi.fn(async (callback) => callback(prisma)); return prisma; }