@powerhousedao/switchboard 6.2.3-dev.11 → 6.2.3-dev.13
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/.tsbuild/test/workflow/harness.d.ts +45 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +17 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-CiKkVE6g.mjs → server-DndPNlPT.mjs} +548 -8
- package/dist/server-DndPNlPT.mjs.map +1 -0
- package/dist/server.d.mts +21 -3
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +17 -13
- package/test/workflow/harness.ts +237 -0
- package/test/workflow/resolvers.test.ts +105 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-unsigned.test.ts +332 -0
- package/test/workflow-runtime.test.ts +412 -0
- package/tsconfig.json +6 -0
- package/dist/server-CiKkVE6g.mjs.map +0 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The unverified and token halves of core#webhook, over a real socket. Only
|
|
2
|
+
// the status code a provider actually receives proves these gates are wired.
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
SECRET,
|
|
6
|
+
SECRET_REF,
|
|
7
|
+
startWebhookHost,
|
|
8
|
+
waitForRuns,
|
|
9
|
+
type FiredRun,
|
|
10
|
+
type WebhookHost,
|
|
11
|
+
} from "./harness.js";
|
|
12
|
+
|
|
13
|
+
const JSON_BODY = { headers: { "content-type": "application/json" } };
|
|
14
|
+
|
|
15
|
+
let host: WebhookHost | undefined;
|
|
16
|
+
|
|
17
|
+
async function start(
|
|
18
|
+
options: { fire?: (run: FiredRun) => unknown } = {},
|
|
19
|
+
): Promise<WebhookHost> {
|
|
20
|
+
host = await startWebhookHost(options);
|
|
21
|
+
return host;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A delivery is answered before its run begins, so an immediate length check
|
|
25
|
+
// would pass even on a host that fires a moment later.
|
|
26
|
+
async function expectNoRuns(target: WebhookHost): Promise<void> {
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
|
+
expect(target.fired).toHaveLength(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
// Unconditional: a failed assertion leaves the listener bound, and one
|
|
33
|
+
// leaked socket per test eventually wedges the whole file.
|
|
34
|
+
await host?.stop();
|
|
35
|
+
host = undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("core#webhook over HTTP: unverified", () => {
|
|
39
|
+
it("starts one run carrying the request the provider sent", async () => {
|
|
40
|
+
const webhook = await start();
|
|
41
|
+
const { token } = await webhook.arm({ methods: "POST", scheme: "none" });
|
|
42
|
+
|
|
43
|
+
const res = await webhook.deliver(token, {
|
|
44
|
+
...JSON_BODY,
|
|
45
|
+
body: '{"a":1}',
|
|
46
|
+
query: { source: "x" },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(res.status).toBe(202);
|
|
50
|
+
const [run] = await waitForRuns(webhook, 1);
|
|
51
|
+
expect(run.kind).toBe("webhook");
|
|
52
|
+
expect(run.payload).toMatchObject({
|
|
53
|
+
method: "POST",
|
|
54
|
+
path: `/webhooks/${token}`,
|
|
55
|
+
queryParams: { source: "x" },
|
|
56
|
+
// Decoded, not the raw bytes: an authored expression reads `body.a`.
|
|
57
|
+
body: { a: 1 },
|
|
58
|
+
headers: { "content-type": "application/json" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not start a second run for one delivery", async () => {
|
|
63
|
+
// A stray retry or double-fire would duplicate the run without changing
|
|
64
|
+
// the status the provider sees.
|
|
65
|
+
const webhook = await start();
|
|
66
|
+
const { token } = await webhook.arm({ scheme: "none" });
|
|
67
|
+
|
|
68
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
69
|
+
expect(res.status).toBe(202);
|
|
70
|
+
|
|
71
|
+
await waitForRuns(webhook, 1);
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
73
|
+
expect(webhook.fired).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("core#webhook over HTTP: method restriction", () => {
|
|
78
|
+
it("refuses every method the author did not allow", async () => {
|
|
79
|
+
// A provider's verification round often probes with GET; the author asked
|
|
80
|
+
// for POST only, so the probe must be refused rather than run anything.
|
|
81
|
+
const webhook = await start();
|
|
82
|
+
const { token } = await webhook.arm({ methods: "POST", scheme: "none" });
|
|
83
|
+
|
|
84
|
+
const get = await webhook.deliver(token, { method: "GET" });
|
|
85
|
+
const put = await webhook.deliver(token, {
|
|
86
|
+
method: "PUT",
|
|
87
|
+
...JSON_BODY,
|
|
88
|
+
body: "{}",
|
|
89
|
+
});
|
|
90
|
+
expect(get.status).toBe(405);
|
|
91
|
+
expect(put.status).toBe(405);
|
|
92
|
+
await expectNoRuns(webhook);
|
|
93
|
+
|
|
94
|
+
const post = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
95
|
+
expect(post.status).toBe(202);
|
|
96
|
+
await waitForRuns(webhook, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts every method when the author spelled it "ANY"', async () => {
|
|
100
|
+
const webhook = await start();
|
|
101
|
+
const { token } = await webhook.arm({ methods: "ANY", scheme: "none" });
|
|
102
|
+
|
|
103
|
+
for (const method of ["GET", "POST", "DELETE"]) {
|
|
104
|
+
const res = await webhook.deliver(token, { method });
|
|
105
|
+
expect(res.status).toBe(202);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const runs = await waitForRuns(webhook, 3);
|
|
109
|
+
expect(
|
|
110
|
+
runs.map((run) => (run.payload as { method: string }).method),
|
|
111
|
+
).toEqual(["GET", "POST", "DELETE"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("accepts every method when the author named none", async () => {
|
|
115
|
+
// Omitting the field is how the editor spells "any"; read as an empty
|
|
116
|
+
// allow-list it would refuse everything instead.
|
|
117
|
+
const webhook = await start();
|
|
118
|
+
const { token } = await webhook.arm({ scheme: "none" });
|
|
119
|
+
|
|
120
|
+
const res = await webhook.deliver(token, { method: "PATCH" });
|
|
121
|
+
expect(res.status).toBe(202);
|
|
122
|
+
await waitForRuns(webhook, 1);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("core#webhook over HTTP: token scheme", () => {
|
|
127
|
+
const tokenConfig = { scheme: "token", secretRef: SECRET_REF };
|
|
128
|
+
|
|
129
|
+
it("accepts a delivery presenting the shared secret", async () => {
|
|
130
|
+
const webhook = await start();
|
|
131
|
+
const { token } = await webhook.arm(tokenConfig);
|
|
132
|
+
|
|
133
|
+
const res = await webhook.deliver(token, {
|
|
134
|
+
headers: { ...JSON_BODY.headers, "x-webhook-token": SECRET },
|
|
135
|
+
body: '{"id":"evt_1"}',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(res.status).toBe(202);
|
|
139
|
+
const [run] = await waitForRuns(webhook, 1);
|
|
140
|
+
// The credential must not survive into the run journal, where it would be
|
|
141
|
+
// as readable as the payload.
|
|
142
|
+
expect(run.payload).toMatchObject({
|
|
143
|
+
headers: { "x-webhook-token": "[redacted]" },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("refuses a wrong token with 401 and runs nothing", async () => {
|
|
148
|
+
const webhook = await start();
|
|
149
|
+
const { token } = await webhook.arm(tokenConfig);
|
|
150
|
+
|
|
151
|
+
const res = await webhook.deliver(token, {
|
|
152
|
+
headers: { ...JSON_BODY.headers, "x-webhook-token": "not-the-secret" },
|
|
153
|
+
body: "{}",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(res.status).toBe(401);
|
|
157
|
+
await expectNoRuns(webhook);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("refuses a delivery with no token at all", async () => {
|
|
161
|
+
// Absent has to fail exactly as wrong does: omitting the header is the
|
|
162
|
+
// cheapest possible probe.
|
|
163
|
+
const webhook = await start();
|
|
164
|
+
const { token } = await webhook.arm(tokenConfig);
|
|
165
|
+
|
|
166
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
167
|
+
|
|
168
|
+
expect(res.status).toBe(401);
|
|
169
|
+
await expectNoRuns(webhook);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("reads the token from the header the author named", async () => {
|
|
173
|
+
// Providers disagree about the header, so the default is only a default:
|
|
174
|
+
// an override has to displace it, not be accepted alongside it.
|
|
175
|
+
const webhook = await start();
|
|
176
|
+
const { token } = await webhook.arm({ ...tokenConfig, header: "X-Auth" });
|
|
177
|
+
|
|
178
|
+
const defaulted = await webhook.deliver(token, {
|
|
179
|
+
headers: { ...JSON_BODY.headers, "x-webhook-token": SECRET },
|
|
180
|
+
body: "{}",
|
|
181
|
+
});
|
|
182
|
+
expect(defaulted.status).toBe(401);
|
|
183
|
+
await expectNoRuns(webhook);
|
|
184
|
+
|
|
185
|
+
const res = await webhook.deliver(token, {
|
|
186
|
+
headers: { ...JSON_BODY.headers, "x-auth": SECRET },
|
|
187
|
+
body: "{}",
|
|
188
|
+
});
|
|
189
|
+
expect(res.status).toBe(202);
|
|
190
|
+
await waitForRuns(webhook, 1);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("core#webhook over HTTP: response modes", () => {
|
|
195
|
+
it("answers async before the run has finished", async () => {
|
|
196
|
+
// The point of async mode: the provider's socket is never held for as long
|
|
197
|
+
// as the workflow takes, even for a run that never settles.
|
|
198
|
+
const webhook = await start({
|
|
199
|
+
fire: () =>
|
|
200
|
+
new Promise(() => {
|
|
201
|
+
/* never settles */
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
const { token } = await webhook.arm({ responseMode: "async" });
|
|
205
|
+
|
|
206
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
207
|
+
|
|
208
|
+
expect(res.status).toBe(202);
|
|
209
|
+
expect(await res.text()).toBe("");
|
|
210
|
+
// Started, but still going: the answer above did not wait for it.
|
|
211
|
+
await waitForRuns(webhook, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("answers async with the status the author chose", async () => {
|
|
215
|
+
const webhook = await start();
|
|
216
|
+
const { token } = await webhook.arm({
|
|
217
|
+
responseMode: "async",
|
|
218
|
+
responseStatus: 204,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
222
|
+
expect(res.status).toBe(204);
|
|
223
|
+
await waitForRuns(webhook, 1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("holds the socket in sync mode and reports the run outcome", async () => {
|
|
227
|
+
let finished = false;
|
|
228
|
+
const webhook = await start({
|
|
229
|
+
fire: () =>
|
|
230
|
+
new Promise((resolve) =>
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
finished = true;
|
|
233
|
+
resolve({ runId: "run-slow", status: "SUCCEEDED", steps: [] });
|
|
234
|
+
}, 50),
|
|
235
|
+
),
|
|
236
|
+
});
|
|
237
|
+
const { token } = await webhook.arm({ responseMode: "sync" });
|
|
238
|
+
|
|
239
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(200);
|
|
242
|
+
// The outcome is in the answer, so the answer cannot predate the run.
|
|
243
|
+
expect(finished).toBe(true);
|
|
244
|
+
expect(res.headers.get("content-type")).toBe(
|
|
245
|
+
"application/json; charset=utf-8",
|
|
246
|
+
);
|
|
247
|
+
expect(await res.json()).toEqual({
|
|
248
|
+
runId: "run-slow",
|
|
249
|
+
status: "SUCCEEDED",
|
|
250
|
+
error: null,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("answers sync with the status the author chose for a succeeding run", async () => {
|
|
255
|
+
const webhook = await start();
|
|
256
|
+
const { token } = await webhook.arm({
|
|
257
|
+
responseMode: "sync",
|
|
258
|
+
responseStatus: 201,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(201);
|
|
264
|
+
expect(await res.json()).toMatchObject({ status: "SUCCEEDED" });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("answers 500 in sync mode when the run fails", async () => {
|
|
268
|
+
// The author's status is for a success only: a provider that retries on
|
|
269
|
+
// 5xx must not be told a failed run went through.
|
|
270
|
+
const webhook = await start({
|
|
271
|
+
fire: () => ({
|
|
272
|
+
runId: "run-bad",
|
|
273
|
+
status: "FAILED",
|
|
274
|
+
error: "step blew up",
|
|
275
|
+
steps: [],
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
const { token } = await webhook.arm({
|
|
279
|
+
responseMode: "sync",
|
|
280
|
+
responseStatus: 201,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const res = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
284
|
+
|
|
285
|
+
expect(res.status).toBe(500);
|
|
286
|
+
expect(await res.json()).toEqual({
|
|
287
|
+
runId: "run-bad",
|
|
288
|
+
status: "FAILED",
|
|
289
|
+
error: "step blew up",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("core#webhook over HTTP: endpoints that are not armed", () => {
|
|
295
|
+
it("answers a disarmed endpoint exactly as an unknown one", async () => {
|
|
296
|
+
// The endpoint row outlives disabling so re-enabling keeps the URL, which
|
|
297
|
+
// is why the answer must not betray that the token is a real one.
|
|
298
|
+
const webhook = await start();
|
|
299
|
+
const { token } = await webhook.arm({ scheme: "none" });
|
|
300
|
+
await webhook.disarm();
|
|
301
|
+
|
|
302
|
+
const disarmed = await webhook.deliver(token, { ...JSON_BODY, body: "{}" });
|
|
303
|
+
const unknown = await webhook.deliver("ffffffffffffffffffffffffffffffff", {
|
|
304
|
+
...JSON_BODY,
|
|
305
|
+
body: "{}",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(disarmed.status).toBe(404);
|
|
309
|
+
expect(unknown.status).toBe(disarmed.status);
|
|
310
|
+
expect(await disarmed.text()).toBe(await unknown.text());
|
|
311
|
+
await expectNoRuns(webhook);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("refuses deliveries to an endpoint whose config does not parse", async () => {
|
|
315
|
+
// A token scheme with no secretRef cannot be honoured; firing anyway would
|
|
316
|
+
// run the workflow with no verification at all.
|
|
317
|
+
const webhook = await start();
|
|
318
|
+
const { token } = await webhook.arm({ scheme: "none" });
|
|
319
|
+
// Re-published enabled but unparseable. The row minted above still makes
|
|
320
|
+
// the token addressable, so this is the state a provider would hit.
|
|
321
|
+
await webhook.arm({ scheme: "token" });
|
|
322
|
+
|
|
323
|
+
expect(await webhook.policyFor()).toBeUndefined();
|
|
324
|
+
const res = await webhook.deliver(token, {
|
|
325
|
+
headers: { ...JSON_BODY.headers, "x-webhook-token": SECRET },
|
|
326
|
+
body: "{}",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(res.status).toBe(404);
|
|
330
|
+
await expectNoRuns(webhook);
|
|
331
|
+
});
|
|
332
|
+
});
|