Files
suaveplan/packages/api-stack/api/api-plugin-payment/tests/integration/webhook.test.ts
2026-05-27 23:29:53 +01:00

124 lines
3.8 KiB
TypeScript

import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createPaymentPlugin,
resetPaymentInstruments,
signPaymentWebhook,
} from "../../src/index.js";
import type { PaymentPluginConfig } from "../../src/types/types.js";
import { FakePaymentProvider } from "../setup/fake-provider.js";
import { flushTestMetrics } from "../setup/polyfills.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const SECRET = "shared-webhook-secret-32-chars-or-more";
async function boot() {
resetPaymentInstruments();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", null);
await next();
});
const provider = new FakePaymentProvider();
const services = new Map<string, unknown>();
services.set("app", app);
services.set(
"openAPIRegistry",
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
);
const plugin = createPaymentPlugin({ provider, webhookSecret: SECRET });
await plugin.onInit?.(
createPluginContext<PaymentPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as PaymentPluginConfig,
services,
})
);
return { app, provider };
}
beforeEach(() => {
resetPaymentInstruments();
});
describe("payment plugin — webhook", () => {
it("valid HMAC accepted", { timeout: 60_000 }, async () => {
const { app } = await boot();
const body = JSON.stringify({
id: "evt_1",
type: "intent.captured",
timestamp: 1,
data: {},
});
const ts = Date.now();
const sig = signPaymentWebhook({
body,
timestamp: ts,
secret: SECRET,
algorithm: "SHA-256",
});
const res = await app.request("/payments/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"X-Signature": sig,
"X-Timestamp": String(ts),
},
body,
});
expect(res.status).toBe(200);
const parsed = (await res.json()) as { accepted: boolean };
expect(parsed.accepted).toBe(true);
const collected = await flushTestMetrics();
const names = collected.flatMap((c) =>
c.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("payments.webhook_received_total");
});
it("invalid HMAC rejected before deserialisation", {
timeout: 60_000,
}, async () => {
const { app } = await boot();
const body = JSON.stringify({
id: "evt_1",
type: "intent.captured",
timestamp: 1,
data: {},
});
const ts = Date.now();
const res = await app.request("/payments/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"X-Signature": "deadbeef",
"X-Timestamp": String(ts),
},
body,
});
expect(res.status).toBe(400);
const collected = await flushTestMetrics();
const names = collected.flatMap((c) =>
c.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("payments.webhook_signature_failures_total");
});
});