slicetest 0.4.0 → 0.5.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.
@@ -0,0 +1,52 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
3
+ /**
4
+ * The headers a provider sends with `body`, signed with `secret` the way its
5
+ * 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, …).
8
+ */
9
+ export function signWebhook(body, opts) {
10
+ const now = Math.floor(Date.now() / 1000);
11
+ const ts = opts.timestamp ?? (opts.stale ? now - 600 : now);
12
+ const secret = opts.invalidSignature ? `${opts.secret}-not-the-real-secret` : opts.secret;
13
+ const p = opts.provider;
14
+ if (typeof p === "object") {
15
+ if (!p.header)
16
+ throw new Error("slicetest: a custom webhook provider needs `header`, e.g. { header: \"X-Signature\", prefix: \"sha256=\" }");
17
+ return { [p.header]: `${p.prefix ?? ""}${hmac(p.algorithm ?? "sha256", secret, body).digest(p.encoding ?? "hex")}` };
18
+ }
19
+ switch (p) {
20
+ case "stripe":
21
+ return { "stripe-signature": `t=${ts},v1=${hmac("sha256", secret, `${ts}.${body}`).digest("hex")}` };
22
+ case "github":
23
+ return {
24
+ "x-hub-signature-256": `sha256=${hmac("sha256", secret, body).digest("hex")}`,
25
+ "x-github-event": opts.event ?? "ping",
26
+ "x-github-delivery": randomUUID(),
27
+ };
28
+ case "slack":
29
+ return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
30
+ case "shopify":
31
+ return {
32
+ "x-shopify-hmac-sha256": hmac("sha256", secret, body).digest("base64"),
33
+ ...(opts.event ? { "x-shopify-topic": opts.event } : {}),
34
+ "x-shopify-webhook-id": randomUUID(),
35
+ };
36
+ case "standard": {
37
+ const id = opts.id ?? `msg_${randomUUID().replace(/-/g, "")}`;
38
+ const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
39
+ return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
40
+ }
41
+ default:
42
+ throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
43
+ }
44
+ }
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) {
47
+ if (typeof payload === "string")
48
+ return { body: payload, type: "application/json" };
49
+ if (payload instanceof URLSearchParams)
50
+ return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
51
+ return { body: JSON.stringify(payload ?? {}), type: "application/json" };
52
+ }
@@ -4,6 +4,7 @@ import "./provided.js";
4
4
  import { formatChanges } from "./db.js";
5
5
  import "./matchers.js";
6
6
  import { scenario } from "./scenario.js";
