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/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 body = Buffer.concat(chunks).toString("utf8");
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] = decodeURIComponent(m[i + 1])));
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`). Default: a random `msg_…`. */
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 and
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 and
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 stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
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: "application/x-www-form-urlencoded" };
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
  }
@@ -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" }` and
16
- * `{ $any: true }` become Vitest asymmetric matchers.
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 {};