slicetest 0.4.0 → 0.6.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.
Files changed (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
package/README.md CHANGED
@@ -38,7 +38,7 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
38
38
  npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
39
39
  ```
40
40
 
41
- `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. It lists every guess as a comment in the config so you know what to check.
41
+ `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
42
42
 
43
43
  ## What you get that's hard to find elsewhere
44
44
 
@@ -52,6 +52,12 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
52
52
  - **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table.
53
53
  - **Races on purpose.** `http.concurrently(10, ...)` and `toHaveStatuses({ 201: 1, 409: 9 })` turn "what if two people click at once" into a test against the real database.
54
54
  - **Mail as a fourth boundary.** `mail: true` catches the app's SMTP traffic in-process, decoded, with the links pulled out, so a sign-up test can follow the confirmation link.
55
+ - **Real token verification, any user.** `auth: true` gives the app an OpenID issuer with a JWKS, so JWT checks stay on in tests, and scenarios mint tokens with any claims, including expired or foreign-signed ones.
56
+ - **Webhooks signed like the real sender.** Stripe, GitHub, Slack, Shopify and Standard Webhooks signatures, plus forged and replayed deliveries, so signature checks are tested instead of bypassed.
57
+ - **Reproducible chaos.** Stubs can fail the first calls, drop connections or add latency, from a seed the failure output prints, so a resilience test that fails once fails again on demand.
58
+ - **Forms as a browser sends them.** `http.submit()` presses a button on a server-rendered page, hidden fields included, so CSRF tokens and Next.js server actions work without knowing their internals.
59
+ - **Hard-coded APIs, stubbed anyway.** `hosts: [api.github.com]` catches calls to URLs written in the code or built into a framework, over HTTPS, from Node, Python, Go, Ruby or the JVM, with no change to the app. Redirects to those hosts are followed to the stub, so OAuth logins run end to end.
60
+ - **N+1 detection for any stack.** A wire-protocol proxy records the SQL the app runs, so query counts are asserted at the HTTP boundary, whatever the ORM or language.
55
61
  - **Postgres, MySQL or SQLite**, with the same scenarios and the same helpers on all three, plus Redis, MinIO or any other `containers` reset between scenarios.
56
62
  - **Fast resets.** `TRUNCATE` between scenarios (about 1.5 ms) with the app still running, and a cached migrated template, so the second run skips container start-up and migrations.
57
63
 
@@ -167,12 +173,30 @@ res.status; res.headers; res.text; res.json; res.durationMs;
167
173
  await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" } });
168
174
  await http.post("/login", http.form({ user: "a", pass: "b" })); // urlencoded; FormData, Blob and bytes also work
169
175
  await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
176
+ await http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } }); // a form, as a browser sends it
170
177
 
171
178
  const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
172
179
  http.cookies.get("session"); // cookies persist within a scenario
173
180
  ```
174
181
 
175
- Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config.
182
+ Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config. With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), `303` and `301`/`302` turn into a `GET` as in browsers, and a redirect to another host is returned instead of followed.
183
+
184
+ #### Forms: `http.submit()`
185
+
186
+ `http.submit(page, opts)` sends a form of a page the app returned, the way a browser with JavaScript off does: every field with its value, the hidden ones included, plus the button that was pressed. So the test doesn't need to know about CSRF tokens (Django, Rails, Laravel) or the action ids and bound arguments of **Next.js server actions** — they are hidden inputs, sent along like a browser sends them.
187
+
188
+ ```ts
189
+ const page = await http.get(`/q/${q.id}`); // a Next.js page with <button formAction={vote.bind(null, id, "a")}>
190
+ const res = await http.submit(page, { button: "Dogs", follow: true });
191
+ expect(res.text).toContain("Your choice");
192
+
193
+ await http.submit(await http.get("/settings"), {
194
+ form: "profile", // by id, name or position, when the page has several
195
+ fields: { name: "Ada", newsletter: true, tags: ["a", "b"] }, // checkboxes and radios by true / false / value
196
+ });
197
+ ```
198
+
199
+ `button` matches a submit button's text, `value`, `name` or `id`, and picks its form; its `formaction` / `formmethod` / `formenctype` apply. `fields` replace what the page had and must name existing fields, so a typo fails. When nothing matches, the error lists the page's forms and their buttons. In YAML, `submit:` uses the page the previous step requested.
176
200
 
177
201
  ### `db` — arrange and inspect the real database
178
202
 
@@ -187,6 +211,18 @@ await db.query("UPDATE users SET name = $1", ["b"]);
187
211
 
188
212
  In `where`, `null` means `IS NULL` and an array means `IN (...)`.
189
213
 
214
+ #### `db.make()` — rows from the schema, not from fixtures
215
+
216
+ Give only the columns the scenario is about. slicetest reads the table's definition and fills in the rest: a value of each required column's type, the first allowed value for enums and `CHECK (status IN (...))`, and a parent row for every required foreign key, made the same way.
217
+
218
+ ```ts
219
+ const order = await db.make("orders", { status: "paid" }); // also creates the customer and the product it references
220
+ await db.makeMany("votes", 3, { poll_id: order.poll_id });
221
+ await db.makeMany("users", 2, (i) => ({ name: `user ${i}` }));
222
+ ```
223
+
224
+ Generated values are numbered per scenario (`title-1`, `orders-2@example.test`, UUIDs, dates from 2026-01-01), so they are unique and identical on every run, which keeps `trace()` snapshots stable. Works the same on Postgres, MySQL and SQLite. When a column needs a value slicetest can't guess (a custom type, a check it can't satisfy), the error names the column to pass.
225
+
190
226
  #### `db.changes()` — assert on everything the app wrote
