Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 186 additions & 2 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,72 @@ describe("WebhookBuilder", () => {
});
});

describe("payload extraction", () => {
it("should validate and handle payload extracted by getPayload", async () => {
const schema = z.object({
action: z.string(),
data: z.object({ id: z.number() }),
nonce: z.string(),
});

const payloadEvent = defineEvent({
name: "payload.event",
schema,
provider: "test" as const,
});

const provider: Provider<"test"> = {
name: "test",
verification: "disabled",
getEventType(headers: Headers) {
return headers["x-test-event"];
},
getDeliveryId() {
return undefined;
},
verify() {
return true;
},
getPayload(body: unknown) {
if (body && typeof body === "object" && "payload" in body) {
const payload = (body as { payload: unknown }).payload;
const nonce =
"nonce" in body &&
typeof (body as { nonce: unknown }).nonce === "string"
? (body as { nonce: string }).nonce
: undefined;

if (payload && typeof payload === "object" && nonce) {
return { ...(payload as Record<string, unknown>), nonce };
}

return payload;
}
return body;
},
};

const handler = vi.fn();
const webhook = createWebhook(provider).event(payloadEvent, handler);
const envelope = {
type: "payload.event",
payload: { action: "created", data: { id: 1 } },
nonce: "nonce-123",
};

const result = await webhook.process({
headers: { "x-test-event": "payload.event" },
rawBody: JSON.stringify(envelope),
});

expect(result.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ nonce: "nonce-123" }),
expect.objectContaining({ eventType: "payload.event" }),
);
});
});

