@blokjs/trigger-webhook 0.2.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,163 +1,252 @@
1
1
  /**
2
- * WebhookTrigger Tests
2
+ * WebhookTrigger — v0.7 PR 4 — unit tests for the public surface.
3
+ *
4
+ * Covers construction, the pre-catch-all hook coordination contract
5
+ * with HttpTrigger, route discovery via WorkflowRegistry, idempotent
6
+ * listen(), and the singleton helper accessor. End-to-end coverage
7
+ * (real HTTP POST with a signed Stripe-shaped payload + replay
8
+ * dedup) lives in `WebhookTrigger.integration.test.ts`.
3
9
  */
4
10
 
5
- import crypto from "crypto";
6
- import { describe, expect, it } from "vitest";
7
- import { sourceHandlers } from "./WebhookTrigger";
8
-
9
- describe("WebhookTrigger", () => {
10
- describe("WebhookEvent Interface", () => {
11
- it("should accept valid webhook event structure", () => {
12
- const event = {
13
- id: "event-123",
14
- source: "github",
15
- eventType: "push",
16
- payload: { ref: "refs/heads/main" },
17
- headers: { "x-github-event": "push" },
18
- signature: "sha256=abc123",
19
- timestamp: new Date(),
20
- rawBody: '{"ref":"refs/heads/main"}',
21
- };
22
-
23
- expect(event.id).toBe("event-123");
24
- expect(event.source).toBe("github");
25
- expect(event.eventType).toBe("push");
26
- });
11
+ import { createHmac } from "node:crypto";
12
+ import { WorkflowRegistry } from "@blokjs/runner";
13
+ import { Hono } from "hono";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+
16
+ vi.mock("@opentelemetry/api", () => ({
17
+ trace: {
18
+ getTracer: () => ({
19
+ startActiveSpan: (_name: string, fn: (span: unknown) => unknown) =>
20
+ fn({ setAttribute: vi.fn(), setStatus: vi.fn(), recordException: vi.fn(), end: vi.fn() }),
21
+ }),
22
+ },
23
+ metrics: {
24
+ getMeter: () => ({
25
+ createCounter: () => ({ add: vi.fn() }),
26
+ createHistogram: () => ({ record: vi.fn() }),
27
+ createGauge: () => ({ record: vi.fn() }),
28
+ createObservableGauge: () => ({ addCallback: vi.fn() }),
29
+ }),
30
+ },
31
+ SpanStatusCode: { OK: 0, ERROR: 1 },
32
+ }));
33
+
34
+ import WebhookTrigger, { _getActiveWebhookTrigger, _setActiveWebhookTrigger } from "./WebhookTrigger";
35
+
36
+ const SECRET = "shhh-its-a-secret-1234567890";
37
+
38
+ function hmacHex(data: string, secret = SECRET): string {
39
+ return createHmac("sha256", secret).update(data).digest("hex");
40
+ }
41
+
42
+ describe("WebhookTrigger — v0.7 PR 4", () => {
43
+ beforeEach(() => {
44
+ WorkflowRegistry.resetInstance();
45
+ _setActiveWebhookTrigger(null);
46
+ process.env.GH_SECRET = SECRET;
27
47
  });
28
- });
29
-
30
- describe("Source Handlers", () => {
31
- describe("GitHub Handler", () => {
32
- const handler = sourceHandlers.github;
33
-
34
- it("should extract event type from headers", () => {
35
- const headers = { "x-github-event": "push" };
36
- expect(handler.getEventType(headers, {})).toBe("push");
37
- });
38
48
 
39
- it("should extract signature from headers", () => {
40
- const headers = { "x-hub-signature-256": "sha256=abc123" };
41
- expect(handler.getSignature(headers)).toBe("sha256=abc123");
42
- });
43
-
44
- it("should verify valid signature", () => {
45
- const secret = "my-secret";
46
- const rawBody = '{"action":"created"}';
47
- const hmac = crypto.createHmac("sha256", secret);
48
- const signature = "sha256=" + hmac.update(rawBody).digest("hex");
49
-
50
- const result = handler.verifySignature(rawBody, signature, secret);
51
- expect(result.valid).toBe(true);
52
- });
53
-
54
- it("should reject invalid signature", () => {
55
- const result = handler.verifySignature('{"action":"created"}', "sha256=invalid", "my-secret");
56
- expect(result.valid).toBe(false);
57
- });
58
-
59
- it("should extract event ID from headers", () => {
60
- const headers = { "x-github-delivery": "delivery-123" };
61
- expect(handler.getEventId(headers, {})).toBe("delivery-123");
62
- });
49
+ afterEach(() => {
50
+ _setActiveWebhookTrigger(null);
51
+ process.env.GH_SECRET = undefined;
63
52
  });
64
53
 
65
- describe("Stripe Handler", () => {
66
- const handler = sourceHandlers.stripe;
67
-
68
- it("should extract event type from body", () => {
69
- const body = { type: "payment_intent.succeeded" };
70
- expect(handler.getEventType({}, body)).toBe("payment_intent.succeeded");
71
- });
72
-
73
- it("should extract signature from headers", () => {
74
- const headers = { "stripe-signature": "t=123,v1=abc" };
75
- expect(handler.getSignature(headers)).toBe("t=123,v1=abc");
76
- });
77
-
78
- it("should verify valid Stripe signature", () => {
79
- const secret = "whsec_test";
80
- const rawBody = '{"type":"test"}';
81
- const timestamp = Math.floor(Date.now() / 1000);
82
- const payload = `${timestamp}.${rawBody}`;
83
- const hmac = crypto.createHmac("sha256", secret);
84
- const sig = hmac.update(payload).digest("hex");
85
- const signature = `t=${timestamp},v1=${sig}`;
86
-
87
- const result = handler.verifySignature(rawBody, signature, secret);
88
- expect(result.valid).toBe(true);
54
+ describe("constructor()", () => {
55
+ it("registers as the active webhook trigger singleton", () => {
56
+ const app = new Hono();
57
+ const trigger = new WebhookTrigger(app);
58
+ expect(trigger).toBeDefined();
59
+ expect(_getActiveWebhookTrigger()).toBe(trigger);
89
60
  });
90
61
 
91
- it("should extract event ID from body", () => {
92
- const body = { id: "evt_123" };
93
- expect(handler.getEventId({}, body)).toBe("evt_123");
62
+ it("accepts an optional httpTrigger for pre-catch-all coordination", () => {
63
+ const app = new Hono();
64
+ const addPreCatchAllHook = vi.fn();
65
+ const httpTrigger = { addPreCatchAllHook };
66
+ const trigger = new WebhookTrigger(app, httpTrigger);
67
+ expect(trigger).toBeDefined();
68
+ expect(addPreCatchAllHook).not.toHaveBeenCalled();
94
69
  });
95
70
  });
96
71
 
97
- describe("Shopify Handler", () => {
98
- const handler = sourceHandlers.shopify;
99
-
100
- it("should extract event type from headers", () => {
101
- const headers = { "x-shopify-topic": "orders/create" };
102
- expect(handler.getEventType(headers, {})).toBe("orders/create");
103
- });
104
-
105
- it("should extract signature from headers", () => {
106
- const headers = { "x-shopify-hmac-sha256": "abc123base64==" };
107
- expect(handler.getSignature(headers)).toBe("abc123base64==");
108
- });
109
-
110
- it("should extract event ID from headers", () => {
111
- const headers = { "x-shopify-webhook-id": "webhook-123" };
112
- expect(handler.getEventId(headers, {})).toBe("webhook-123");
72
+ describe("listen()", () => {
73
+ it("registers a POST route per webhook workflow in the registry", async () => {
74
+ const app = new Hono();
75
+ WorkflowRegistry.getInstance().register({
76
+ name: "gh-events",
77
+ source: "/test/gh.json",
78
+ workflow: {
79
+ name: "gh-events",
80
+ version: "1.0.0",
81
+ trigger: { webhook: { provider: "github", path: "/webhooks/github", secretEnv: "GH_SECRET" } },
82
+ steps: [],
83
+ },
84
+ });
85
+
86
+ const trigger = new WebhookTrigger(app);
87
+ await trigger.listen();
88
+
89
+ // Send a valid signed POST — should at least not 404.
90
+ const body = JSON.stringify({ ref: "refs/heads/main" });
91
+ const res = await app.fetch(
92
+ new Request("http://localhost/webhooks/github", {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ "x-hub-signature-256": `sha256=${hmacHex(body)}`,
97
+ "x-github-event": "push",
98
+ "x-github-delivery": "delivery-1",
99
+ },
100
+ body,
101
+ }),
102
+ );
103
+ expect(res.status).not.toBe(404);
104
+ });
105
+
106
+ it("skips workflows without trigger.webhook config", async () => {
107
+ const app = new Hono();
108
+ WorkflowRegistry.getInstance().register({
109
+ name: "http-only",
110
+ source: "/test/http.json",
111
+ workflow: {
112
+ name: "http-only",
113
+ version: "1.0.0",
114
+ trigger: { http: { method: "POST", path: "/api/foo" } },
115
+ steps: [],
116
+ },
117
+ });
118
+ const trigger = new WebhookTrigger(app);
119
+ await trigger.listen();
120
+ // No webhook route mounted — anything not on app returns 404.
121
+ const res = await app.fetch(new Request("http://localhost/webhooks/anywhere", { method: "POST" }));
122
+ expect(res.status).toBe(404);
123
+ });
124
+
125
+ it("skips workflows with neither `provider` nor `signature`", async () => {
126
+ const app = new Hono();
127
+ WorkflowRegistry.getInstance().register({
128
+ name: "misconfigured",
129
+ source: "/test/bad.json",
130
+ workflow: {
131
+ name: "misconfigured",
132
+ version: "1.0.0",
133
+ trigger: { webhook: { path: "/webhooks/bad" } },
134
+ steps: [],
135
+ },
136
+ });
137
+ const trigger = new WebhookTrigger(app);
138
+ await trigger.listen();
139
+ expect(trigger.getStats().workflowsRegistered).toBe(0);
140
+ });
141
+
142
+ it("registers a pre-catch-all hook on httpTrigger when provided", async () => {
143
+ const app = new Hono();
144
+ const addPreCatchAllHook = vi.fn();
145
+ const httpTrigger = { addPreCatchAllHook };
146
+ WorkflowRegistry.getInstance().register({
147
+ name: "gh-events",
148
+ source: "/test/gh.json",
149
+ workflow: {
150
+ name: "gh-events",
151
+ version: "1.0.0",
152
+ trigger: { webhook: { provider: "github", path: "/webhooks/github", secretEnv: "GH_SECRET" } },
153
+ steps: [],
154
+ },
155
+ });
156
+ const trigger = new WebhookTrigger(app, httpTrigger);
157
+ await trigger.listen();
158
+ expect(addPreCatchAllHook).toHaveBeenCalledTimes(1);
159
+ expect(addPreCatchAllHook).toHaveBeenCalledWith(expect.any(Function));
160
+ });
161
+
162
+ it("is idempotent — second listen() call is a no-op", async () => {
163
+ const app = new Hono();
164
+ const trigger = new WebhookTrigger(app);
165
+ await trigger.listen();
166
+ await expect(trigger.listen()).resolves.toBeTypeOf("number");
113
167
  });
114
168
  });
115
169
 
116
- describe("Custom Handler", () => {
117
- const handler = sourceHandlers.custom;
118
-
119
- it("should extract event type from headers or body", () => {
120
- expect(handler.getEventType({ "x-event-type": "custom.event" }, {})).toBe("custom.event");
121
- expect(handler.getEventType({}, { event: "body.event" })).toBe("body.event");
122
- });
123
-
124
- it("should extract signature from headers", () => {
125
- const headers = { "x-signature": "sig123" };
126
- expect(handler.getSignature(headers)).toBe("sig123");
170
+ describe("request handling", () => {
171
+ it("returns 401 with structured reason when the signature is invalid", async () => {
172
+ const app = new Hono();
173
+ WorkflowRegistry.getInstance().register({
174
+ name: "gh-events",
175
+ source: "/test/gh.json",
176
+ workflow: {
177
+ name: "gh-events",
178
+ version: "1.0.0",
179
+ trigger: { webhook: { provider: "github", path: "/webhooks/github", secretEnv: "GH_SECRET" } },
180
+ steps: [],
181
+ },
182
+ });
183
+ const trigger = new WebhookTrigger(app);
184
+ await trigger.listen();
185
+
186
+ const res = await app.fetch(
187
+ new Request("http://localhost/webhooks/github", {
188
+ method: "POST",
189
+ headers: {
190
+ "content-type": "application/json",
191
+ "x-hub-signature-256": "sha256=deadbeef",
192
+ "x-github-event": "push",
193
+ },
194
+ body: JSON.stringify({}),
195
+ }),
196
+ );
197
+ expect(res.status).toBe(401);
198
+ const json = (await res.json()) as { reason?: string };
199
+ expect(json.reason).toBe("signature_mismatch");
200
+ });
201
+
202
+ it("returns 200 `ignored` when the event isn't in the allowlist", async () => {
203
+ const app = new Hono();
204
+ WorkflowRegistry.getInstance().register({
205
+ name: "gh-events",
206
+ source: "/test/gh.json",
207
+ workflow: {
208
+ name: "gh-events",
209
+ version: "1.0.0",
210
+ trigger: {
211
+ webhook: {
212
+ provider: "github",
213
+ path: "/webhooks/github",
214
+ secretEnv: "GH_SECRET",
215
+ events: ["push"],
216
+ },
217
+ },
218
+ steps: [],
219
+ },
220
+ });
221
+ const trigger = new WebhookTrigger(app);
222
+ await trigger.listen();
223
+
224
+ const body = JSON.stringify({});
225
+ const res = await app.fetch(
226
+ new Request("http://localhost/webhooks/github", {
227
+ method: "POST",
228
+ headers: {
229
+ "content-type": "application/json",
230
+ "x-hub-signature-256": `sha256=${hmacHex(body)}`,
231
+ "x-github-event": "pull_request",
232
+ },
233
+ body,
234
+ }),
235
+ );
236
+ expect(res.status).toBe(200);
237
+ const json = (await res.json()) as { status?: string; reason?: string };
238
+ expect(json.status).toBe("ignored");
239
+ expect(json.reason).toBe("event_not_allowed");
127
240
  });
128
-
129
- it("should verify valid custom signature", () => {
130
- const secret = "custom-secret";
131
- const rawBody = '{"data":"test"}';
132
- const hmac = crypto.createHmac("sha256", secret);
133
- const signature = hmac.update(rawBody).digest("hex");
134
-
135
- const result = handler.verifySignature(rawBody, signature, secret);
136
- expect(result.valid).toBe(true);
137
- });
138
- });
139
- });
140
-
141
- describe("WebhookTriggerOpts Schema", () => {
142
- it("should validate webhook trigger configuration", () => {
143
- const validConfig = {
144
- source: "github",
145
- events: ["push", "pull_request.*"],
146
- secret: "my-webhook-secret",
147
- path: "/webhooks/github",
148
- };
149
-
150
- expect(validConfig.source).toBe("github");
151
- expect(validConfig.events).toContain("push");
152
- expect(validConfig.secret).toBeDefined();
153
241
  });
154
242
 
155
- it("should support wildcard events", () => {
156
- const config = {
157
- source: "stripe",
158
- events: ["payment_intent.*", "checkout.session.*"],
159
- };
160
-
161
- expect(config.events).toContain("payment_intent.*");
243
+ describe("stop()", () => {
244
+ it("clears the singleton", async () => {
245
+ const app = new Hono();
246
+ const trigger = new WebhookTrigger(app);
247
+ await trigger.listen();
248
+ await trigger.stop();
249
+ expect(_getActiveWebhookTrigger()).toBeNull();
250
+ });
162
251
  });
163
252
  });