@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.
- package/dist/WebhookTrigger.d.ts +100 -117
- package/dist/WebhookTrigger.js +315 -316
- package/dist/index.d.ts +36 -65
- package/dist/index.js +34 -71
- package/dist/verifiers.d.ts +80 -0
- package/dist/verifiers.js +294 -0
- package/package.json +6 -4
- package/src/WebhookTrigger.integration.test.ts +162 -0
- package/src/WebhookTrigger.test.ts +232 -143
- package/src/WebhookTrigger.ts +386 -407
- package/src/index.ts +41 -70
- package/src/verifiers.test.ts +316 -0
- package/src/verifiers.ts +357 -0
|
@@ -1,163 +1,252 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebhookTrigger
|
|
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
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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("
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
expect(
|
|
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("
|
|
92
|
-
const
|
|
93
|
-
|
|
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("
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
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("
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
});
|