describe("error handling", () => {
it("should call onError when handler throws", async () => {
const provider = createTestProvider();
Expand Down Expand Up @@ -587,6 +653,48 @@ describe("WebhookBuilder", () => {
expect.any(Object),
);
});

it("should ignore errors from onError handler", async () => {
const provider = createTestProvider();
const onError = vi.fn(() => {
throw new Error("onError failure");
});

const webhook = createWebhook(provider)
.event(testEvent, () => {
throw new Error("Handler error");
})
.onError(onError);

const result = await webhook.process({
headers: { "x-test-event": "test.event" },
rawBody: JSON.stringify(validPayload),
secret: "test-secret",
});

expect(result.status).toBe(500);
expect(onError).toHaveBeenCalledTimes(1);
});

it("should ignore errors from onVerificationFailed handler", async () => {
const provider = createTestProvider({ verifyResult: false });
const onVerificationFailed = vi.fn(() => {
throw new Error("onVerificationFailed failure");
});

const webhook = createWebhook(provider)
.event(testEvent, () => {})
.onVerificationFailed(onVerificationFailed);

const result = await webhook.process({
headers: { "x-test-event": "test.event" },
rawBody: JSON.stringify(validPayload),
secret: "test-secret",
});

expect(result.status).toBe(401);
expect(onVerificationFailed).toHaveBeenCalledTimes(1);
});
});

describe("secret resolution", () => {
Expand Down Expand Up @@ -654,6 +762,35 @@ describe("WebhookBuilder", () => {
);
});

it("should fall back to WEBHOOK_SECRET when provider-specific env is missing", async () => {
const previous = process.env.WEBHOOK_SECRET;
process.env.WEBHOOK_SECRET = "global-secret";

try {
const provider = createTestProvider();
const verifyMock = vi.spyOn(provider, "verify");

const webhook = createWebhook(provider).event(testEvent, () => {});

await webhook.process({
headers: { "x-test-event": "test.event" },
rawBody: JSON.stringify(validPayload),
});

expect(verifyMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
"global-secret",
);
} finally {
if (previous === undefined) {
delete process.env.WEBHOOK_SECRET;
} else {
process.env.WEBHOOK_SECRET = previous;
}
}
});

it("should fail when no secret is available and verification is required", async () => {
const provider = createTestProvider();
const verifyMock = vi.spyOn(provider, "verify");
Expand Down Expand Up @@ -792,6 +929,18 @@ describe("verifyHmac", () => {
expect(isValid).toBe(true);
});

it("should return false when base64 signature length mismatches", () => {
const isValid = verifyHmac({
algorithm: "sha256",
rawBody: payload,
secret,
signature: "AA==",
signatureEncoding: "base64",
});

expect(isValid).toBe(false);
});

it("should work with sha1 algorithm", () => {
const signature = computeHmac("sha1", payload, secret);

Expand Down Expand Up @@ -841,10 +990,14 @@ describe("createHmacVerifier", () => {
const secret = "test-secret";
const payload = JSON.stringify({ test: "data" });

function computeHmac(body: string, secretKey: string): string {
function computeHmac(
body: string,
secretKey: string,
encoding: "hex" | "base64" = "hex",
): string {
const hmac = createHmac("sha256", secretKey);
hmac.update(body, "utf-8");
return hmac.digest("hex");
return hmac.digest(encoding);
}

it("should create a verifier function", () => {
Expand Down Expand Up @@ -898,6 +1051,21 @@ describe("createHmacVerifier", () => {

expect(isValid).toBe(true);
});

it("should verify base64 signatures", () => {
const verify = createHmacVerifier({
algorithm: "sha256",
signatureHeader: "x-signature",
signatureEncoding: "base64",
});

const signature = computeHmac(payload, secret, "base64");
const headers: Headers = { "x-signature": signature };

const isValid = verify(payload, headers, secret);

expect(isValid).toBe(true);
});
});

// ============================================================================
Expand Down Expand Up @@ -1652,6 +1820,22 @@ describe("createWebhookStats", () => {
expect(snapshot.errorCount).toBe(1);
});

it("should not track by event type when event type is unknown", async () => {
const provider = createTestProvider();
const stats = createWebhookStats();

const webhook = createWebhook(provider).observe(stats.observer);

await webhook.process({
headers: {},
rawBody: JSON.stringify(validPayload),
});

const snapshot = stats.snapshot();
expect(snapshot.totalRequests).toBe(1);
expect(Object.keys(snapshot.byEventType)).toHaveLength(0);
});

it("should track by provider", async () => {
const provider = createTestProvider();
const stats = createWebhookStats();
Expand Down
24 changes: 24 additions & 0 deletions packages/express/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,5 +395,29 @@ describe("toExpress", () => {
expect(onCompleted1).toHaveBeenCalledTimes(1);
expect(onCompleted2).toHaveBeenCalledTimes(1);
});

it("should not mutate original webhook when observer is provided", async () => {
const provider = createTestProvider();
const webhook = createWebhook(provider).event(testEvent, () => {});
const onCompleted = vi.fn();
const middleware = toExpress(webhook, { observer: { onCompleted } });

const req = createMockRequest({
headers: { "x-test-event": "test.event" },
body: Buffer.from(JSON.stringify(validPayload)),
});
const { res } = createMockResponse();

await middleware(req as Request, res as Response);

const result = await webhook.process({
headers: { "x-test-event": "test.event" },
rawBody: JSON.stringify(validPayload),
secret: "test-secret",
});

expect(result.status).toBe(200);
expect(onCompleted).toHaveBeenCalledTimes(1);
});
});
});
24 changes: 24 additions & 0 deletions packages/gcp-functions/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,30 @@ describe("toGCPFunction", () => {
expect(state.statusCode).toBe(400);
expect(onCompleted).not.toHaveBeenCalled();
});

it("should not mutate original webhook when observer is provided", async () => {
const provider = createTestProvider();
const webhook = createWebhook(provider).event(testEvent, () => {});
const onCompleted = vi.fn();
const handler = toGCPFunction(webhook, { observer: { onCompleted } });

const req = createMockRequest({
headers: { "x-test-event": "test.event" },
body: Buffer.from(JSON.stringify(validPayload)),
});
const { res } = createMockResponse();

await handler(req, res);

const result = await webhook.process({
headers: { "x-test-event": "test.event" },
rawBody: JSON.stringify(validPayload),
secret: "test-secret",
});

expect(result.status).toBe(200);
expect(onCompleted).toHaveBeenCalledTimes(1);
});
});

describe("header normalization", () => {
Expand Down
Loading