@btravstack/testing 0.11.0 → 0.13.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 CHANGED
@@ -11,7 +11,6 @@ declare const OrderApi: Module<
11
11
  never,
12
12
  Env | Scope
13
13
  >;
14
- declare const RequestModule: Module<never, never, Logger>;
15
14
  -->
16
15
 
17
16
  # @btravstack/testing
@@ -35,7 +34,9 @@ pnpm add -D @btravstack/testing @btravstack/core @btravstack/config @btravstack/
35
34
  peer dependencies; the package depends on nothing else — not even `vitest`:
36
35
  `bootFixture` is a plain `(ctx, use) => Promise<void>` function, which is
37
36
  vitest's fixture protocol, so no import is needed to hand it to `test.extend`.
38
- Node `>=22`.
37
+ `jose` is an **optional** peer behind the `@btravstack/testing/jwt` subpath —
38
+ see below — so a consumer that never imports it installs nothing extra. Node
39
+ `>=22`.
39
40
 
40
41
  ## A booted application, as a fixture
41
42
 
@@ -48,7 +49,7 @@ const it = test.extend<{ boot: Boot }>({
48
49
  });
49
50
 
50
51
  it("serves on an ephemeral port", async ({ boot }) => {
51
- const app = boot(OrderApi, { unit: RequestModule });
52
+ const app = boot(OrderApi);
52
53
 
53
54
  await expect(app.runtimeInfo()).toBeOkWith(
54
55
  expect.objectContaining({ port: expect.any(Number) }),
@@ -57,7 +58,7 @@ it("serves on an ephemeral port", async ({ boot }) => {
57
58
 
58
59
  it("holds the very logger the use cases write to", async ({ boot }) => {
59
60
  const tap = tapped(OrderApi, [Logger]);
60
- await boot(tap.module, { unit: RequestModule }).runtimeInfo();
61
+ await boot(tap.module).runtimeInfo();
61
62
 
62
63
  const [logger] = tap.services();
63
64
  expect(logger.lines()).toEqual([]);
@@ -85,15 +86,43 @@ answers the very instances the running graph holds once it is built — after
85
86
  are its types.
86
87
  - **`tapped(module, ports)`** → `{ module, services() }` — read services out
87
88
  of a booted application.
88
- - **`testRuntime(name?)`** / **`TestRuntimePort`** — an in-memory `Runtime`
89
- with `submit()` to hold a unit open across a drain, and a `module` that
90
- provides it where a starter would.
89
+ - **`testRuntime(name?, { unit? })`** / **`TestRuntimePort`** — an in-memory
90
+ `Runtime` with `submit()` to hold a unit open across a drain, `host()` to
91
+ reach the `RuntimeHost` it was last started with, and a `module` that
92
+ provides it where a starter would; `unit` is a module every submitted unit
93
+ forks, with no seed, before its work runs — and what that module needs joins
94
+ `module`'s own, so `start` names it instead of the first `submit()` failing.
91
95
  - **`createFakeClock(start?)`** — a `Clock` for `StartOptions.clock` whose
92
96
  time moves only on `advance(ms)`.
93
97
 
94
98
  The teardown rule: a `Defect` on `exited` fails the test, a modeled `Err` does
95
99
  not.
96
100
 
101
+ ## A local JWT issuer, on its own subpath
102
+
103
+ ```ts
104
+ import { localIssuer } from "@btravstack/testing/jwt";
105
+
106
+ const issuer = await localIssuer({
107
+ issuer: "https://issuer.test",
108
+ audience: "orders-api",
109
+ }).get();
110
+
111
+ const token = await issuer.sign({ sub: "u-1" }).get();
112
+ // verify `token` against `issuer.jwks`, the same way a real JWKS would be
113
+ await issuer.close();
114
+ ```
115
+
116
+ `@btravstack/testing/jwt`'s `localIssuer(options)` mints a generated key
117
+ pair, a `node:http` listener answering its public key as a JWKS, and a
118
+ `sign(claims?, options?)` closing over the private key — a real fetch against
119
+ a real JWKS document, for a test that wants the verifying library's own
120
+ behaviour rather than a double's. `sign`'s `expiresIn: false` mints a token
121
+ with no `exp` claim; `localIssuer`'s `algorithm` picks the asymmetric algorithm
122
+ (default `"RS256"`). The subpath needs Node ≥22.12 under CommonJS — `jose` is
123
+ ESM-only, so the CJS build's `require("jose")` depends on `require(esm)`;
124
+ ESM consumers, and anyone who never imports the subpath, are unaffected.
125
+
97
126
  ## License
98
127
 
99
128
  [MIT](./LICENSE) © Benoit TRAVERS
package/dist/index.cjs CHANGED
@@ -15,7 +15,7 @@ let _btravstack_di = require("@btravstack/di");
15
15
  * });
16
16
  *
17
17
  * it("answers", async ({ boot }) => {
18
- * const app = boot(OrderApi, { unit: RequestModule });
18
+ * const app = boot(OrderApi);
19
19
  * …
20
20
  * });
21
21
  * ```
@@ -154,8 +154,8 @@ var Tap = class extends (0, _btravstack_di.Port)("@btravstack/testing/Tap") {};
154
154
  //#region src/test-runtime.ts
155
155
  /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
156
156
  var TestRuntimePort = class extends _btravstack_core.RuntimePort {};
157
- const testRuntime = (name = "test") => {
158
- let run;
157
+ const testRuntime = (name = "test", options = {}) => {
158
+ let host;
159
159
  let accepting = false;
160
160
  let serving;
161
161
  let submitted = 0;
@@ -184,8 +184,8 @@ const testRuntime = (name = "test") => {
184
184
  exports: [TestRuntimePort]
185
185
  }),
186
186
  resolves: [],
187
- start: (host) => {
188
- run = host.run;
187
+ start: (h) => {
188
+ host = h;
189
189
  accepting = true;
190
190
  serving = make();
191
191
  onStarted();
@@ -198,21 +198,26 @@ const testRuntime = (name = "test") => {
198
198
  if (serving === void 0) throw new Error("[test-runtime] not started");
199
199
  return serving;
200
200
  },
201
+ host: () => {
202
+ if (host === void 0) throw new Error("[test-runtime] not started");
203
+ return host;
204
+ },
201
205
  submit: () => {
202
- if (run === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
206
+ if (host === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
203
207
  submitted += 1;
204
208
  let settle;
205
209
  const held = new Promise((resolve) => {
206
210
  settle = resolve;
207
211
  });
208
212
  const forwarded = new AbortController();
209
- const result = run({
213
+ const unit = options.unit;
214
+ const result = host.run({
210
215
  kind: "test",
211
216
  id: `${submitted}`
212
- }, (_ctx, signal) => {
217
+ }, (unitHost, signal) => {
213
218
  if (signal.aborted) forwarded.abort(signal.reason);
214
219
  else signal.addEventListener("abort", () => forwarded.abort(signal.reason), { once: true });
215
- return held;
220
+ return unit === void 0 ? held : unitHost.fork(unit, []).flatMap(() => (0, unthrown.fromSafePromise)(held).flatMap((r) => r));
216
221
  });
217
222
  return {
218
223
  settle,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { Clock, RunningApp, Runtime, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
2
- import { AnyPort, Module, Provider, ServiceOf } from "@btravstack/di";
1
+ import { Clock, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
2
+ import { AnyPort, Module, Provider, Scope, ServiceOf } from "@btravstack/di";
3
3
  import { AsyncResult, Result } from "unthrown";
4
4
  //#region src/boot-fixture.d.ts
5
5
  /**
@@ -7,9 +7,9 @@ import { AsyncResult, Result } from "unthrown";
7
7
  * gate, minus `signals` (always off) — every application it starts is stopped
8
8
  * when the test ends.
9
9
  */
10
- type Boot = <X, E, N, UnitX = never, UnitNeeds = never>(module: Module<X, E, N> & StartGate<X, UnitNeeds, N>, options?: Omit<StartOptions<UnitX, UnitNeeds>, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
10
+ type Boot = <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: Omit<StartOptions, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
11
11
  /** What every `boot` in the fixture starts with; a call's own options win. */
12
- type BootDefaults = Omit<StartOptions, "signals" | "unit">;
12
+ type BootDefaults = Omit<StartOptions, "signals">;
13
13
  /**
14
14
  * A `test.extend` fixture that hands the test a {@link Boot} and stops every
15
15
  * application it started once the test is over — on every exit path, a failing
@@ -21,7 +21,7 @@ type BootDefaults = Omit<StartOptions, "signals" | "unit">;
21
21
  * });
22
22
  *
23
23
  * it("answers", async ({ boot }) => {
24
- * const app = boot(OrderApi, { unit: RequestModule });
24
+ * const app = boot(OrderApi);
25
25
  * …
26
26
  * });
27
27
  * ```
@@ -142,7 +142,26 @@ type TestRuntimeInfo = {
142
142
  };
143
143
  /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
144
144
  declare class TestRuntimePort extends RuntimePort<Runtime<never, TestRuntimeInfo>> {}
145
- type TestRuntime = Runtime<never, TestRuntimeInfo> & {
145
+ /**
146
+ * A module `options.unit` may bind, as the upper bound `testRuntime`
147
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
148
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
149
+ * module's own (necessarily narrower) export type assignable to this bound.
150
+ * `@btravstack/http-server`'s `AnyUnitModule` carries the same bound for the
151
+ * same reason.
152
+ */
153
+ type AnyUnitModule = Module<never, never, unknown>;
154
+ /**
155
+ * The needs a bound `unit` module still owes, or `never` when none is bound.
156
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
157
+ * `NeedsGate` itself carries.
158
+ */
159
+ type UnitNeedsOf<Unit> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope> : never;
160
+ type TestRuntimeOptions<Unit extends AnyUnitModule | undefined = undefined> = {
161
+ /** A module every submitted unit forks, with no seed, before its work runs. */
162
+ readonly unit?: Unit;
163
+ };
164
+ type TestRuntime<Unit extends AnyUnitModule | undefined = undefined> = Runtime<never, TestRuntimeInfo> & {
146
165
  /**
147
166
  * A module providing this very runtime on `TestRuntimePort`: import it next
148
167
  * to the module under test, export the port, and `start` finds it.
@@ -150,8 +169,14 @@ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
150
169
  * It provides THIS object, so a wrapper built by spreading copies the module
151
170
  * too and that module still boots the inner runtime — a wrapper needs a module
152
171
  * of its own.
172
+ *
173
+ * Its Needs channel carries a bound `unit` module's own unmet needs, though
174
+ * nothing here reads them: the module is forked over the application context
175
+ * per unit, so what it needs is exactly what the test composition must
176
+ * supply — and this is what makes `start`'s `UNSATISFIED DEPENDENCIES` say so
177
+ * at boot instead of leaving a `WiringDefect` for the first `submit()`.
153
178
  */
154
- readonly module: Module<TestRuntimePort, never, never>;
179
+ readonly module: Module<TestRuntimePort, never, UnitNeedsOf<Unit>>;
155
180
  readonly started: () => boolean;
156
181
  /** Resolves the first time the kernel calls `start` — `start` itself stays pending until shutdown. */
157
182
  readonly untilStarted: () => AsyncResult<void, never>;
@@ -163,7 +188,9 @@ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
163
188
  readonly accepting: () => boolean;
164
189
  readonly serving: () => Serving<TestRuntimeInfo>;
165
190
  readonly submit: <T = string, E = never>() => SubmittedUnit<T, E>;
191
+ /** The `RuntimeHost` the kernel last called `start` with. **Throws** if the runtime was never started — the same misuse guard as `serving()`. */
192
+ readonly host: () => RuntimeHost<never>;
166
193
  };
167
- declare const testRuntime: (name?: string) => TestRuntime;
194
+ declare const testRuntime: <Unit extends AnyUnitModule | undefined = undefined>(name?: string, options?: TestRuntimeOptions<Unit>) => TestRuntime<Unit>;
168
195
  //#endregion
169
- export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
196
+ export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, type TestRuntimeOptions, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
package/dist/index.d.mts CHANGED
@@ -1,15 +1,15 @@
1
- import { Clock, RunningApp, Runtime, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
1
+ import { Clock, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
2
2
  import { AsyncResult, Result } from "unthrown";
3
- import { AnyPort, Module, Provider, ServiceOf } from "@btravstack/di";
3
+ import { AnyPort, Module, Provider, Scope, ServiceOf } from "@btravstack/di";
4
4
  //#region src/boot-fixture.d.ts
5
5
  /**
6
6
  * `start`, as a test hands it out: the same signature and the same phantom
7
7
  * gate, minus `signals` (always off) — every application it starts is stopped
8
8
  * when the test ends.
9
9
  */
10
- type Boot = <X, E, N, UnitX = never, UnitNeeds = never>(module: Module<X, E, N> & StartGate<X, UnitNeeds, N>, options?: Omit<StartOptions<UnitX, UnitNeeds>, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
10
+ type Boot = <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: Omit<StartOptions, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
11
11
  /** What every `boot` in the fixture starts with; a call's own options win. */
12
- type BootDefaults = Omit<StartOptions, "signals" | "unit">;
12
+ type BootDefaults = Omit<StartOptions, "signals">;
13
13
  /**
14
14
  * A `test.extend` fixture that hands the test a {@link Boot} and stops every
15
15
  * application it started once the test is over — on every exit path, a failing
@@ -21,7 +21,7 @@ type BootDefaults = Omit<StartOptions, "signals" | "unit">;
21
21
  * });
22
22
  *
23
23
  * it("answers", async ({ boot }) => {
24
- * const app = boot(OrderApi, { unit: RequestModule });
24
+ * const app = boot(OrderApi);
25
25
  * …
26
26
  * });
27
27
  * ```
@@ -142,7 +142,26 @@ type TestRuntimeInfo = {
142
142
  };
143
143
  /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
144
144
  declare class TestRuntimePort extends RuntimePort<Runtime<never, TestRuntimeInfo>> {}
145
- type TestRuntime = Runtime<never, TestRuntimeInfo> & {
145
+ /**
146
+ * A module `options.unit` may bind, as the upper bound `testRuntime`
147
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
148
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
149
+ * module's own (necessarily narrower) export type assignable to this bound.
150
+ * `@btravstack/http-server`'s `AnyUnitModule` carries the same bound for the
151
+ * same reason.
152
+ */
153
+ type AnyUnitModule = Module<never, never, unknown>;
154
+ /**
155
+ * The needs a bound `unit` module still owes, or `never` when none is bound.
156
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
157
+ * `NeedsGate` itself carries.
158
+ */
159
+ type UnitNeedsOf<Unit> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope> : never;
160
+ type TestRuntimeOptions<Unit extends AnyUnitModule | undefined = undefined> = {
161
+ /** A module every submitted unit forks, with no seed, before its work runs. */
162
+ readonly unit?: Unit;
163
+ };
164
+ type TestRuntime<Unit extends AnyUnitModule | undefined = undefined> = Runtime<never, TestRuntimeInfo> & {
146
165
  /**
147
166
  * A module providing this very runtime on `TestRuntimePort`: import it next
148
167
  * to the module under test, export the port, and `start` finds it.
@@ -150,8 +169,14 @@ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
150
169
  * It provides THIS object, so a wrapper built by spreading copies the module
151
170
  * too and that module still boots the inner runtime — a wrapper needs a module
152
171
  * of its own.
172
+ *
173
+ * Its Needs channel carries a bound `unit` module's own unmet needs, though
174
+ * nothing here reads them: the module is forked over the application context
175
+ * per unit, so what it needs is exactly what the test composition must
176
+ * supply — and this is what makes `start`'s `UNSATISFIED DEPENDENCIES` say so
177
+ * at boot instead of leaving a `WiringDefect` for the first `submit()`.
153
178
  */
154
- readonly module: Module<TestRuntimePort, never, never>;
179
+ readonly module: Module<TestRuntimePort, never, UnitNeedsOf<Unit>>;
155
180
  readonly started: () => boolean;
156
181
  /** Resolves the first time the kernel calls `start` — `start` itself stays pending until shutdown. */
157
182
  readonly untilStarted: () => AsyncResult<void, never>;
@@ -163,7 +188,9 @@ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
163
188
  readonly accepting: () => boolean;
164
189
  readonly serving: () => Serving<TestRuntimeInfo>;
165
190
  readonly submit: <T = string, E = never>() => SubmittedUnit<T, E>;
191
+ /** The `RuntimeHost` the kernel last called `start` with. **Throws** if the runtime was never started — the same misuse guard as `serving()`. */
192
+ readonly host: () => RuntimeHost<never>;
166
193
  };
167
- declare const testRuntime: (name?: string) => TestRuntime;
194
+ declare const testRuntime: <Unit extends AnyUnitModule | undefined = undefined>(name?: string, options?: TestRuntimeOptions<Unit>) => TestRuntime<Unit>;
168
195
  //#endregion
169
- export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
196
+ export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, type TestRuntimeOptions, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
package/dist/index.mjs CHANGED
@@ -14,7 +14,7 @@ import { Module, Port, Provider, overrideProvider } from "@btravstack/di";
14
14
  * });
15
15
  *
16
16
  * it("answers", async ({ boot }) => {
17
- * const app = boot(OrderApi, { unit: RequestModule });
17
+ * const app = boot(OrderApi);
18
18
  * …
19
19
  * });
20
20
  * ```
@@ -153,8 +153,8 @@ var Tap = class extends Port("@btravstack/testing/Tap") {};
153
153
  //#region src/test-runtime.ts
154
154
  /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
155
155
  var TestRuntimePort = class extends RuntimePort {};
156
- const testRuntime = (name = "test") => {
157
- let run;
156
+ const testRuntime = (name = "test", options = {}) => {
157
+ let host;
158
158
  let accepting = false;
159
159
  let serving;
160
160
  let submitted = 0;
@@ -183,8 +183,8 @@ const testRuntime = (name = "test") => {
183
183
  exports: [TestRuntimePort]
184
184
  }),
185
185
  resolves: [],
186
- start: (host) => {
187
- run = host.run;
186
+ start: (h) => {
187
+ host = h;
188
188
  accepting = true;
189
189
  serving = make();
190
190
  onStarted();
@@ -197,21 +197,26 @@ const testRuntime = (name = "test") => {
197
197
  if (serving === void 0) throw new Error("[test-runtime] not started");
198
198
  return serving;
199
199
  },
200
+ host: () => {
201
+ if (host === void 0) throw new Error("[test-runtime] not started");
202
+ return host;
203
+ },
200
204
  submit: () => {
201
- if (run === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
205
+ if (host === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
202
206
  submitted += 1;
203
207
  let settle;
204
208
  const held = new Promise((resolve) => {
205
209
  settle = resolve;
206
210
  });
207
211
  const forwarded = new AbortController();
208
- const result = run({
212
+ const unit = options.unit;
213
+ const result = host.run({
209
214
  kind: "test",
210
215
  id: `${submitted}`
211
- }, (_ctx, signal) => {
216
+ }, (unitHost, signal) => {
212
217
  if (signal.aborted) forwarded.abort(signal.reason);
213
218
  else signal.addEventListener("abort", () => forwarded.abort(signal.reason), { once: true });
214
- return held;
219
+ return unit === void 0 ? held : unitHost.fork(unit, []).flatMap(() => fromSafePromise(held).flatMap((r) => r));
215
220
  });
216
221
  return {
217
222
  settle,
package/dist/jwt.cjs ADDED
@@ -0,0 +1,49 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let unthrown = require("unthrown");
3
+ let node_http = require("node:http");
4
+ let jose = require("jose");
5
+ //#region src/jwt.ts
6
+ const KID = "k1";
7
+ /**
8
+ * A real JWKS endpoint and a matching signer, for a test that wants a genuine
9
+ * fetch and a genuine verify rather than a double for either.
10
+ *
11
+ * The whole `LocalIssuer` is minted from one call: a key pair, a `node:http`
12
+ * listener answering the JWKS on any path, and `sign` closing over the
13
+ * private key. `close()` stops the listener; nothing else needs tearing down.
14
+ */
15
+ const localIssuer = (options) => {
16
+ const algorithm = options.algorithm ?? "RS256";
17
+ return (0, unthrown.fromSafePromise)((async () => {
18
+ const { publicKey, privateKey } = await (0, jose.generateKeyPair)(algorithm, { extractable: true });
19
+ const jwk = {
20
+ ...await (0, jose.exportJWK)(publicKey),
21
+ kid: KID,
22
+ alg: algorithm,
23
+ use: "sig"
24
+ };
25
+ const server = (0, node_http.createServer)((_request, response) => {
26
+ response.setHeader("content-type", "application/json");
27
+ response.end(JSON.stringify({ keys: [jwk] }));
28
+ });
29
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
30
+ const { port } = server.address();
31
+ return {
32
+ jwks: `http://127.0.0.1:${port}/jwks.json`,
33
+ jwk,
34
+ issuer: options.issuer,
35
+ audience: options.audience,
36
+ sign: (claims = {}, signOptions = {}) => (0, unthrown.fromSafePromise)((async () => {
37
+ const token = new jose.SignJWT(claims).setProtectedHeader({
38
+ alg: algorithm,
39
+ kid: KID
40
+ }).setIssuer(signOptions.issuer ?? options.issuer).setAudience(signOptions.audience ?? options.audience).setIssuedAt();
41
+ if (signOptions.expiresIn !== false) token.setExpirationTime(signOptions.expiresIn ?? "5m");
42
+ return token.sign(privateKey);
43
+ })()),
44
+ close: () => (0, unthrown.fromSafePromise)(new Promise((done) => server.close(() => done())))
45
+ };
46
+ })());
47
+ };
48
+ //#endregion
49
+ exports.localIssuer = localIssuer;
package/dist/jwt.d.cts ADDED
@@ -0,0 +1,36 @@
1
+ import { AsyncResult } from "unthrown";
2
+ import { JWK, JWTPayload } from "jose";
3
+ //#region src/jwt.d.ts
4
+ type LocalIssuerOptions = {
5
+ readonly issuer: string;
6
+ readonly audience: string;
7
+ /** Asymmetric only, the same list `jwtAuthenticator` accepts; default `"RS256"`. */
8
+ readonly algorithm?: "RS256" | "RS384" | "RS512" | "ES256" | "ES384";
9
+ };
10
+ type SignOptions = {
11
+ readonly issuer?: string;
12
+ readonly audience?: string;
13
+ /** `"5m"` by default; `false` mints a token with no `exp` at all. */
14
+ readonly expiresIn?: string | false;
15
+ };
16
+ type LocalIssuer = {
17
+ /** `http://127.0.0.1:<port>/jwks.json` — served on ANY path, so a caller need not match it exactly. */
18
+ readonly jwks: string;
19
+ /** The public key as served, with `kid`/`alg`/`use` already set. */
20
+ readonly jwk: JWK;
21
+ readonly issuer: string;
22
+ readonly audience: string;
23
+ readonly sign: (claims?: JWTPayload, options?: SignOptions) => AsyncResult<string, never>;
24
+ readonly close: () => AsyncResult<void, never>;
25
+ };
26
+ /**
27
+ * A real JWKS endpoint and a matching signer, for a test that wants a genuine
28
+ * fetch and a genuine verify rather than a double for either.
29
+ *
30
+ * The whole `LocalIssuer` is minted from one call: a key pair, a `node:http`
31
+ * listener answering the JWKS on any path, and `sign` closing over the
32
+ * private key. `close()` stops the listener; nothing else needs tearing down.
33
+ */
34
+ declare const localIssuer: (options: LocalIssuerOptions) => AsyncResult<LocalIssuer, never>;
35
+ //#endregion
36
+ export { LocalIssuer, LocalIssuerOptions, SignOptions, localIssuer };
package/dist/jwt.d.mts ADDED
@@ -0,0 +1,36 @@
1
+ import { AsyncResult } from "unthrown";
2
+ import { JWK, JWTPayload } from "jose";
3
+ //#region src/jwt.d.ts
4
+ type LocalIssuerOptions = {
5
+ readonly issuer: string;
6
+ readonly audience: string;
7
+ /** Asymmetric only, the same list `jwtAuthenticator` accepts; default `"RS256"`. */
8
+ readonly algorithm?: "RS256" | "RS384" | "RS512" | "ES256" | "ES384";
9
+ };
10
+ type SignOptions = {
11
+ readonly issuer?: string;
12
+ readonly audience?: string;
13
+ /** `"5m"` by default; `false` mints a token with no `exp` at all. */
14
+ readonly expiresIn?: string | false;
15
+ };
16
+ type LocalIssuer = {
17
+ /** `http://127.0.0.1:<port>/jwks.json` — served on ANY path, so a caller need not match it exactly. */
18
+ readonly jwks: string;
19
+ /** The public key as served, with `kid`/`alg`/`use` already set. */
20
+ readonly jwk: JWK;
21
+ readonly issuer: string;
22
+ readonly audience: string;
23
+ readonly sign: (claims?: JWTPayload, options?: SignOptions) => AsyncResult<string, never>;
24
+ readonly close: () => AsyncResult<void, never>;
25
+ };
26
+ /**
27
+ * A real JWKS endpoint and a matching signer, for a test that wants a genuine
28
+ * fetch and a genuine verify rather than a double for either.
29
+ *
30
+ * The whole `LocalIssuer` is minted from one call: a key pair, a `node:http`
31
+ * listener answering the JWKS on any path, and `sign` closing over the
32
+ * private key. `close()` stops the listener; nothing else needs tearing down.
33
+ */
34
+ declare const localIssuer: (options: LocalIssuerOptions) => AsyncResult<LocalIssuer, never>;
35
+ //#endregion
36
+ export { LocalIssuer, LocalIssuerOptions, SignOptions, localIssuer };
package/dist/jwt.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { fromSafePromise } from "unthrown";
2
+ import { createServer } from "node:http";
3
+ import { SignJWT, exportJWK, generateKeyPair } from "jose";
4
+ //#region src/jwt.ts
5
+ const KID = "k1";
6
+ /**
7
+ * A real JWKS endpoint and a matching signer, for a test that wants a genuine
8
+ * fetch and a genuine verify rather than a double for either.
9
+ *
10
+ * The whole `LocalIssuer` is minted from one call: a key pair, a `node:http`
11
+ * listener answering the JWKS on any path, and `sign` closing over the
12
+ * private key. `close()` stops the listener; nothing else needs tearing down.
13
+ */
14
+ const localIssuer = (options) => {
15
+ const algorithm = options.algorithm ?? "RS256";
16
+ return fromSafePromise((async () => {
17
+ const { publicKey, privateKey } = await generateKeyPair(algorithm, { extractable: true });
18
+ const jwk = {
19
+ ...await exportJWK(publicKey),
20
+ kid: KID,
21
+ alg: algorithm,
22
+ use: "sig"
23
+ };
24
+ const server = createServer((_request, response) => {
25
+ response.setHeader("content-type", "application/json");
26
+ response.end(JSON.stringify({ keys: [jwk] }));
27
+ });
28
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
29
+ const { port } = server.address();
30
+ return {
31
+ jwks: `http://127.0.0.1:${port}/jwks.json`,
32
+ jwk,
33
+ issuer: options.issuer,
34
+ audience: options.audience,
35
+ sign: (claims = {}, signOptions = {}) => fromSafePromise((async () => {
36
+ const token = new SignJWT(claims).setProtectedHeader({
37
+ alg: algorithm,
38
+ kid: KID
39
+ }).setIssuer(signOptions.issuer ?? options.issuer).setAudience(signOptions.audience ?? options.audience).setIssuedAt();
40
+ if (signOptions.expiresIn !== false) token.setExpirationTime(signOptions.expiresIn ?? "5m");
41
+ return token.sign(privateKey);
42
+ })()),
43
+ close: () => fromSafePromise(new Promise((done) => server.close(() => done())))
44
+ };
45
+ })());
46
+ };
47
+ //#endregion
48
+ export { localIssuer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/testing",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Test the applications @btravstack/core boots: an in-memory runtime, a fake clock, a boot fixture that stops what it started, and a tap into a running graph",
5
5
  "keywords": [
6
6
  "dependency-injection",
@@ -41,34 +41,52 @@
41
41
  "default": "./dist/index.cjs"
42
42
  }
43
43
  },
44
- "./package.json": "./package.json"
44
+ "./package.json": "./package.json",
45
+ "./jwt": {
46
+ "import": {
47
+ "types": "./dist/jwt.d.mts",
48
+ "default": "./dist/jwt.mjs"
49
+ },
50
+ "require": {
51
+ "types": "./dist/jwt.d.cts",
52
+ "default": "./dist/jwt.cjs"
53
+ }
54
+ }
45
55
  },
46
56
  "devDependencies": {
47
- "@btravstack/tsconfig": "0.2.0",
57
+ "@btravstack/tsconfig": "0.4.0",
48
58
  "@types/node": "26.4.0",
49
59
  "@unthrown/vitest": "5.7.0",
50
60
  "@vitest/coverage-v8": "4.1.11",
61
+ "jose": "6.2.10",
51
62
  "tsdown": "0.22.14",
52
63
  "typescript": "7.0.2",
53
64
  "unthrown": "5.7.0",
54
65
  "vitest": "4.1.11",
55
- "@btravstack/config": "0.11.0",
56
- "@btravstack/core": "0.11.0",
57
- "@btravstack/di": "0.11.0"
66
+ "@btravstack/config": "0.13.0",
67
+ "@btravstack/core": "0.13.0",
68
+ "@btravstack/di": "0.13.0"
58
69
  },
59
70
  "peerDependencies": {
71
+ "jose": "^6",
60
72
  "unthrown": "^5.0.0",
61
- "@btravstack/config": "^0.11.0",
62
- "@btravstack/core": "^0.11.0",
63
- "@btravstack/di": "^0.11.0"
73
+ "@btravstack/config": "^0.13.0",
74
+ "@btravstack/core": "^0.13.0",
75
+ "@btravstack/di": "^0.13.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "jose": {
79
+ "optional": true
80
+ }
64
81
  },
65
82
  "engines": {
66
83
  "node": ">=22"
67
84
  },
68
85
  "scripts": {
69
- "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
70
- "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
86
+ "build": "tsdown src/index.ts src/jwt.ts --format cjs,esm --dts --clean",
87
+ "dev": "tsdown src/index.ts src/jwt.ts --format cjs,esm --dts --watch",
71
88
  "test": "vitest run --coverage",
72
- "typecheck": "tsc --noEmit"
89
+ "test:types": "tsc --noEmit -p tsconfig.test-d.json",
90
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
73
91
  }
74
92
  }