forked from UKSOURCE/ipv6
174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
);
|
|
});
|