Initial Commit
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createWebhookService,
|
||||
MemoryDeadLetterSink,
|
||||
MemoryEndpointStore,
|
||||
resetWebhookInstruments,
|
||||
WEBHOOK_CODES,
|
||||
} from "../../src/index.js";
|
||||
|
||||
async function startReceiver(
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void
|
||||
) {
|
||||
const server: Server = createServer((req, res) => {
|
||||
req.on("data", () => undefined);
|
||||
req.on("end", () => handler(req, res));
|
||||
});
|
||||
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");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/hooks`,
|
||||
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 dead letter", () => {
|
||||
it("5x 500 lands in dead letter", { timeout: 30_000 }, async () => {
|
||||
let attempts = 0;
|
||||
const receiver = await startReceiver((_, res) => {
|
||||
attempts++;
|
||||
res.writeHead(500);
|
||||
res.end("nope");
|
||||
});
|
||||
try {
|
||||
resetWebhookInstruments();
|
||||
const sink = new MemoryDeadLetterSink();
|
||||
const store = new MemoryEndpointStore({
|
||||
clock: () => 0,
|
||||
idFactory: () => "ep-1",
|
||||
});
|
||||
const service = createWebhookService({
|
||||
endpointStore: store,
|
||||
deadLetterSink: sink,
|
||||
maxAttempts: 5,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetryDelayMs: 30,
|
||||
idempotencyGuard: null,
|
||||
logger: noopLogger(),
|
||||
clock: () => 0,
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
});
|
||||
await service.register({
|
||||
tenantId: null,
|
||||
url: receiver.url,
|
||||
secret: "shared-secret",
|
||||
events: ["evt"],
|
||||
});
|
||||
await expect(
|
||||
service.send("ep-1", "evt", { x: 1 })
|
||||
).rejects.toMatchObject({
|
||||
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
|
||||
});
|
||||
expect(attempts).toBe(5);
|
||||
const dl = await sink.list({ endpointId: "ep-1" });
|
||||
expect(dl).toHaveLength(1);
|
||||
const entry = dl[0];
|
||||
if (!entry) throw new Error("missing dl entry");
|
||||
expect(entry.event).toBe("evt");
|
||||
expect(entry.endpointId).toBe("ep-1");
|
||||
expect(entry.payload.event).toBe("evt");
|
||||
} finally {
|
||||
await receiver.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createWebhookPlugin,
|
||||
resetWebhookInstruments,
|
||||
type WebhookPluginConfig,
|
||||
type WebhookService,
|
||||
} from "../../src/index.js";
|
||||
|
||||
interface Receiver {
|
||||
readonly url: string;
|
||||
readonly observed: { event: string; body: string }[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startReceiver(): Promise<Receiver> {
|
||||
const observed: { event: string; body: string }[] = [];
|
||||
const server: Server = createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => {
|
||||
body += c;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const evt = req.headers["x-webhook-event"];
|
||||
observed.push({
|
||||
event: Array.isArray(evt) ? (evt[0] ?? "") : (evt ?? ""),
|
||||
body,
|
||||
});
|
||||
res.writeHead(200);
|
||||
res.end("ok");
|
||||
});
|
||||
});
|
||||
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");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/hooks`,
|
||||
observed,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("integration: webhook plugin full boot", () => {
|
||||
it("POST /webhooks/:id/test fires a synthetic webhook.ping", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const receiver = await startReceiver();
|
||||
try {
|
||||
resetWebhookInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
app.onError((err, c) => {
|
||||
const ctx = (err as { context?: { statusCode?: number } })
|
||||
.context;
|
||||
const status = (ctx?.statusCode ?? 500) as
|
||||
| 401
|
||||
| 403
|
||||
| 404
|
||||
| 500;
|
||||
return c.json({ message: err?.message ?? "error" }, status);
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
// Install a permissive auth middleware ABOVE the plugin's
|
||||
// /webhooks/* requireAuth so the integration test does not
|
||||
// need a real auth plugin.
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: null });
|
||||
await next();
|
||||
});
|
||||
const descriptor = createWebhookPlugin();
|
||||
const ctx = createPluginContext<WebhookPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: descriptor.defaults as WebhookPluginConfig,
|
||||
services,
|
||||
});
|
||||
await descriptor.onInit?.(ctx);
|
||||
|
||||
// Register an endpoint via the service that the plugin
|
||||
// registered in ServiceRegistry.
|
||||
const service = ctx.getService("webhookService") as WebhookService;
|
||||
const endpoint = await service.register({
|
||||
tenantId: null,
|
||||
url: receiver.url,
|
||||
secret: "shared-secret",
|
||||
events: ["webhook.ping"],
|
||||
});
|
||||
|
||||
// Fire via the test route.
|
||||
const res = await app.request(`/webhooks/${endpoint.id}/test`, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("delivered");
|
||||
expect(receiver.observed).toHaveLength(1);
|
||||
expect(receiver.observed[0]?.event).toBe("webhook.ping");
|
||||
} finally {
|
||||
await receiver.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createWebhookService,
|
||||
MemoryDeadLetterSink,
|
||||
MemoryEndpointStore,
|
||||
resetWebhookInstruments,
|
||||
WEBHOOK_CODES,
|
||||
} from "../../src/index.js";
|
||||
|
||||
async function startReceiver(
|
||||
handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
attempt: number
|
||||
) => void
|
||||
) {
|
||||
let attempt = 0;
|
||||
const server: Server = createServer((req, res) => {
|
||||
req.on("data", () => undefined);
|
||||
req.on("end", () => {
|
||||
attempt += 1;
|
||||
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");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/hooks`,
|
||||
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 retry", () => {
|
||||
it("retries 5xx then succeeds", { timeout: 30_000 }, async () => {
|
||||
const receiver = await startReceiver((_, res, attempt) => {
|
||||
if (attempt < 3) {
|
||||
res.writeHead(500);
|
||||
res.end("transient");
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end("ok");
|
||||
}
|
||||
});
|
||||
try {
|
||||
resetWebhookInstruments();
|
||||
const store = new MemoryEndpointStore({
|
||||
clock: () => 0,
|
||||
idFactory: () => "ep-1",
|
||||
});
|
||||
const service = createWebhookService({
|
||||
endpointStore: store,
|
||||
deadLetterSink: new MemoryDeadLetterSink(),
|
||||
maxAttempts: 5,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetryDelayMs: 50,
|
||||
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", {
|
||||
x: 1,
|
||||
});
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(result.attempts.length).toBe(3);
|
||||
expect(result.finalStatusCode).toBe(200);
|
||||
} finally {
|
||||
await receiver.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("4xx terminates without retry", { timeout: 30_000 }, async () => {
|
||||
let attempts = 0;
|
||||
const receiver = await startReceiver((_, res) => {
|
||||
attempts++;
|
||||
res.writeHead(422);
|
||||
res.end("client error");
|
||||
});
|
||||
try {
|
||||
resetWebhookInstruments();
|
||||
const store = new MemoryEndpointStore({
|
||||
clock: () => 0,
|
||||
idFactory: () => "ep-1",
|
||||
});
|
||||
const service = createWebhookService({
|
||||
endpointStore: store,
|
||||
deadLetterSink: new MemoryDeadLetterSink(),
|
||||
maxAttempts: 5,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetryDelayMs: 50,
|
||||
idempotencyGuard: null,
|
||||
logger: noopLogger(),
|
||||
clock: () => 0,
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
});
|
||||
await service.register({
|
||||
tenantId: null,
|
||||
url: receiver.url,
|
||||
secret: "shared-secret",
|
||||
events: ["evt"],
|
||||
});
|
||||
await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject(
|
||||
{
|
||||
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
|
||||
}
|
||||
);
|
||||
expect(attempts).toBe(1);
|
||||
} finally {
|
||||
await receiver.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user