This commit is contained in:
2026-05-19 12:48:41 +07:00
parent 208a91b971
commit 55a0133ed4
23 changed files with 3781 additions and 22 deletions
@@ -0,0 +1,173 @@
/**
* Property-based tests for the webhook handler
* Feature: paypal-payment-integration
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as fc from "fast-check";
import { NextRequest } from "next/server";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockVerifyWebhookSignature = vi.fn();
const mockMarkPaymentPaid = vi.fn();
vi.mock("@/lib/paypal", () => ({
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
}));
vi.mock("@/lib/summit-requests", () => ({
markPaymentPaid: (...args: unknown[]) => mockMarkPaymentPaid(...args),
}));
function makeRequest(body: unknown, headers: Record<string, string> = {}): NextRequest {
const defaultHeaders: Record<string, string> = {
"paypal-transmission-id": "tx-123",
"paypal-transmission-time": "2026-01-01T00:00:00Z",
"paypal-cert-url": "https://api.paypal.com/cert",
"paypal-auth-algo": "SHA256withRSA",
"paypal-transmission-sig": "sig-abc",
"content-type": "application/json",
...headers,
};
return new NextRequest("http://localhost/api/payments/webhook", {
method: "POST",
headers: defaultHeaders,
body: JSON.stringify(body),
});
}
// ---------------------------------------------------------------------------
// Property 8: Webhook với signature không hợp lệ → HTTP 401
// ---------------------------------------------------------------------------
describe("webhook handler — signature validation", () => {
beforeEach(() => {
mockVerifyWebhookSignature.mockReset();
mockMarkPaymentPaid.mockReset();
process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id";
});
it(
"Feature: paypal-payment-integration, Property 8: Webhook signature không hợp lệ → 401",
async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
event_type: fc.string(),
resource: fc.record({ id: fc.string() }),
}),
async (payload) => {
mockVerifyWebhookSignature.mockResolvedValue(false);
mockMarkPaymentPaid.mockResolvedValue(undefined);
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(401);
expect(mockMarkPaymentPaid).not.toHaveBeenCalled();
}
),
{ numRuns: 30 }
);
}
);
it(
"Feature: paypal-payment-integration, Property 8b: Webhook thiếu headers → 401",
async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
event_type: fc.string(),
resource: fc.record({ id: fc.string() }),
}),
async (payload) => {
// Missing all PayPal signature headers
const { POST } = await import("../route");
const req = makeRequest(payload, {
"paypal-transmission-id": "",
"paypal-transmission-time": "",
"paypal-cert-url": "",
"paypal-auth-algo": "",
"paypal-transmission-sig": "",
});
const res = await POST(req);
expect(res.status).toBe(401);
}
),
{ numRuns: 20 }
);
}
);
});
// ---------------------------------------------------------------------------
// Property 9: Webhook idempotent — valid signature, already paid → 200
// Property 10: Webhook hợp lệ cập nhật trạng thái sang paid
// ---------------------------------------------------------------------------
describe("webhook handler — PAYMENT.CAPTURE.COMPLETED", () => {
beforeEach(() => {
mockVerifyWebhookSignature.mockReset();
mockMarkPaymentPaid.mockReset();
process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id";
});
it(
"Feature: paypal-payment-integration, Property 10: Webhook hợp lệ cập nhật trạng thái sang paid",
async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 30 }),
fc.string({ minLength: 1, maxLength: 30 }),
async (orderId, captureId) => {
mockVerifyWebhookSignature.mockResolvedValue(true);
mockMarkPaymentPaid.mockResolvedValue(undefined);
const payload = {
event_type: "PAYMENT.CAPTURE.COMPLETED",
resource: {
id: captureId,
supplementary_data: {
related_ids: { order_id: orderId },
},
},
};
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(200);
expect(mockMarkPaymentPaid).toHaveBeenCalledWith(orderId, captureId);
}
),
{ numRuns: 30 }
);
}
);
it(
"Feature: paypal-payment-integration, Property 9: Webhook idempotent — trả về 200 kể cả khi đã paid",
async () => {
// markPaymentPaid is idempotent (no-op if already paid) — webhook should still return 200
mockVerifyWebhookSignature.mockResolvedValue(true);
mockMarkPaymentPaid.mockResolvedValue(undefined); // no-op
const payload = {
event_type: "PAYMENT.CAPTURE.COMPLETED",
resource: {
id: "capture-already-done",
supplementary_data: { related_ids: { order_id: "order-already-paid" } },
},
};
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(200);
}
);
});
+85
View File
@@ -0,0 +1,85 @@
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { verifyWebhookSignature } from "@/lib/paypal";
import { markPaymentPaid } from "@/lib/summit-requests";
export async function POST(req: NextRequest): Promise<NextResponse> {
// Read raw body as text for signature verification
const rawBody = await req.text();
// Parse the body as JSON
const event = JSON.parse(rawBody);
// Read required PayPal signature headers
const transmissionId = req.headers.get("paypal-transmission-id");
const transmissionTime = req.headers.get("paypal-transmission-time");
const certUrl = req.headers.get("paypal-cert-url");
const authAlgo = req.headers.get("paypal-auth-algo");
const transmissionSig = req.headers.get("paypal-transmission-sig");
// Return 401 if any required header is missing
if (!transmissionId || !transmissionTime || !certUrl || !authAlgo || !transmissionSig) {
return NextResponse.json(
{ error: "Missing PayPal signature headers." },
{ status: 401 }
);
}
// Verify webhook signature
const isValid = await verifyWebhookSignature({
authAlgo,
certUrl,
transmissionId,
transmissionSig,
transmissionTime,
webhookId: process.env.PAYPAL_WEBHOOK_ID ?? "",
webhookEvent: event,
});
if (!isValid) {
return NextResponse.json(
{ error: "Invalid webhook signature." },
{ status: 401 }
);
}
console.log(
"[webhook] Received event:",
event.event_type,
"resource_id:",
event.resource?.id,
"transmission_id:",
transmissionId
);
try {
if (event.event_type === "PAYMENT.CAPTURE.COMPLETED") {
const orderId =
event.resource?.supplementary_data?.related_ids?.order_id ??
event.resource?.id;
const result = await markPaymentPaid(orderId, event.resource.id);
console.log(
"[webhook] markPaymentPaid result for orderId:",
orderId,
"captureId:",
event.resource.id,
result
);
return NextResponse.json({ received: true }, { status: 200 });
}
// All other event types — acknowledge and ignore
console.log("[webhook] Ignoring unhandled event type:", event.event_type);
return NextResponse.json({ received: true }, { status: 200 });
} catch (err) {
console.error("[webhook] Internal error processing event:", event.event_type, err);
// Return 500 so PayPal retries the webhook
return NextResponse.json(
{ error: "Internal server error." },
{ status: 500 }
);
}
}