slicetest 0.7.0 → 0.8.0
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/README.md +71 -13
- package/dist/cli.js +20 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +3 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +29 -5
- package/dist/http.d.ts +11 -3
- package/dist/http.js +32 -9
- package/dist/index.d.ts +4 -3
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +7 -0
- package/dist/matchers.js +34 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/runtime.js +1 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/stub.d.ts +22 -0
- package/dist/stub.js +105 -2
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +244 -34
- package/dist/yaml.d.ts +43 -2
- package/dist/yaml.js +91 -12
- package/package.json +1 -1
- package/schema/scenario.schema.json +361 -2
package/dist/stub.js
CHANGED
|
@@ -259,7 +259,9 @@ export class Stub {
|
|
|
259
259
|
const chunks = [];
|
|
260
260
|
for await (const chunk of req)
|
|
261
261
|
chunks.push(chunk);
|
|
262
|
-
const
|
|
262
|
+
const raw = Buffer.concat(chunks);
|
|
263
|
+
const body = raw.toString("utf8");
|
|
264
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
263
265
|
const url = new URL(req.url ?? "/", this.url);
|
|
264
266
|
const call = {
|
|
265
267
|
method: req.method ?? "GET",
|
|
@@ -268,6 +270,7 @@ export class Stub {
|
|
|
268
270
|
headers: req.headers,
|
|
269
271
|
body,
|
|
270
272
|
json: parseJson(body),
|
|
273
|
+
form: isForm(contentType) ? parseForm(body) : /^multipart\/form-data/i.test(contentType) ? await parseMultipart(raw, contentType) : undefined,
|
|
271
274
|
params: {},
|
|
272
275
|
matched: false,
|
|
273
276
|
};
|
|
@@ -371,9 +374,18 @@ function matchPath(path, pattern, actual, names = []) {
|
|
|
371
374
|
if (!m)
|
|
372
375
|
return undefined;
|
|
373
376
|
const params = { ...m.groups };
|
|
374
|
-
names.forEach((n, i) => (params[n] =
|
|
377
|
+
names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
|
|
375
378
|
return params;
|
|
376
379
|
}
|
|
380
|
+
/** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
|
|
381
|
+
function decodeSegment(segment) {
|
|
382
|
+
try {
|
|
383
|
+
return decodeURIComponent(segment);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return segment;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
377
389
|
function matchConditions(match, call) {
|
|
378
390
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
379
391
|
if (!test(m, call.query.get(k) ?? undefined))
|
|
@@ -388,6 +400,8 @@ function matchConditions(match, call) {
|
|
|
388
400
|
return false;
|
|
389
401
|
if (match.json !== undefined && !subset(match.json, call.json))
|
|
390
402
|
return false;
|
|
403
|
+
if (match.form !== undefined && !subset(formExpectation(match.form, call.form), call.form))
|
|
404
|
+
return false;
|
|
391
405
|
if (match.graphql) {
|
|
392
406
|
const g = call.graphql;
|
|
393
407
|
if (!g)
|
|
@@ -422,6 +436,13 @@ function conditionMismatch(match, call) {
|
|
|
422
436
|
if (diff)
|
|
423
437
|
return diff;
|
|
424
438
|
}
|
|
439
|
+
if (match.form !== undefined) {
|
|
440
|
+
if (call.form === undefined)
|
|
441
|
+
return `form: expected a form-encoded body, got ${call.body ? `${call.headers["content-type"] ?? "no content type"}: ${JSON.stringify(call.body.slice(0, 100))}` : "an empty body"}`;
|
|
442
|
+
const diff = difference(formExpectation(match.form, call.form), call.form, "form");
|
|
443
|
+
if (diff)
|
|
444
|
+
return diff;
|
|
445
|
+
}
|
|
425
446
|
if (match.graphql) {
|
|
426
447
|
const g = call.graphql;
|
|
427
448
|
if (!g)
|
|
@@ -534,6 +555,88 @@ export function subset(expected, actual) {
|
|
|
534
555
|
function isAsymmetric(m) {
|
|
535
556
|
return !!m && typeof m === "object" && typeof m.asymmetricMatch === "function";
|
|
536
557
|
}
|
|
558
|
+
function isForm(contentType) {
|
|
559
|
+
return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
|
|
563
|
+
* `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
|
|
564
|
+
* twice (`to=1&to=2`) becomes an array too.
|
|
565
|
+
*/
|
|
566
|
+
export function parseForm(body) {
|
|
567
|
+
const out = {};
|
|
568
|
+
for (const [key, value] of new URLSearchParams(body)) {
|
|
569
|
+
const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
|
|
570
|
+
if (!m || !m[2]) {
|
|
571
|
+
const prev = out[key];
|
|
572
|
+
out[key] = prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value];
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
|
|
576
|
+
let node = out;
|
|
577
|
+
for (let i = 0; i < path.length; i++) {
|
|
578
|
+
const seg = path[i];
|
|
579
|
+
const last = i === path.length - 1;
|
|
580
|
+
const nextIsIndex = !last && (path[i + 1] === "" || /^\d+$/.test(path[i + 1]));
|
|
581
|
+
if (Array.isArray(node)) {
|
|
582
|
+
const index = seg === "" ? node.length : Number(seg);
|
|
583
|
+
if (last)
|
|
584
|
+
node[index] = value;
|
|
585
|
+
else
|
|
586
|
+
node = (node[index] ??= nextIsIndex ? [] : {});
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
if (last)
|
|
590
|
+
node[seg] = value;
|
|
591
|
+
else {
|
|
592
|
+
const child = node[seg];
|
|
593
|
+
node = (child && typeof child === "object" ? child : (node[seg] = nextIsIndex ? [] : {}));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Form values arrive as strings: compare `amount: 2000` and `capture: true` with what was sent.
|
|
602
|
+
* Only where a string was sent, so an uploaded file's `size` stays a number.
|
|
603
|
+
*/
|
|
604
|
+
function formExpectation(expected, actual) {
|
|
605
|
+
if ((typeof expected === "number" || typeof expected === "boolean") && typeof actual === "string")
|
|
606
|
+
return String(expected);
|
|
607
|
+
if (Array.isArray(expected))
|
|
608
|
+
return expected.map((e, i) => formExpectation(e, Array.isArray(actual) ? actual[i] : undefined));
|
|
609
|
+
if (expected && typeof expected === "object" && !isAsymmetric(expected) && !(expected instanceof RegExp)) {
|
|
610
|
+
const at = (k) => (actual && typeof actual === "object" ? actual[k] : undefined);
|
|
611
|
+
return Object.fromEntries(Object.entries(expected).map(([k, v]) => [k, formExpectation(v, at(k))]));
|
|
612
|
+
}
|
|
613
|
+
return expected;
|
|
614
|
+
}
|
|
615
|
+
const TEXTUAL = /^(text\/|application\/(json|xml|csv|x-ndjson|yaml|javascript)|[^;]*\+(json|xml))/i;
|
|
616
|
+
/** Fields of a `multipart/form-data` body, files as `{ filename, type, size, text }`. Undefined if it can't be parsed. */
|
|
617
|
+
async function parseMultipart(raw, contentType) {
|
|
618
|
+
let data;
|
|
619
|
+
try {
|
|
620
|
+
data = await new Response(new Uint8Array(raw), { headers: { "content-type": contentType } }).formData();
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
const out = {};
|
|
626
|
+
for (const [key, value] of data) {
|
|
627
|
+
const v = typeof value === "string"
|
|
628
|
+
? value
|
|
629
|
+
: {
|
|
630
|
+
filename: value.name,
|
|
631
|
+
type: value.type,
|
|
632
|
+
size: value.size,
|
|
633
|
+
...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
|
|
634
|
+
};
|
|
635
|
+
const prev = out[key];
|
|
636
|
+
out[key] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
537
640
|
function parseJson(body) {
|
|
538
641
|
if (!body)
|
|
539
642
|
return undefined;
|
package/dist/webhook.d.ts
CHANGED
|
@@ -8,12 +8,12 @@ export interface HmacScheme {
|
|
|
8
8
|
/** Prepended to the digest, e.g. `sha256=`. */
|
|
9
9
|
prefix?: string;
|
|
10
10
|
}
|
|
11
|
-
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
|
|
11
|
+
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | HmacScheme;
|
|
12
12
|
export interface WebhookOptions {
|
|
13
13
|
provider: WebhookProvider;
|
|
14
14
|
/** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
|
|
15
15
|
secret: string;
|
|
16
|
-
/** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
|
|
16
|
+
/** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`, `X-Gitlab-Event`, `Linear-Event`, `Twitch-Eventsub-Message-Type`. */
|
|
17
17
|
event?: string;
|
|
18
18
|
/** Unix seconds the signature is made for. Default: now. */
|
|
19
19
|
timestamp?: number;
|
|
@@ -21,20 +21,29 @@ export interface WebhookOptions {
|
|
|
21
21
|
stale?: boolean;
|
|
22
22
|
/** Signed with a different secret: the app must refuse it. */
|
|
23
23
|
invalidSignature?: boolean;
|
|
24
|
-
/** Message id for `standard` (`webhook-id
|
|
24
|
+
/** Message id for `standard` (`webhook-id`, default a random `msg_…`) and `twitch`. */
|
|
25
25
|
id?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The URL the app sees the request at, for providers that sign it (Twilio). `http.webhook()` fills in
|
|
28
|
+
* the app's address and the path; set it when the app validates against a public URL it's configured with.
|
|
29
|
+
*/
|
|
30
|
+
url?: string;
|
|
26
31
|
/** Extra request headers. */
|
|
27
32
|
headers?: Record<string, string>;
|
|
28
33
|
}
|
|
34
|
+
/** The providers `signWebhook` knows by name. */
|
|
35
|
+
export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
|
|
29
36
|
/**
|
|
30
37
|
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
31
38
|
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
32
|
-
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC
|
|
33
|
-
* Standard Webhooks (Svix, Resend, Clerk, …)
|
|
39
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
|
|
40
|
+
* Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
|
|
41
|
+
* Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
|
|
42
|
+
* signature and Twitch EventSub.
|
|
34
43
|
*/
|
|
35
44
|
export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
|
|
36
|
-
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
37
|
-
export declare function webhookBody(payload: unknown): {
|
|
45
|
+
/** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
|
|
46
|
+
export declare function webhookBody(payload: unknown, provider?: WebhookProvider): {
|
|
38
47
|
body: string;
|
|
39
48
|
type: string;
|
|
40
49
|
};
|
package/dist/webhook.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { encodeForm } from "./form.js";
|
|
3
|
+
/** The providers `signWebhook` knows by name. */
|
|
4
|
+
export const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
|
|
2
5
|
const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
|
|
3
6
|
/**
|
|
4
7
|
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
5
8
|
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
6
|
-
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC
|
|
7
|
-
* Standard Webhooks (Svix, Resend, Clerk, …)
|
|
9
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
|
|
10
|
+
* Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
|
|
11
|
+
* Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
|
|
12
|
+
* signature and Twitch EventSub.
|
|
8
13
|
*/
|
|
9
14
|
export function signWebhook(body, opts) {
|
|
10
15
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -38,15 +43,50 @@ export function signWebhook(body, opts) {
|
|
|
38
43
|
const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
|
|
39
44
|
return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
|
|
40
45
|
}
|
|
46
|
+
case "line":
|
|
47
|
+
return { "x-line-signature": hmac("sha256", secret, body).digest("base64") };
|
|
48
|
+
case "paddle":
|
|
49
|
+
return { "paddle-signature": `ts=${ts};h1=${hmac("sha256", secret, `${ts}:${body}`).digest("hex")}` };
|
|
50
|
+
case "linear":
|
|
51
|
+
return {
|
|
52
|
+
"linear-signature": hmac("sha256", secret, body).digest("hex"),
|
|
53
|
+
"linear-delivery": randomUUID(),
|
|
54
|
+
...(opts.event ? { "linear-event": opts.event } : {}),
|
|
55
|
+
};
|
|
56
|
+
case "gitlab":
|
|
57
|
+
return { "x-gitlab-token": secret, "x-gitlab-event": opts.event ?? "Push Hook", "x-gitlab-event-uuid": randomUUID() };
|
|
58
|
+
case "zoom":
|
|
59
|
+
return { "x-zm-request-timestamp": String(ts), "x-zm-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
|
|
60
|
+
case "twitch": {
|
|
61
|
+
const id = opts.id ?? randomUUID();
|
|
62
|
+
const at = new Date(ts * 1000).toISOString();
|
|
63
|
+
return {
|
|
64
|
+
"twitch-eventsub-message-id": id,
|
|
65
|
+
"twitch-eventsub-message-timestamp": at,
|
|
66
|
+
"twitch-eventsub-message-type": opts.event ?? "notification",
|
|
67
|
+
"twitch-eventsub-message-signature": `sha256=${hmac("sha256", secret, `${id}${at}${body}`).digest("hex")}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
case "twilio": {
|
|
71
|
+
// Twilio's validateRequest: the URL, then each POST parameter's name and value, sorted by name; HMAC-SHA1, base64.
|
|
72
|
+
if (!opts.url)
|
|
73
|
+
throw new Error("slicetest: a twilio webhook is signed over its URL: pass `url`, or send it with http.webhook()");
|
|
74
|
+
const params = [...new URLSearchParams(body)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
75
|
+
return { "x-twilio-signature": hmac("sha1", secret, opts.url + params.map(([k, v]) => k + v).join("")).digest("base64") };
|
|
76
|
+
}
|
|
41
77
|
default:
|
|
42
|
-
throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected
|
|
78
|
+
throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding })`);
|
|
43
79
|
}
|
|
44
80
|
}
|
|
45
|
-
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
46
|
-
export function webhookBody(payload) {
|
|
81
|
+
/** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
|
|
82
|
+
export function webhookBody(payload, provider) {
|
|
83
|
+
const form = "application/x-www-form-urlencoded";
|
|
47
84
|
if (typeof payload === "string")
|
|
48
|
-
return { body: payload, type: "application/json" };
|
|
85
|
+
return { body: payload, type: provider === "twilio" ? form : "application/json" };
|
|
49
86
|
if (payload instanceof URLSearchParams)
|
|
50
|
-
return { body: payload.toString(), type:
|
|
87
|
+
return { body: payload.toString(), type: form };
|
|
88
|
+
// Twilio posts its parameters as a form.
|
|
89
|
+
if (provider === "twilio" && payload && typeof payload === "object")
|
|
90
|
+
return { body: encodeForm(payload).toString(), type: form };
|
|
51
91
|
return { body: JSON.stringify(payload ?? {}), type: "application/json" };
|
|
52
92
|
}
|
package/dist/yaml-runtime.d.ts
CHANGED
|
@@ -12,8 +12,9 @@ export declare function lookup(obj: unknown, path: string): unknown;
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function interpolate(value: unknown, vars: Vars): unknown;
|
|
14
14
|
/**
|
|
15
|
-
* `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`
|
|
16
|
-
* `{ $
|
|
15
|
+
* `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`, `{ $any: true }`,
|
|
16
|
+
* `{ $gte: 1, $lt: 10 }`, `{ $len: 3 }`, `{ $not: … }`, `{ $oneOf: [...] }` and
|
|
17
|
+
* `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
|
|
17
18
|
*/
|
|
18
19
|
export declare function toMatchers(value: unknown): unknown;
|
|
19
20
|
export {};
|