7
+ import { signWebhook, webhookBody } from "./webhook.js";
7
8
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
8
9
  export function defineYamlScenarios(doc) {
9
10
  for (const sc of doc.scenarios) {
@@ -47,6 +48,10 @@ function describeStep(step) {
47
48
  return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
48
49
  if ("insert" in step)
49
50
  return `insert ${step.insert}`;
51
+ if ("make" in step)
52
+ return `make ${step.make}`;
53
+ if ("chaos" in step)
54
+ return `chaos ${step.chaos}`;
50
55
  if ("db" in step)
51
56
  return `db ${step.db}`;
52
57
  if ("changes" in step)
@@ -159,16 +164,22 @@ async function runStep(step, ctx, vars) {
159
164
  if ("request" in step) {
160
165
  const [method, rawPath] = splitCall(step.request);
161
166
  const path = interpolate(rawPath, vars);
167
+ const headers = interpolate(step.headers, vars);
162
168
  const opts = {
163
- headers: interpolate(step.headers, vars),
169
+ headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
164
170
  query: interpolate(step.query, vars),
165
171
  follow: step.follow,
166
172
  };
167
- const body = step.json !== undefined
173
+ let body = step.json !== undefined
168
174
  ? interpolate(step.json, vars)
169
175
  : step.form !== undefined
170
176
  ? ctx.http.form(interpolate(step.form, vars))
171
177
  : interpolate(step.body, vars);
178
+ if (step.webhook) {
179
+ const signed = webhookBody(body);
180
+ body = signed.body;
181
+ opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
182
+ }
172
183
  const e = step.expect;
173
184
  const verify = (res) => {
174
185
  if (e?.status !== undefined)
@@ -190,7 +201,18 @@ async function runStep(step, ctx, vars) {
190
201
  all.forEach(verify);
191
202
  return;
192
203
  }
193
- const res = await ctx.http.request(method, path, body, opts);
204
+ const max = e?.queries === undefined ? undefined : interpolate(e.queries, vars);
205
+ let res;
206
+ const queries = await (max === undefined ? undefined : ctx.db.queries(async () => (res = await ctx.http.request(method, path, body, opts))));
207
+ if (!queries)
208
+ res = await ctx.http.request(method, path, body, opts);
209
+ if (queries && max !== undefined) {
210
+ const ran = queries.withoutTransactions();
211
+ if (ran.length > max) {
212
+ const shapes = ran.shapes().map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql}`);
213
+ throw new Error(`expected at most ${max} SQL statements for ${method} ${path}, the app ran ${ran.length}:\n${shapes.join("\n")}`);
214
+ }
215
+ }
194
216
  verify(res);
195
217
  capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
196
218
  return;
@@ -200,6 +222,22 @@ async function runStep(step, ctx, vars) {
200
222
  capture(step.capture, { rows, row: rows[0] }, vars);
201
223
  return;
202
224
  }
225
+ if ("chaos" in step) {
226
+ const { chaos, name: _, ...opts } = step;
227
+ ctx.stub(chaos).chaos(opts);
228
+ return;
229
+ }
230
+ if ("make" in step) {
231
+ const given = interpolate(step.rows, vars) ?? {};
232
+ const rows = [];
233
+ if (Array.isArray(given))
234
+ for (const r of given)
235
+ rows.push(await ctx.db.make(step.make, r));
236
+ else
237
+ rows.push(...(await ctx.db.makeMany(step.make, step.count ?? 1, given)));
238
+ capture(step.capture, { rows, row: rows[0] }, vars);
239
+ return;
240
+ }
203
241
  if ("sql" in step) {
204
242
  const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
205
243
  expectRows(step.expect, rows, vars);
package/dist/yaml.d.ts CHANGED
@@ -21,7 +21,7 @@ export interface YamlScenario {
21
21
  timeout?: number;
22
22
  steps: Step[];
23
23
  }
24
- export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
24
+ export type Step = (StubStep | RequestStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
25
25
  line: number;
26
26
  name?: string;
27
27
  };
@@ -57,14 +57,31 @@ export interface RequestStep {
57
57
  form?: Record<string, unknown>;
58
58
  body?: string;
59
59
  follow?: boolean;
60
+ /** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
61
+ auth?: true | Record<string, unknown>;
62
+ /** Sign the body as this provider would deliver a webhook: { provider: stripe, secret: whsec_test, event, stale, invalidSignature }. */
63
+ webhook?: {
64
+ provider: string | {
65
+ header: string;
66
+ algorithm?: string;
67
+ encoding?: "hex" | "base64";
68
+ prefix?: string;
69
+ };
70
+ secret: string;
71
+ event?: string;
72
+ stale?: boolean;
73
+ invalidSignature?: boolean;
74
+ };
60
75
  /** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
61
76
  concurrency?: number;
77
+ /** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
62
78
  expect?: {
63
79
  status?: number;
64
80
  statuses?: Record<string, number>;
65
81
  headers?: Record<string, unknown>;
66
82
  json?: unknown;
67
83
  text?: unknown;
84
+ queries?: number;
68
85
  };
69
86
  capture?: Record<string, string>;
70
87
  }
@@ -73,6 +90,24 @@ export interface InsertStep {
73
90
  rows: Record<string, unknown> | Record<string, unknown>[];
74
91
  capture?: Record<string, string>;
75
92
  }
93
+ /** Rows built by `db.make()`: only the columns given are fixed, the rest is filled from the schema. */
94
+ export interface MakeStep {
95
+ make: string;
96
+ rows?: Record<string, unknown> | Record<string, unknown>[];
97
+ /** Make this many rows, each with `rows` (a mapping). */
98
+ count?: number;
99
+ capture?: Record<string, string>;
100
+ }
101
+ /** Faults injected into a stub's answers for the rest of the scenario, like `stub(name).chaos()`. */
102
+ export interface ChaosStep {
103
+ chaos: string;
104
+ failFirst?: number;
105
+ errorRate?: number;
106
+ statuses?: number[];
107
+ networkErrorRate?: number;
108
+ latency?: number | [number, number];
109
+ seed?: number;
110
+ }
76
111
  export interface SqlStep {
77
112
  sql: string;
78
113
  params?: unknown[];
package/dist/yaml.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
2
  const KINDS = {
3
3
  stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
- request: ["headers", "query", "json", "form", "body", "follow", "concurrency", "expect", "capture"],
4
+ request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
5
5
  insert: ["rows", "capture"],
6
6
  sql: ["params", "expect", "capture", "within"],
7
7
  db: ["where", "orderBy", "expect", "capture", "within"],
@@ -11,13 +11,17 @@ const KINDS = {
11
11
  checkpoint: [],
12
12
  snapshot: ["mask"],
13
13
  mail: ["times", "within", "capture"],
14
+ make: ["rows", "count", "capture"],
15
+ chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
14
16
  };
15
17
  const EXPECT_KEYS = {
16
- request: ["status", "statuses", "headers", "json", "text"],
18
+ request: ["status", "statuses", "headers", "json", "text", "queries"],
17
19
  sql: ["rows", "count"],
18
20
  db: ["rows", "count"],
19
21
  };
20
22
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
23
+ const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
24
+ const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
21
25
  const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
22
26
  const CHANGE_KEYS = ["inserted", "updated", "deleted"];
23
27
  const RESPONSE_KEYS = ["status", "headers", "body"];
@@ -181,8 +185,31 @@ function parseStep(node, fail, lineOf) {
181
185
  call("request", REQUEST);
182
186
  if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
183
187
  fail(node, "use only one of json / form / body");
188
+ if (raw.webhook !== undefined) {
189
+ const w = raw.webhook;
190
+ if (!w || typeof w !== "object" || Array.isArray(w))
191
+ fail(at("webhook"), "`webhook` must be a mapping such as { provider: stripe, secret: whsec_test }");
192
+ for (const k of Object.keys(w))
193
+ if (!WEBHOOK_KEYS.includes(k))
194
+ fail(at("webhook"), `unknown key "${k}" in webhook (allowed: ${WEBHOOK_KEYS.join(", ")})`);
195
+ if (typeof w.secret !== "string" || !w.secret)
196
+ fail(at("webhook"), "`webhook.secret` is required: the signing secret the app is configured with");
197
+ const p = w.provider;
198
+ if (!(typeof p === "string" ? WEBHOOK_PROVIDERS.includes(p) : p && typeof p === "object" && typeof p.header === "string")) {
199
+ fail(at("webhook"), `\`webhook.provider\` must be one of ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding }`);
200
+ }
201
+ }
202
+ if (raw.auth !== undefined && raw.auth !== true && !(raw.auth && typeof raw.auth === "object" && !Array.isArray(raw.auth)))
203
+ fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
184
204
  if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
185
205
  fail(at("concurrency"), "`concurrency` must be a positive whole number");
206
+ {
207
+ const q = raw.expect?.queries;
208
+ if (q !== undefined && !(Number.isInteger(q) && q >= 0))
209
+ fail(at("expect"), "`expect.queries` is the most SQL statements the request may run, e.g. 3");
210
+ if (q !== undefined && raw.concurrency !== undefined)
211
+ fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
212
+ }
186
213
  if (raw.concurrency !== undefined && raw.capture !== undefined)
187
214
  fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
188
215
  {
@@ -200,6 +227,33 @@ function parseStep(node, fail, lineOf) {
200
227
  if (!raw.rows || typeof raw.rows !== "object")
201
228
  fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
202
229
  break;
230
+ case "chaos":
231
+ for (const k of ["errorRate", "networkErrorRate"]) {
232
+ if (raw[k] !== undefined && !(typeof raw[k] === "number" && raw[k] >= 0 && raw[k] <= 1))
233
+ fail(at(k), `\`${k}\` must be a number between 0 and 1`);
234
+ }
235
+ for (const k of ["failFirst", "seed"])
236
+ if (raw[k] !== undefined && !Number.isInteger(raw[k]))
237
+ fail(at(k), `\`${k}\` must be a whole number`);
238
+ if (raw.statuses !== undefined && !(Array.isArray(raw.statuses) && raw.statuses.length > 0 && raw.statuses.every((s) => Number.isInteger(s) && s >= 400 && s <= 599))) {
239
+ fail(at("statuses"), "`statuses` must be a list of 4xx/5xx codes, e.g. [503]");
240
+ }
241
+ {
242
+ const l = raw.latency;
243
+ if (l !== undefined && !(typeof l === "number" || (Array.isArray(l) && l.length === 2 && l.every((x) => typeof x === "number"))))
244
+ fail(at("latency"), "`latency` must be milliseconds or [min, max]");
245
+ }
246
+ break;
247
+ case "make":
248
+ if (raw.rows !== undefined && (!raw.rows || typeof raw.rows !== "object"))
249
+ fail(at("rows"), "`rows` must be a mapping or a list of mappings");
250
+ if (raw.count !== undefined) {
251
+ if (!(Number.isInteger(raw.count) && raw.count >= 1))
252
+ fail(at("count"), "`count` must be a positive whole number");
253
+ if (Array.isArray(raw.rows))
254
+ fail(at("count"), "use either `count` or a list of `rows`");
255
+ }
256
+ break;
203
257
  case "log":
204
258
  try {
205
259
  new RegExp(raw.log);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicetest",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres, MySQL or SQLite and stubbed outbound APIs, checked against OpenAPI contracts.",
5
5
  "keywords": [
6
6
  "testing",
@@ -18,7 +18,13 @@
18
18
  "testcontainers",
19
19
  "language-agnostic",
20
20
  "openapi",
21
- "contract-testing"
21
+ "contract-testing",
22
+ "jwt",
23
+ "oidc",
24
+ "webhooks",
25
+ "chaos-testing",
26
+ "n-plus-one",
27
+ "test-data-factory"
22
28
  ],
23
29
  "author": "pon-bok",
24
30
  "repository": {
@@ -180,6 +180,81 @@
180
180
  "follow": {
181
181
  "type": "boolean"
182
182
  },
183
+ "auth": {
184
+ "description": "Send Authorization: Bearer with a token from the auth issuer: true, or the token's claims",
185
+ "oneOf": [
186
+ {
187
+ "const": true
188
+ },
189
+ {
190
+ "type": "object"
191
+ }
192
+ ]
193
+ },
194
+ "webhook": {
195
+ "type": "object",
196
+ "description": "Sign the body as this provider delivers webhooks",
197
+ "required": [
198
+ "provider",
199
+ "secret"
200
+ ],
201
+ "additionalProperties": false,
202
+ "properties": {
203
+ "provider": {
204
+ "oneOf": [
205
+ {
206
+ "enum": [
207
+ "stripe",
208
+ "github",
209
+ "slack",
210
+ "shopify",
211
+ "standard"
212
+ ]
213
+ },
214
+ {
215
+ "type": "object",
216
+ "required": [
217
+ "header"
218
+ ],
219
+ "additionalProperties": false,
220
+ "properties": {
221
+ "header": {
222
+ "type": "string"
223
+ },
224
+ "algorithm": {
225
+ "type": "string"
226
+ },
227
+ "encoding": {
228
+ "enum": [
229
+ "hex",
230
+ "base64"
231
+ ]
232
+ },
233
+ "prefix": {
234
+ "type": "string"
235
+ }
236
+ }
237
+ }
238
+ ]
239
+ },
240
+ "secret": {
241
+ "type": "string",
242
+ "description": "The signing secret the app is configured with"
243
+ },
244
+ "event": {
245
+ "type": "string",
246
+ "description": "Event type, e.g. X-GitHub-Event"
247
+ },
248
+ "stale": {
249
+ "type": "boolean",
250
+ "description": "Signed ten minutes ago"
251
+ },
252
+ "invalidSignature": {
253
+ "type": "boolean",
254
+ "description": "Signed with a different secret"
255
+ }
256
+ }
257
+ },
183
258
  "concurrency": {
184
259
  "type": "integer",
185
260
  "minimum": 1,
@@ -212,6 +287,11 @@
212
287
  "minimum": 0
213
288
  },
214
289
  "description": "With `concurrency`: how many responses have each status, e.g. { 201: 1, 409: 9 }"
290
+ },
291
+ "queries": {
292
+ "type": "integer",
293
+ "minimum": 0,
294
+ "description": "At most this many SQL statements for this request, not counting BEGIN/COMMIT (needs db.queries)"
215
295
  }
216
296
  }
217
297
  },
@@ -255,6 +335,106 @@
255
335
  }
256
336
  }
257
337
  },
338
+ {
339
+ "type": "object",
340
+ "required": [
341
+ "make"
342
+ ],
343
+ "additionalProperties": false,
344
+ "properties": {
345
+ "make": {
346
+ "type": "string",
347
+ "description": "Table to make rows in: required columns and parent rows are filled from the schema"
348
+ },
349
+ "name": {
350
+ "type": "string",
351
+ "description": "Label shown when this step fails"
352
+ },
353
+ "rows": {
354
+ "type": [
355
+ "object",
356
+ "array"
357
+ ],
358
+ "description": "Columns to set: a mapping, or a list of mappings for several rows"
359
+ },
360
+ "count": {
361
+ "type": "integer",
362
+ "minimum": 1,
363
+ "description": "Make this many rows, each with `rows`"
364
+ },
365
+ "capture": {
366
+ "type": "object",
367
+ "description": "Save values as variables for later steps, e.g. { orderId: row.id }",
368
+ "additionalProperties": {
369
+ "type": "string"
370
+ }
371
+ }
372
+ }
373
+ },
374
+ {
375
+ "type": "object",
376
+ "required": [
377
+ "chaos"
378
+ ],
379
+ "additionalProperties": false,
380
+ "properties": {
381
+ "chaos": {
382
+ "type": "string",
383
+ "description": "Stub to inject faults into, for the rest of the scenario"
384
+ },
385
+ "name": {
386
+ "type": "string",
387
+ "description": "Label shown when this step fails"
388
+ },
389
+ "failFirst": {
390
+ "type": "integer",
391
+ "minimum": 0,
392
+ "description": "Fail the first n calls, then answer normally"
393
+ },
394
+ "errorRate": {
395
+ "type": "number",
396
+ "minimum": 0,
397
+ "maximum": 1,
398
+ "description": "Share of calls answered with an error status"
399
+ },
400
+ "statuses": {
401
+ "type": "array",
402
+ "minItems": 1,
403
+ "items": {
404
+ "type": "integer",
405
+ "minimum": 400,
406
+ "maximum": 599
407
+ },
408
+ "description": "Error statuses to pick from (default 500, 502, 503)"
409
+ },
410
+ "networkErrorRate": {
411
+ "type": "number",
412
+ "minimum": 0,
413
+ "maximum": 1,
414
+ "description": "Share of calls whose connection is dropped"
415
+ },
416
+ "latency": {
417
+ "oneOf": [
418
+ {
419
+ "type": "number"
420
+ },
421
+ {
422
+ "type": "array",
423
+ "items": {
424
+ "type": "number"
425
+ },
426
+ "minItems": 2,
427
+ "maxItems": 2
428
+ }
429
+ ],
430
+ "description": "Extra delay in ms, or [min, max]"
431
+ },
432
+ "seed": {
433
+ "type": "integer",
434
+ "description": "Seed for the random faults"
435
+ }
436
+ }
437
+ },
258
438
  {
259
439
  "type": "object",
260
440
  "required": [