191
227
 
192
228
  Instead of guessing which tables to query, ask for the diff. Rows are matched by primary key, so updates show which columns changed:
@@ -205,6 +241,26 @@ expect(await db.changes()).toEqual({
205
241
 
206
242
  `toEqual` fails if the app wrote to a table you didn't list, which catches unexpected side effects. Each entry in `updated` has `key`, `before`, `after` and `changed`. Tables without a primary key report an update as one deleted row plus one inserted row. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
207
243
 
244
+ #### `db.queries()` — the SQL the app ran, from any language
245
+
246
+ With `db: { queries: true }`, the app's `{{db.url}}` points at a proxy that reads the Postgres or MySQL wire protocol and records every statement the app runs. Nothing changes in the app, and it works the same for an ORM in Node, Python, Ruby, Go or Java. So you can pin down N+1 queries and query counts at the HTTP boundary:
247
+
248
+ ```ts
249
+ const queries = await db.queries(() => http.get("/posts")); // only what ran during the request
250
+ expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times
251
+ expect(queries.withoutTransactions()).toHaveLength(2); // ignore BEGIN / COMMIT that some drivers add
252
+ ```
253
+
254
+ `repeated(min = 3)` and `shapes()` group statements by shape, with literals and parameters replaced by `?`. `db.queries()` without a function returns the whole scenario so far. The test's own `db` calls aren't included. When a scenario fails, the output lists the SQL the app ran, most frequent first, so an N+1 stands out:
255
+
256
+ ```
257
+ SQL the app ran during this scenario (21 statements, most frequent first):
258
+ ×20 SELECT * FROM authors WHERE id = ?
259
+ SELECT * FROM posts ORDER BY id
260
+ ```
261
+
262
+ YAML: `expect: { queries: 3 }` on a `request` step fails if the request ran more than 3 statements (not counting transaction control). Connections that switch to TLS are forwarded but not read. SQLite apps open the file directly, so this isn't available for them.
263
+
208
264
  ### `stub(name)` — fake the services the app calls
209
265
 
210
266
  ```ts
@@ -276,6 +332,88 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
276
332
 
277
333
  The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
278
334
 
335
+ #### Streaming replies: `sse()`
336
+
337
+ LLM APIs stream their answers as Server-Sent Events. `sse(events)` makes such a reply, each event as `[event, data]` or `{ event, data, id }`, data that isn't a string as JSON:
338
+
339
+ ```ts
340
+ import { sse } from "slicetest";
341
+
342
+ stub("anthropic").on("POST", "/v1/messages").reply(sse([
343
+ ["message_start", { type: "message_start", message: { id: "msg_1", type: "message", role: "assistant", content: [], model: "claude-sonnet-4-6", usage: { input_tokens: 5, output_tokens: 1 } } }],
344
+ ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
345
+ ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }],
346
+ ["content_block_stop", { type: "content_block_stop", index: 0 }],
347
+ ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }],
348
+ ["message_stop", { type: "message_stop" }],
349
+ ]));
350
+ ```
351
+
352
+ In YAML, `reply: { sse: [{ event: message_start, data: { ... } }, ...] }` instead of `body`.
353
+
354
+ #### Chaos: faults the app must survive
355
+
356
+ `chaos()` makes a stub misbehave for the rest of the scenario, to test retries, timeouts and fallbacks against the app's real HTTP client:
357
+
358
+ ```ts
359
+ stub("payments").on("POST", "/charges").once().reply(201, { id: "ch_1" });
360
+ stub("payments").chaos({ failFirst: 2, statuses: [503] }); // 503, 503, then the real answer
361
+ await http.post("/orders", { ... });
362
+ expect(stub("payments")).toHaveReceivedTimes(3, "POST", "/charges");
363
+
364
+ stub("search").chaos({ errorRate: 0.3, networkErrorRate: 0.1, latency: [50, 300] });
365
+ ```
366
+
367
+ Faulted calls don't use up `once()` / `times()` routes, so a retry gets the answer you registered. 429 and 503 come with `Retry-After: 1`. Random faults are drawn from a seeded generator: a failing scenario prints `chaos on search: …; 4 of 12 calls faulted. Replay with SLICETEST_CHAOS_SEED=1840211`, and running with that variable gives the same faults. `stub.faults()` lists the calls that faulted. YAML: `- chaos: payments` with `failFirst`, `errorRate`, `statuses`, `networkErrorRate`, `latency` and `seed`.
368
+
369
+ ### Hard-coded hosts: `hosts`
370
+
371
+ Stubs normally take over by giving the app their URL (`{{stub.github}}`) instead of the real one. When the URL is written in the code (`https://api.github.com`), or built into a framework (Spring Security's GitHub login), give the stub the hosts instead:
372
+
373
+ ```yaml
374
+ stubs:
375
+ - name: github
376
+ hosts: [github.com] # OAuth authorize and token endpoints
377
+ - name: github-api
378
+ hosts: [api.github.com]
379
+ ```
380
+
381
+ The app is then started with `HTTPS_PROXY` / `HTTP_PROXY` pointing at slicetest and a certificate authority made for the run in the trust settings each runtime reads: `NODE_USE_ENV_PROXY` and `NODE_EXTRA_CA_CERTS` for Node (22.21+ / 24.5+), `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` for Python, Ruby, Go and curl, and proxy and trust-store system properties in `JAVA_TOOL_OPTIONS` for the JVM (`HttpURLConnection`, `java.net.http.HttpClient`, Spring's `RestClient` and `RestTemplate`). Calls to those hosts reach the stub with their path and `Host` header, over HTTPS or HTTP; calls to other hosts go to the real ones and are listed in the failure output. Nothing changes in the app. Variables you set in `app.env` win over these, and `{{proxy.url}}`, `{{proxy.ca}}`, `{{proxy.bundle}}` and `{{proxy.truststore}}` are there for clients configured some other way.
382
+
383
+ `*.connpass.com` covers every subdomain (one stub for `findy.connpass.com`, `mercari.connpass.com`, …); the stub sees the `Host` header, so a reply can depend on it (`{{call.headers.host}}`, or `call.headers.host` in a reply function).
384
+
385
+ With `offline: true`, the app can only reach localhost and the stubs' hosts. A call anywhere else is refused and fails the scenario with the host's name, so a forgotten stub can't quietly reach a real service, and the first run tells you which hosts to add:
386
+
387
+ ```
388
+ slicetest: offline: the app tried to reach api.lu.ma, which no stub answers. Add it to a stub's `hosts` (or remove `offline`).
389
+ ```
390
+
391
+ Node apps also get a small preload (`NODE_OPTIONS=--require …`) that gives the proxy settings to `http.Agent`s that libraries make themselves (the Stripe SDK, many API clients), which `NODE_USE_ENV_PROXY` alone doesn't reach. A client in any language that ignores proxy settings altogether goes straight to the real host; `offline` can't stop what doesn't pass through it.
392
+
393
+ The proxy settings reach every process the app command starts. When that command is a build tool (`gradle bootRun`, `mvn spring-boot:run`, `go run`), its own downloads go through the proxy too, and `offline` refuses them; the failure says so when the host is a package registry. Download dependencies in `app.build` (`./gradlew bootJar`, `mvn package`, which `slicetest init` sets up for Spring Boot), or start a built artifact.
394
+
395
+ A redirect to an intercepted host is followed to its stub, as a browser would follow it to the real site. So a whole OAuth login runs in a scenario: the stub plays the provider's consent page and sends the browser back.
396
+
397
+ ```yaml
398
+ setup:
399
+ - stub: github
400
+ on: GET /login/oauth/authorize
401
+ reply: { status: 302, headers: { location: "{{call.query.redirect_uri}}?code=c1&state={{call.query.state}}" } }
402
+ - stub: github
403
+ on: POST /login/oauth/access_token
404
+ reply: { body: { access_token: gho_test, token_type: bearer } }
405
+ - stub: github-api
406
+ on: GET /user
407
+ reply: { body: { id: 1, login: octocat } }
408
+ scenarios:
409
+ - name: log in with GitHub
410
+ steps:
411
+ - request: GET /oauth2/authorization/github
412
+ follow: true # → github.com (stub) → back to the app's callback
413
+ - request: GET /api/me
414
+ expect: { json: { login: octocat } }
415
+ ```
416
+
279
417
  ### Recording a real service
280
418
 
281
419
  A stub can also answer from recordings of the real service, the way VCR or Polly do, except that it works for an app in any language because the stub is a server. Give it the real base URL:
@@ -389,6 +527,57 @@ scenario("sign-up sends a confirmation link that works", async ({ http, mail })
389
527
 
390
528
  `mail.messages(filter?)`, `mail.last(filter?)` and `mail.waitFor(filter?, { within })` take `{ to, from, subject, text, html }`: addresses match exactly, other strings as substrings, and RegExps test the value. Each message has `from`, `to` (the envelope, so Cc and Bcc too), `subject`, `text`, `html`, `headers`, `links` and `raw`. The mailbox is emptied before each scenario, what was sent shows up in the failure output and in `trace()`, and `{{mail.url}}` is `smtp://host:port` for libraries that take a URL. No container is involved, so it works the same for apps in any language and on Windows.
391
529
 
530
+ ### Auth: a real OpenID issuer, tokens with any claims
531
+
532
+ Apps that verify JWTs are hard to test from the outside: you either disable verification in tests or copy a production token. With `auth: true`, slicetest runs an OpenID Connect issuer for the app, with a discovery document, a JWKS and RS256 keys made for the run, so the app verifies tokens exactly as it does in production. The scenario mints whatever user it needs.
533
+
534
+ ```ts
535
+ slicetest({
536
+ auth: { audience: "api://orders", claims: { tenant: "acme" } }, // or just `auth: true`
537
+ app: { command: "...", env: { OIDC_ISSUER: "{{auth.issuer}}", JWKS_URL: "{{auth.jwks}}", OIDC_AUDIENCE: "{{auth.audience}}" } },
538
+ });
539
+
540
+ scenario("admins can delete orders", async ({ http, auth }) => {
541
+ const res = await http.delete("/orders/1", { headers: auth.header({ sub: "alice", roles: ["admin"] }) });
542
+ expect(res).toHaveStatus(204);
543
+ });
544
+
545
+ scenario("the app rejects tokens it must not trust", async ({ http, auth }) => {
546
+ for (const opts of [{ expired: true }, { wrongKey: true }, { audience: "api://other" }, { issuer: "https://evil.example" }]) {
547
+ expect(await http.get("/orders", { headers: auth.header({}, opts) })).toHaveStatus(401);
548
+ }
549
+ });
550
+ ```
551
+
552
+ `auth.token(claims, opts)` returns the JWT itself. Tokens get `iss`, `aud`, `sub: "user-1"`, `iat`, `nbf`, `exp` (1 hour, or `expiresIn`) and the configured `claims`, all overridable. `auth.rotate()` switches to a new signing key, to check that the app refetches the JWKS. Apps that fetch tokens themselves can use `POST {{auth.issuer}}/token` with the client-credentials grant: `sub` is the client id, and `scope` and `audience` are carried over. No dependencies: keys and signatures come from `node:crypto`.
553
+
554
+ For libraries that don't take an issuer URL: `{{auth.publicKey}}` is the signing key as a PEM public key, and `auth.jwks` the JWKS document, for a stub to serve at the provider's own URL (see [Clerk](#clerk)).
555
+
556
+ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those claims (`auth: true` for the defaults):
557
+
558
+ ```yaml
559
+ - request: GET /me
560
+ auth: { sub: alice, roles: [admin] }
561
+ expect: { status: 200 }
562
+ ```
563
+
564
+ ### Webhooks: deliveries signed like the provider's
565
+
566
+ `http.webhook()` posts a payload the way Stripe, GitHub, Slack, Shopify or any [Standard Webhooks](https://www.standardwebhooks.com/) sender (Svix, Resend, Clerk, …) delivers it, signed with the secret the app is configured with, so the app's real verification code runs. The signatures are checked against the providers' documented examples.
567
+
568
+ ```ts
569
+ const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
570
+
571
+ await http.webhook("/webhooks/stripe", { type: "invoice.paid", data: { object: { id: "in_1" } } }, stripe);
572
+ await http.webhook("/webhooks/github", { action: "opened" }, { provider: "github", secret: "s", event: "pull_request" });
573
+
574
+ // Deliveries the app must refuse:
575
+ expect(await http.webhook("/webhooks/stripe", event, { ...stripe, invalidSignature: true })).toHaveStatus(400);
576
+ expect(await http.webhook("/webhooks/stripe", event, { ...stripe, stale: true })).toHaveStatus(400); // signed 10 minutes ago
577
+ ```
578
+
579
+ Objects are sent as JSON, `URLSearchParams` as a form (Slack slash commands), strings as they are. Other HMAC schemes: `provider: { header: "X-Signature", prefix: "sha256=", encoding: "hex" }`. `signWebhook(body, opts)` returns just the headers. In YAML, add `webhook: { provider, secret, event, stale, invalidSignature }` to a `request` step; its `json`, `form` or `body` is what gets signed.
580
+
392
581
  ### Asynchronous side effects
393
582
 
394
583
  If the app does work in the background (a job queue, a fire-and-forget webhook), wait for the effect with Vitest's own helpers. slicetest doesn't need its own:
@@ -436,23 +625,31 @@ Scenarios in one file share an app and a database, so they always run one at a t
436
625
  | Option | Default | |
437
626
  |---|---|---|
438
627
  | `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
439
- | `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. The rest of `process.env` is inherited. |
628
+ | `app.build` | none | Shell command run once per run before the app starts, while the database starts, e.g. `npm run build` for `next start`. It gets `app.env`'s literal values (Next.js inlines `NEXT_PUBLIC_*` at build time), not those with placeholders. Not repeated in watch mode. Services take `build` too. |
629
+ | `db` | Postgres in a container | Database options (below), or `false` for an app without a database: nothing is started, `{{db.*}}` aren't set and `db.*` in scenarios explains that it's off. `slicetest init` writes `false` when it finds no migrations, no database service and no database library. |
630
+ | `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. For apps that don't take one URL: `{{db.jdbcUrl}}` (`jdbc:postgresql://…`), `{{db.host}}`, `{{db.port}}`, `{{db.name}}`, `{{db.user}}`, `{{db.password}}`. The rest of `process.env` is inherited. |
440
631
  | `app.cwd` | vitest root | |
441
632
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
442
633
  | `app.readyTimeout` | `30000` | |
634
+ | `app.scope` | `"file"` | `"worker"`: start the app (and stubs, services) once per Vitest worker and keep it for all of that worker's test files, for apps that start slowly. Sets Vitest's `isolate: false`. |
443
635
  | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
444
636
  | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
445
637
  | `db.seed` | none | SQL file re-run after every reset. |
446
638
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
447
639
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
640
+ | `db.neon` | `false` | For Neon's serverless driver over HTTP: `{{db.url}}` is a Neon-style URL and slicetest answers the driver's queries from the test database ([Neon](#neon-and-vercel-postgres)). |
641
+ | `db.queries` | `false` | Point the app at a proxy that records its SQL (Postgres, MySQL), for [`db.queries()`](#dbqueries--the-sql-the-app-ran-from-any-language). |
448
642
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
449
643
  | `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
450
644
  | `db.reuse` | on, unless `CI` is set or `db.url` is given | Keep the container between runs and cache the migrated template. The cache key is the migration files' contents; for `{ command }`, list what it reads in `inputs: ["prisma/migrations"]`, or it migrates every run. Databases left by killed runs are dropped after a day. Remove the container (`docker rm -f` / `podman rm -f`) to start clean. |
451
645
  | `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
452
646
  | `mail` | `false` | Start an SMTP server at `{{mail.host}}` / `{{mail.port}}` and collect the app's mail. See [Mail](#mail-catch-what-the-app-sends). |
647
+ | `auth` | `false` | `true` or `{ audience, claims }`: an OpenID Connect issuer at `{{auth.issuer}}` (JWKS at `{{auth.jwks}}`) whose tokens scenarios mint with `auth.token()`. See [Auth](#auth-a-real-openid-issuer-tokens-with-any-claims). |
453
648
  | `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
454
- | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings? }`: check calls against the provider's spec, answer from it, or [replay recordings](#recording-a-real-service) of the real service. |
455
- | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
649
+ | `offline` | `false` | Refuse the app's HTTP(S) calls to hosts no stub intercepts, and fail the scenario naming them. |
650
+ | `workers` | Vitest's default | Most Vitest workers (`maxWorkers`). Each has its own app and database. |
651
+ | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings?, hosts? }`: check calls against the provider's spec, answer from it, [replay recordings](#recording-a-real-service) of the real service, or answer for [hard-coded hosts](#hard-coded-hosts-hosts). |
652
+ | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`, or `{ fromApp: "/v3/api-docs" }` for a spec the running app serves (springdoc, FastAPI's `/openapi.json`, NestJS). Every response must match it; the run ends with a coverage report. |
456
653
  | `http` | `{}` | Default `headers` / `query` for every request. |
457
654
 
458
655
  The config is validated up front: a missing `app.command`, an ambiguous `db.migrate` or a duplicate stub name fails with a clear message instead of a timeout.
@@ -504,8 +701,13 @@ scenarios:
504
701
  | Step | Keys |
505
702
  |---|---|
506
703
  | `stub: <name>` | `on: METHOD /path` (`:params` allowed), `when: { query, headers, json, body }`, one of `reply: { status, headers, body }` / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`. |
704
+ | `submit: <button>` | `form`, `fields`, `headers`, `follow`, `expect: { status, headers, json, text }`, `capture`. Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
507
705
  | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture`. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
508
706
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
707
+ | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
708
+ | `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
709
+ | `chaos: <stub>` | `failFirst`, `errorRate`, `statuses`, `networkErrorRate`, `latency`, `seed` — like `stub(name).chaos()` |
710
+ | `make: <table>` | `rows` (a mapping, or a list for several rows), `count`, `capture` (from `row` / `rows`) — like `db.make()` |
509
711
  | `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
510
712
  | `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
511
713
  | `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
@@ -525,7 +727,7 @@ scenarios:
525
727
 
526
728
  ### Without any JavaScript: `npx slicetest`
527
729
 
528
- Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config:
730
+ Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config. To add TypeScript scenarios later, `slicetest()` without options in a `vitest.config.ts` reads the same file (`slicetest("path/to/config.yaml")` for another one):
529
731
 
530
732
  ```yaml
531
733
  # slicetest.config.yaml
@@ -553,6 +755,8 @@ npx slicetest gen --uncovered # only the documented responses the last run d
553
755
 
554
756
  writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented response. Requests are built from the spec's examples and schemas. A path that needs an id gets a step that creates the resource first through the collection's `POST` and captures its id. A 404 on a made-up id and a 400/422 on an empty body are runnable as is; other responses are generated as `skip: true` scenarios marked TODO, so the skipped list in the test output is what's left to cover. Existing files are kept unless you pass `--force`.
555
757
 
758
+ Operations that the spec protects with a bearer token (`http: bearer`, `oauth2` or `openIdConnect` security) get `auth:` on their requests, with the scopes the spec requires in the token's `scope` claim, and their 401 responses become runnable scenarios that send no token. With [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) in the config, the generated scenarios run against the app's real token checks.
759
+
556
760
  `--uncovered` reads the coverage the last run left in `node_modules/.cache/slicetest/`, which closes the loop: run, look at the ✗ in the coverage table, `gen --uncovered`, fill in the TODOs.
557
761
 
558
762
  ### Record a scenario by using the app: `npx slicetest record`
@@ -597,6 +801,88 @@ Checks what a run needs before it starts, instead of failing with a timeout half
597
801
  1 problem(s) to fix before running.
598
802
  ```
599
803
 
804
+ ## Neon and Vercel Postgres
805
+
806
+ Apps on Neon's serverless driver (`neon()` from `@neondatabase/serverless`, `drizzle-orm/neon-http`, `@vercel/postgres`'s `sql`) send each query over HTTPS to Neon, not to a Postgres port. With `db: { neon: true }`, `{{db.url}}` is a Neon-style connection string, and slicetest answers the driver's HTTP queries (single statements and `transaction()` batches) from the test database. The driver, its type parsing and its errors (with Postgres's `code`) are the real ones; `db.queries` sees the SQL. `slicetest init` sets it when the driver is a dependency. The driver's WebSocket mode (`Pool`, `Client`) isn't covered.
807
+
808
+ With Drizzle, `drizzle-kit push` and `migrate` connect through the project's driver; with only Neon's installed, they try a WebSocket a local server doesn't answer, and exit 0 anyway (slicetest stops when a migration leaves the database without tables). Generate the SQL instead (`npx drizzle-kit generate`) and let slicetest apply it: `migrate: { sql: db/migrations }`, which `slicetest init` reads from `drizzle.config`'s `out`.
809
+
810
+ ## Clerk
811
+
812
+ Clerk verifies session tokens with keys from its Backend API, and reads users from it. Both are stubbed, and the tokens come from slicetest's issuer, so requests run as any user, with no Clerk account:
813
+
814
+ ```yaml
815
+ # slicetest.config.yaml (what `slicetest init` writes when @clerk/nextjs is a dependency)
816
+ app:
817
+ env:
818
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuc2xpY2V0ZXN0LnRlc3Qk # base64 of "clerk.slicetest.test$"
819
+ CLERK_SECRET_KEY: sk_test_slicetest
820
+ auth: true
821
+ stubs:
822
+ - name: clerk
823
+ hosts: [api.clerk.com, clerk.slicetest.test]
824
+ ```
825
+
826
+ ```ts
827
+ function signIn({ auth, stub }: ScenarioContext, id = "user_1", email = "ada@example.com") {
828
+ stub("clerk").on("GET", "/v1/jwks").reply(200, auth.jwks); // the keys Clerk verifies tokens with
829
+ stub("clerk").on("GET", `/v1/users/${id}`).reply(200, {
830
+ object: "user", id, first_name: "Ada", last_name: null, primary_email_address_id: "e1",
831
+ email_addresses: [{ object: "email_address", id: "e1", email_address: email, verification: { status: "verified" }, linked_to: [] }],
832
+ phone_numbers: [], web3_wallets: [], external_accounts: [], created_at: 0, updated_at: 0,
833
+ });
834
+ return auth.header({ sub: id, sid: "sess_1" });
835
+ }
836
+
837
+ scenario("checkout as a signed-in user", async (ctx) => {
838
+ const res = await ctx.http.post("/api/checkout", undefined, { headers: signIn(ctx) });
839
+ expect(res).toHaveStatus(200);
840
+ });
841
+ ```
842
+
843
+ `CLERK_JWT_KEY` with `{{auth.publicKey}}` would avoid the JWKS stub, but Next.js inlines environment variables into middleware at build time, before the key exists. Without a token, `auth.protect()` in middleware answers API routes with 404.
844
+
845
+ ## Spring Boot and other JVM apps
846
+
847
+ `slicetest init` recognizes Spring Boot (Gradle or Maven, also in a `backend/` folder) and writes:
848
+
849
+ ```yaml
850
+ app:
851
+ cwd: backend
852
+ build: ./gradlew bootJar -q # built once: workers don't compile at once, bootRun downloads nothing
853
+ command: ./gradlew bootRun -q
854
+ env:
855
+ SERVER_PORT: "{{app.port}}"
856
+ SPRING_DATASOURCE_URL: "{{db.jdbcUrl}}" # overrides application.yml
857
+ SPRING_DATASOURCE_USERNAME: "{{db.user}}"
858
+ SPRING_DATASOURCE_PASSWORD: "{{db.password}}"
859
+ ready: { path: /actuator/health }
860
+ readyTimeout: 120000
861
+ scope: worker # one JVM per worker, kept for all of its test files
862
+ workers: 2
863
+ ```
864
+
865
+ Starting a JVM takes seconds, so by default (`scope: file`, one app per test file) the start-up dominates: 8 test files took 28 s on a Spring Boot 4 app. With `scope: worker` the app is started once per Vitest worker and kept for every file that worker runs, and `workers` caps how many there are: 18 s with the default worker count, 10 s with 2, 8 s with 1. Scenarios stay independent, since the database, stubs and cookies are reset between them either way; what's shared is the app process (and its in-memory state, such as caches).
866
+
867
+ Springdoc serves the spec at `/v3/api-docs`: `openapi: { fromApp: /v3/api-docs }` checks every response against it and reports its coverage, without a spec file in the repository. Generated specs often miss error responses and nullable fields, which this makes visible.
868
+
869
+ Apps that run Hibernate with `ddl-auto: validate` against schema-owning migrations (Atlas, or a migration command) work as they are: slicetest migrates, Hibernate validates the result at start-up. For a faster start, build a jar once with `build: ./gradlew bootJar -q` and run `command: java -jar build/libs/app.jar`. Calls to hard-coded hosts (GitHub, Google, …) are caught with [`hosts`](#hard-coded-hosts-hosts).
870
+
871
+ ## Next.js
872
+
873
+ Nothing Next-specific is needed beyond two settings. `slicetest init` writes both when `package.json` has `build` and `start` scripts:
874
+
875
+ ```yaml
876
+ app:
877
+ build: npm run build # `next start` serves the last build; without this, scenarios can pass against old code
878
+ command: npm start # next start reads $PORT
879
+ env: { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" }
880
+ ```
881
+
882
+ - **Server actions** are ordinary form posts when JavaScript is off. Request the page, then `submit:` the button; the action id and bound arguments travel in hidden inputs, so scenarios keep working when a build changes the ids.
883
+ - **Route handlers and pages** are plain HTTP: `request: GET /api/...` and `text: { $contains: ... }`. React may put `<!-- -->` between adjacent text nodes, so match on a word rather than on `75%` built from two values.
884
+ - **Rows inserted by migrations** (seed data in an Atlas or Prisma migration) are truncated before every scenario, like everything else. Create what a scenario needs with `make`, or move shared rows to `db.seed`.
885
+
600
886
  ## Examples
601
887
 
602
888
  `examples/` has a Node app (`node:http` + `pg`) and a Python app (`http.server` + `psycopg`) with the same API. **The same scenario files (`polls.test.ts` and `polls.scenario.yaml`) run against both**:
@@ -612,4 +898,4 @@ npm run test:dist # the built package, and the CLI with examples/slicetest.con
612
898
 
613
899
  ## Status
614
900
 
615
- Early. Postgres and MySQL. CI runs on Linux and Windows.
901
+ Early. Postgres, MySQL and SQLite. CI runs on Linux and Windows.
package/dist/app.js CHANGED
@@ -69,11 +69,11 @@ export class App {
69
69
  // A restarted service keeps its port, so URLs already handed to other processes stay valid.
70
70
  const port = fixedPort ?? (await freePort());
71
71
  vars = { ...vars, [`${key}.port`]: String(port) };
72
- const env = opts.env ?? { PORT: `{{${key}.port}}`, DATABASE_URL: "{{db.url}}" };
72
+ const env = opts.env ?? { PORT: `{{${key}.port}}`, ...("db.url" in vars ? { DATABASE_URL: "{{db.url}}" } : {}) };
73
73
  const child = spawn(interpolate(opts.command, vars, `${key}.command`), {
74
74
  shell: true,
75
75
  cwd: path.resolve(root, opts.cwd ?? "."),
76
- env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
76
+ env: { ...process.env, ...opts.baseEnv, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
77
77
  stdio: ["ignore", "pipe", "pipe"],
78
78
  // POSIX: own process group, so stop() also kills whatever the shell spawned.
79
79
  // Windows: detaching would open a console window; taskkill /T walks the tree instead.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ export interface TokenOptions {
2
+ /** Seconds until the token expires. Default 3600. */
3
+ expiresIn?: number;
4
+ /** A token that expired a minute ago. */
5
+ expired?: boolean;
6
+ /** Signed by a key the issuer doesn't publish: the app must reject it. */
7
+ wrongKey?: boolean;
8
+ /** Override `aud` (default: the configured audience). */
9
+ audience?: string | string[];
10
+ /** Override `iss`, e.g. to test that the app checks it. */
11
+ issuer?: string;
12
+ }
13
+ export interface AuthOptions {
14
+ /** `aud` of the tokens. Default `"slicetest"`. */
15
+ audience?: string;
16
+ /** Claims every token gets unless the scenario overrides them. */
17
+ claims?: Record<string, unknown>;
18
+ }
19
+ /**
20
+ * An OpenID Connect issuer for the app under test: it publishes a discovery
21
+ * document and a JWKS, so the app verifies tokens exactly as it does in
22
+ * production, and the scenario mints tokens with whatever claims it needs.
23
+ * `POST /token` answers the client-credentials grant for apps that fetch
24
+ * tokens themselves.
25
+ */
26
+ export declare class Issuer {
27
+ #private;
28
+ private readonly server;
29
+ readonly url: string;
30
+ private readonly opts;
31
+ private constructor();
32
+ static start(opts?: AuthOptions): Promise<Issuer>;
33
+ get audience(): string;
34
+ /**
35
+ * The signing key as a PEM public key, for libraries that verify tokens with a key
36
+ * given in the environment instead of fetching a JWKS (Clerk's CLERK_JWT_KEY, …).
37
+ * The app gets it at start as {{auth.publicKey}}; tokens minted after `rotate()` use a key it doesn't know.
38
+ */
39
+ get publicKeyPem(): string;
40
+ /** The JWKS document itself (`{ keys: [...] }`), for a stub that serves keys at another provider's URL (Clerk's /v1/jwks, …). */
41
+ get jwks(): {
42
+ keys: Record<string, unknown>[];
43
+ };
44
+ get jwksUrl(): string;
45
+ /** A signed RS256 JWT. `claims` override the defaults (`sub: "user-1"`, `iss`, `aud`, `iat`, `exp`). */
46
+ token(claims?: Record<string, unknown>, opts?: TokenOptions): string;
47
+ /** `{ authorization: "Bearer <token>" }`, for `http.get(path, { headers })`. */
48
+ header(claims?: Record<string, unknown>, opts?: TokenOptions): {
49
+ authorization: string;
50
+ };
51
+ /** Rotate the signing key: tokens minted from now on use a new `kid`, and the JWKS publishes only it. */
52
+ rotate(): void;
53
+ reset(): void;
54
+ close(): Promise<void>;
55
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,140 @@
1
+ import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ const b64url = (data) => Buffer.from(data).toString("base64url");
4
+ function newKey() {
5
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
6
+ const kid = randomUUID();
7
+ return { kid, privateKey, jwk: { ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" }, pem: publicKey.export({ type: "spki", format: "pem" }) };
8
+ }
9
+ /**
10
+ * An OpenID Connect issuer for the app under test: it publishes a discovery
11
+ * document and a JWKS, so the app verifies tokens exactly as it does in
12
+ * production, and the scenario mints tokens with whatever claims it needs.
13
+ * `POST /token` answers the client-credentials grant for apps that fetch
14
+ * tokens themselves.
15
+ */
16
+ export class Issuer {
17
+ server;
18
+ url;
19
+ opts;
20
+ #key = newKey();
21
+ /** Never published: tokens signed with it must be rejected. */
22
+ #rogue;
23
+ #issued = 0;
24
+ constructor(server, url, opts) {
25
+ this.server = server;
26
+ this.url = url;
27
+ this.opts = opts;
28
+ }
29
+ static async start(opts = {}) {
30
+ let issuer;
31
+ const server = createServer((req, res) => {
32
+ issuer.#handle(req).then(([status, body]) => {
33
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
34
+ res.end(JSON.stringify(body));
35
+ }, (e) => {
36
+ res.writeHead(500, { "content-type": "application/json" });
37
+ res.end(JSON.stringify({ error: "server_error", error_description: String(e) }));
38
+ });
39
+ });
40
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
41
+ const { port } = server.address();
42
+ issuer = new Issuer(server, `http://127.0.0.1:${port}`, { audience: opts.audience ?? "slicetest", claims: opts.claims ?? {} });
43
+ return issuer;
44
+ }
45
+ get audience() {
46
+ return this.opts.audience;
47
+ }
48
+ /**
49
+ * The signing key as a PEM public key, for libraries that verify tokens with a key
50
+ * given in the environment instead of fetching a JWKS (Clerk's CLERK_JWT_KEY, …).
51
+ * The app gets it at start as {{auth.publicKey}}; tokens minted after `rotate()` use a key it doesn't know.
52
+ */
53
+ get publicKeyPem() {
54
+ return this.#key.pem;
55
+ }
56
+ /** The JWKS document itself (`{ keys: [...] }`), for a stub that serves keys at another provider's URL (Clerk's /v1/jwks, …). */
57
+ get jwks() {
58
+ return { keys: [this.#key.jwk] };
59
+ }
60
+ get jwksUrl() {
61
+ return `${this.url}/.well-known/jwks.json`;
62
+ }
63
+ /** A signed RS256 JWT. `claims` override the defaults (`sub: "user-1"`, `iss`, `aud`, `iat`, `exp`). */
64
+ token(claims = {}, opts = {}) {
65
+ const now = Math.floor(Date.now() / 1000);
66
+ const exp = opts.expired ? now - 60 : now + (opts.expiresIn ?? 3600);
67
+ const key = opts.wrongKey ? (this.#rogue ??= newKey()) : this.#key;
68
+ const payload = {
69
+ iss: opts.issuer ?? this.url,
70
+ aud: opts.audience ?? this.opts.audience,
71
+ sub: "user-1",
72
+ iat: opts.expired ? exp - 3600 : now,
73
+ nbf: opts.expired ? exp - 3600 : now,
74
+ exp,
75
+ jti: `slicetest-${++this.#issued}`,
76
+ ...this.opts.claims,
77
+ ...claims,
78
+ };
79
+ const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid }));
80
+ const body = b64url(JSON.stringify(payload));
81
+ const signature = createSign("RSA-SHA256").update(`${head}.${body}`).sign(key.privateKey);
82
+ return `${head}.${body}.${b64url(signature)}`;
83
+ }
84
+ /** `{ authorization: "Bearer <token>" }`, for `http.get(path, { headers })`. */
85
+ header(claims, opts) {
86
+ return { authorization: `Bearer ${this.token(claims, opts)}` };
87
+ }
88
+ /** Rotate the signing key: tokens minted from now on use a new `kid`, and the JWKS publishes only it. */
89
+ rotate() {
90
+ this.#key = newKey();
91
+ }
92
+ reset() {
93
+ this.#issued = 0;
94
+ }
95
+ async #handle(req) {
96
+ const path = new URL(req.url ?? "/", this.url).pathname;
97
+ if (req.method === "GET" && path === "/.well-known/openid-configuration") {
98
+ return [
99
+ 200,
100
+ {
101
+ issuer: this.url,
102
+ jwks_uri: this.jwksUrl,
103
+ token_endpoint: `${this.url}/token`,
104
+ response_types_supported: ["token"],
105
+ subject_types_supported: ["public"],
106
+ id_token_signing_alg_values_supported: ["RS256"],
107
+ grant_types_supported: ["client_credentials"],
108
+ },
109
+ ];
110
+ }
111
+ if (req.method === "GET" && path === "/.well-known/jwks.json")
112
+ return [200, { keys: [this.#key.jwk] }];
113
+ if (req.method === "POST" && path === "/token") {
114
+ const form = new URLSearchParams(await text(req));
115
+ if (form.get("grant_type") !== "client_credentials")
116
+ return [400, { error: "unsupported_grant_type" }];
117
+ const basic = /^Basic\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1];
118
+ const client = form.get("client_id") ?? (basic ? decodeURIComponent(Buffer.from(basic, "base64").toString().split(":")[0]) : undefined);
119
+ if (!client)
120
+ return [401, { error: "invalid_client" }];
121
+ const claims = { sub: client, client_id: client };
122
+ const scope = form.get("scope");
123
+ if (scope)
124
+ claims.scope = scope;
125
+ const audience = form.get("audience") ?? undefined;
126
+ return [200, { access_token: this.token(claims, { audience }), token_type: "Bearer", expires_in: 3600, ...(scope ? { scope } : {}) }];
127
+ }
128
+ return [404, { error: "not_found", error_description: `slicetest's issuer serves /.well-known/openid-configuration, /.well-known/jwks.json and POST /token, not ${req.method} ${path}` }];
129
+ }
130
+ async close() {
131
+ this.server.closeAllConnections();
132
+ await new Promise((resolve) => this.server.close(resolve));
133
+ }
134
+ }
135
+ async function text(req) {
136
+ const chunks = [];
137
+ for await (const chunk of req)
138
+ chunks.push(chunk);
139
+ return Buffer.concat(chunks).toString();
140
+ }
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import type { SlicetestOptions } from "./config.js";
2
+ import { type SlicetestOptions } from "./config.js";
3
3
  export interface CliConfig extends SlicetestOptions {
4
4
  include?: string[];
5
5
  }