fix: return wechat pay webhook validation failures

This commit is contained in:
2026-07-06 02:14:21 -07:00
parent 61b27a0813
commit 4f5fbefa99
2 changed files with 113 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import type { Context } from "hono";
import { StatusCodes } from "http-status-codes";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import {
DomesticPaymentIntentService,
@@ -25,10 +26,22 @@ export function createWeChatPayWebhookHandler({
return async (c: Context<HonoEnv>) => {
const ctx = c.get("ctx");
const body = await c.req.text();
const notification = await adapter.parseNotification({
body,
headers: c.req.raw.headers,
});
let notification: Awaited<ReturnType<PaymentProcessorAdapter["parseNotification"]>>;
try {
notification = await adapter.parseNotification({
body,
headers: c.req.raw.headers,
});
} catch (error) {
return c.json(
{
code: "FAIL",
message: error instanceof Error ? error.message : String(error),
},
StatusCodes.BAD_REQUEST,
);
}
await intentService.processParsedNotification({
ctx,

View File

@@ -92,4 +92,100 @@ describe("createWeChatPayWebhookHandler", () => {
notification,
});
});
test("returns a WeChat FAIL response with HTTP 400 when notification parsing fails", async () => {
const body = JSON.stringify({ id: "notify_bad" });
const adapter: PaymentProcessorAdapter = {
provider: DomesticPaymentProvider.WeChatPay,
createPayment: async () => {
throw new Error("createPayment is not used in this test");
},
queryPayment: async () => {
throw new Error("queryPayment is not used in this test");
},
closePayment: async () => {
throw new Error("closePayment is not used in this test");
},
parseNotification: mock(async () => {
throw new Error("Invalid WeChat notification signature");
}),
};
const processParsedNotification = mock(async () => {
throw new Error("processParsedNotification must not be called");
});
const app = new Hono<HonoEnv>();
app.use("*", async (c, next) => {
c.set("ctx", ctx as HonoEnv["Variables"]["ctx"]);
await next();
});
app.post(
"/webhooks/wechatpay",
createWeChatPayWebhookHandler({
adapter,
intentService: { processParsedNotification },
}),
);
const response = await app.request("/webhooks/wechatpay", {
method: "POST",
body,
headers: {
"Wechatpay-Signature": "bad_signature",
},
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
code: "FAIL",
message: "Invalid WeChat notification signature",
});
expect(processParsedNotification).toHaveBeenCalledTimes(0);
});
test("returns a server error after a parsed notification hits a service failure", async () => {
const notification = {
providerEventId: "notify_123",
idempotencyKey: "wechatpay:notification:notify_123",
eventType: DomesticPaymentEventType.WebhookPaid,
providerOrderId: "dp_order_123",
providerTransactionId: "wx_txn_123",
status: DomesticPaymentIntentStatus.Paid,
paidAt: 1_774_001_860_000,
rawPayload: { id: "notify_123" },
parsedPayload: { out_trade_no: "dp_order_123" },
};
const adapter: PaymentProcessorAdapter = {
provider: DomesticPaymentProvider.WeChatPay,
createPayment: async () => {
throw new Error("createPayment is not used in this test");
},
queryPayment: async () => {
throw new Error("queryPayment is not used in this test");
},
closePayment: async () => {
throw new Error("closePayment is not used in this test");
},
parseNotification: mock(async () => notification),
};
const processParsedNotification = mock(async () => {
throw new Error("database unavailable");
});
const handler = createWeChatPayWebhookHandler({
adapter,
intentService: { processParsedNotification },
});
const app = new Hono<HonoEnv>();
app.use("*", async (c, next) => {
c.set("ctx", ctx as HonoEnv["Variables"]["ctx"]);
await next();
});
app.post("/webhooks/wechatpay", handler);
const response = await app.request("/webhooks/wechatpay", {
method: "POST",
body: JSON.stringify({ id: "notify_123" }),
});
expect(response.status).toBe(500);
});
});