144 lines
4.4 KiB
TypeScript
144 lines
4.4 KiB
TypeScript
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse,
|
|
} from "node:http";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { verifySignature } from "@suaveplan/webhook-client";
|
|
import {
|
|
createWebhookService,
|
|
MemoryDeadLetterSink,
|
|
MemoryEndpointStore,
|
|
resetWebhookInstruments,
|
|
} from "../../src/index.js";
|
|
|
|
interface CapturedRequest {
|
|
method: string;
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
body: string;
|
|
}
|
|
|
|
interface TestReceiver {
|
|
readonly url: string;
|
|
readonly requests: CapturedRequest[];
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
type RequestHandler = (
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
attempt: number
|
|
) => void;
|
|
|
|
async function startReceiver(handler: RequestHandler): Promise<TestReceiver> {
|
|
const requests: CapturedRequest[] = [];
|
|
let attempt = 0;
|
|
const server: Server = createServer((req, res) => {
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
attempt += 1;
|
|
const captured: CapturedRequest = {
|
|
method: req.method ?? "GET",
|
|
url: req.url ?? "/",
|
|
headers: Object.fromEntries(
|
|
Object.entries(req.headers).map(([k, v]) => [
|
|
k,
|
|
Array.isArray(v) ? v.join(",") : (v ?? ""),
|
|
])
|
|
),
|
|
body,
|
|
};
|
|
requests.push(captured);
|
|
handler(req, res, attempt);
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", resolve)
|
|
);
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string") {
|
|
throw new Error("failed to bind test receiver");
|
|
}
|
|
const url = `http://127.0.0.1:${address.port}/hooks`;
|
|
return {
|
|
url,
|
|
requests,
|
|
close: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
}),
|
|
};
|
|
}
|
|
|
|
function noopLogger() {
|
|
return {
|
|
debug: () => undefined,
|
|
info: () => undefined,
|
|
warn: () => undefined,
|
|
error: () => undefined,
|
|
};
|
|
}
|
|
|
|
describe("integration: webhook delivery", () => {
|
|
it("sends a signed payload with canonical headers", {
|
|
timeout: 30_000,
|
|
}, async () => {
|
|
const receiver = await startReceiver((_, res) => {
|
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
res.end("ok");
|
|
});
|
|
try {
|
|
resetWebhookInstruments();
|
|
const store = new MemoryEndpointStore({
|
|
clock: () => 0,
|
|
idFactory: () => "ep-1",
|
|
});
|
|
const service = createWebhookService({
|
|
endpointStore: store,
|
|
deadLetterSink: new MemoryDeadLetterSink(),
|
|
maxAttempts: 1,
|
|
initialRetryDelayMs: 1,
|
|
maxRetryDelayMs: 100,
|
|
idempotencyGuard: null,
|
|
logger: noopLogger(),
|
|
clock: () => 0,
|
|
fetch: globalThis.fetch.bind(globalThis),
|
|
});
|
|
await service.register({
|
|
tenantId: null,
|
|
url: receiver.url,
|
|
secret: "shared-secret",
|
|
events: ["order.created"],
|
|
});
|
|
const result = await service.send("ep-1", "order.created", {
|
|
orderId: 42,
|
|
});
|
|
expect(result.status).toBe("delivered");
|
|
expect(receiver.requests).toHaveLength(1);
|
|
const req = receiver.requests[0];
|
|
if (!req) throw new Error("missing request");
|
|
expect(req.method).toBe("POST");
|
|
expect(req.headers["x-webhook-event"]).toBe("order.created");
|
|
expect(req.headers["x-webhook-id"]).toBeTruthy();
|
|
expect(req.headers["x-webhook-timestamp"]).toBeTruthy();
|
|
const sig = req.headers["x-hub-signature-256"];
|
|
expect(sig).toMatch(/^sha256=/);
|
|
const verified = await verifySignature(
|
|
req.body,
|
|
sig as string,
|
|
"shared-secret",
|
|
"sha256"
|
|
);
|
|
expect(verified).toBe(true);
|
|
expect(JSON.parse(req.body).data).toEqual({ orderId: 42 });
|
|
} finally {
|
|
await receiver.close();
|
|
}
|
|
});
|
|
});
|