@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 +36 -7
- package/dist/index.cjs +14 -9
- package/dist/index.d.cts +36 -9
- package/dist/index.d.mts +36 -9
- package/dist/index.mjs +14 -9
- package/dist/jwt.cjs +49 -0
- package/dist/jwt.d.cts +36 -0
- package/dist/jwt.d.mts +36 -0
- package/dist/jwt.mjs +48 -0
- package/package.json +30 -12
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
|
-
|
|
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
|
|
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
|
|
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
|
|
89
|
-
with `submit()` to hold a unit open across a drain,
|
|
90
|
-
|
|
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
|
|
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
|
|
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: (
|
|
188
|
-
|
|
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 (
|
|
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
|
|
213
|
+
const unit = options.unit;
|
|
214
|
+
const result = host.run({
|
|
210
215
|
kind: "test",
|
|
211
216
|
id: `${submitted}`
|
|
212
|
-
}, (
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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: (
|
|
187
|
-
|
|
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 (
|
|
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
|
|
212
|
+
const unit = options.unit;
|
|
213
|
+
const result = host.run({
|
|
209
214
|
kind: "test",
|
|
210
215
|
id: `${submitted}`
|
|
211
|
-
}, (
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
-
"@btravstack/core": "0.
|
|
57
|
-
"@btravstack/di": "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.
|
|
62
|
-
"@btravstack/core": "^0.
|
|
63
|
-
"@btravstack/di": "^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
|
-
"
|
|
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
|
}
|