@prosopo/api-express-router 3.1.51 → 3.1.53

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 (39) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -2
  2. package/.turbo/turbo-build$colon$tsc.log +21 -21
  3. package/.turbo/turbo-build.log +3 -3
  4. package/CHANGELOG.md +11 -0
  5. package/dist/tests/apiExpressRouter.test-d.d.ts +2 -0
  6. package/dist/tests/apiExpressRouter.test-d.d.ts.map +1 -0
  7. package/dist/tests/apiExpressRouter.test-d.js +76 -0
  8. package/dist/tests/apiExpressRouter.test-d.js.map +1 -0
  9. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.d.ts +2 -0
  10. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.d.ts.map +1 -0
  11. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.js +197 -0
  12. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.js.map +1 -0
  13. package/dist/tests/unit/apiExpressRouterFactory.unit.test.d.ts +2 -0
  14. package/dist/tests/unit/apiExpressRouterFactory.unit.test.d.ts.map +1 -0
  15. package/dist/tests/unit/apiExpressRouterFactory.unit.test.js +108 -0
  16. package/dist/tests/unit/apiExpressRouterFactory.unit.test.js.map +1 -0
  17. package/dist/tests/unit/index.unit.test.d.ts +2 -0
  18. package/dist/tests/unit/index.unit.test.d.ts.map +1 -0
  19. package/dist/tests/unit/index.unit.test.js +44 -0
  20. package/dist/tests/unit/index.unit.test.js.map +1 -0
  21. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js +210 -136
  22. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js.map +1 -1
  23. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.d.ts +2 -0
  24. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.d.ts.map +1 -0
  25. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.js +242 -0
  26. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.js.map +1 -0
  27. package/dist/tests/unit/testDoubles.d.ts +8 -0
  28. package/dist/tests/unit/testDoubles.d.ts.map +1 -0
  29. package/dist/tests/unit/testDoubles.js +11 -0
  30. package/dist/tests/unit/testDoubles.js.map +1 -0
  31. package/package.json +2 -2
  32. package/src/tests/apiExpressRouter.test-d.ts +190 -0
  33. package/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts +395 -0
  34. package/src/tests/unit/apiExpressRouterFactory.unit.test.ts +215 -0
  35. package/src/tests/unit/index.unit.test.ts +95 -0
  36. package/src/tests/unit/middlewares/authMiddleware.unit.test.ts +318 -159
  37. package/src/tests/unit/middlewares/requestLoggerMiddleware.unit.test.ts +368 -0
  38. package/src/tests/unit/testDoubles.ts +39 -0
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,368 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { EventEmitter } from "node:events";
16
+ import type { IncomingHttpHeaders } from "node:http";
17
+ import type { ProviderEnvironment } from "@prosopo/env";
18
+ import type { Logger } from "@prosopo/logger";
19
+ import type { Request, Response } from "express";
20
+ import { beforeEach, describe, expect, test, vi } from "vitest";
21
+ import { requestLoggerMiddleware } from "../../../middlewares/requestLoggerMiddleware.js";
22
+ import { type NextCapture, captureNext } from "../testDoubles.js";
23
+
24
+ /**
25
+ * This middleware is the only thing that gives a request an identity, so what
26
+ * matters is that the id is stable across the request, that it is reused when an
27
+ * upstream proxy already assigned one, and that exactly one "Response sent" line
28
+ * is emitted no matter which express event fires.
29
+ */
30
+
31
+ interface LogEntry {
32
+ msg?: string;
33
+ data?: Record<string, unknown>;
34
+ }
35
+
36
+ /** The `.with()` bindings and every info() payload the middleware produced. */
37
+ interface Recorded {
38
+ bindings: Record<string, unknown>;
39
+ infos: LogEntry[];
40
+ }
41
+
42
+ const recorded: Recorded = { bindings: {}, infos: [] };
43
+
44
+ const { getLoggerMock } = vi.hoisted(() => ({
45
+ getLoggerMock: vi.fn(),
46
+ }));
47
+
48
+ vi.mock("@prosopo/logger", async () => {
49
+ const actual =
50
+ await vi.importActual<typeof import("@prosopo/logger")>("@prosopo/logger");
51
+ return { ...actual, getLogger: getLoggerMock };
52
+ });
53
+
54
+ const makeLogger = (): Logger => {
55
+ const logger = {
56
+ with: (bindings: Record<string, unknown>): Logger => {
57
+ recorded.bindings = bindings;
58
+ return logger;
59
+ },
60
+ info: (entry: () => LogEntry): void => {
61
+ recorded.infos.push(entry());
62
+ },
63
+ error: vi.fn<(entry: () => LogEntry) => void>(),
64
+ debug: vi.fn<(entry: () => LogEntry) => void>(),
65
+ warn: vi.fn<(entry: () => LogEntry) => void>(),
66
+ } as unknown as Logger;
67
+ return logger;
68
+ };
69
+
70
+ const env = { config: { logLevel: "info" } } as unknown as ProviderEnvironment;
71
+
72
+ interface Harness {
73
+ req: Request;
74
+ res: Response & EventEmitter;
75
+ next: NextCapture;
76
+ setHeader: ReturnType<
77
+ typeof vi.fn<(name: string, value: string) => Response>
78
+ >;
79
+ }
80
+
81
+ const build = (
82
+ overrides: {
83
+ headers?: IncomingHttpHeaders;
84
+ body?: unknown;
85
+ path?: string;
86
+ method?: string;
87
+ statusCode?: number;
88
+ } = {},
89
+ ): Harness => {
90
+ const emitter = new EventEmitter();
91
+ const setHeader = vi.fn<(name: string, value: string) => Response>();
92
+ const res = Object.assign(emitter, {
93
+ setHeader,
94
+ statusCode: overrides.statusCode ?? 200,
95
+ }) as unknown as Response & EventEmitter;
96
+
97
+ const req = {
98
+ headers: overrides.headers ?? {},
99
+ body: overrides.body,
100
+ path: overrides.path ?? "/verify",
101
+ method: overrides.method ?? "POST",
102
+ } as unknown as Request;
103
+
104
+ return { req, res, next: captureNext(), setHeader };
105
+ };
106
+
107
+ const run = (harness: Harness): void => {
108
+ requestLoggerMiddleware(env)(harness.req, harness.res, harness.next.fn);
109
+ };
110
+
111
+ beforeEach(() => {
112
+ recorded.bindings = {};
113
+ recorded.infos = [];
114
+ getLoggerMock.mockReset();
115
+ getLoggerMock.mockImplementation(() => makeLogger());
116
+ });
117
+
118
+ describe("the request id", () => {
119
+ test("reuses the id an upstream proxy already assigned", () => {
120
+ // Caddy stamps x-request-id; reusing it is what lets a proxy log line and
121
+ // an app log line be joined together.
122
+ const harness = build({ headers: { "x-request-id": "caddy-123" } });
123
+ run(harness);
124
+ expect(harness.req.requestId).toBe("caddy-123");
125
+ expect(recorded.bindings.requestId).toBe("caddy-123");
126
+ });
127
+
128
+ test("generates an e-prefixed uuid when there is no inbound id", () => {
129
+ const harness = build();
130
+ run(harness);
131
+ expect(harness.req.requestId).toMatch(
132
+ /^e-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
133
+ );
134
+ });
135
+
136
+ test("generates a fresh id per request", () => {
137
+ const first = build();
138
+ const second = build();
139
+ run(first);
140
+ run(second);
141
+ expect(first.req.requestId).not.toBe(second.req.requestId);
142
+ });
143
+
144
+ test("falls back for an empty inbound id rather than using a blank one", () => {
145
+ // A blank header is worse than no header: it would group every such
146
+ // request under the same empty id.
147
+ const harness = build({ headers: { "x-request-id": "" } });
148
+ run(harness);
149
+ expect(harness.req.requestId).toMatch(/^e-/);
150
+ });
151
+
152
+ test("mirrors the id back onto the response", () => {
153
+ const harness = build({ headers: { "x-request-id": "caddy-123" } });
154
+ run(harness);
155
+ expect(harness.setHeader).toHaveBeenCalledWith("x-request-id", "caddy-123");
156
+ });
157
+
158
+ test("mirrors the generated id too, not just an inbound one", () => {
159
+ const harness = build();
160
+ run(harness);
161
+ expect(harness.setHeader).toHaveBeenCalledWith(
162
+ "x-request-id",
163
+ harness.req.requestId,
164
+ );
165
+ });
166
+ });
167
+
168
+ describe("the log bindings", () => {
169
+ test("carry the user and site key headers when present", () => {
170
+ const harness = build({
171
+ headers: { "prosopo-user": "alice", "prosopo-site-key": "site-1" },
172
+ });
173
+ run(harness);
174
+ expect(recorded.bindings.user).toBe("alice");
175
+ expect(recorded.bindings.siteKey).toBe("site-1");
176
+ });
177
+
178
+ test("omit absent fields entirely rather than binding undefined", () => {
179
+ // A bound `user: undefined` would show up as a field in every log line
180
+ // for anonymous traffic.
181
+ const harness = build();
182
+ run(harness);
183
+ expect(Object.keys(recorded.bindings)).toEqual(["requestId"]);
184
+ });
185
+
186
+ test("omit headers present but empty", () => {
187
+ const harness = build({
188
+ headers: { "prosopo-user": "", "prosopo-site-key": "" },
189
+ });
190
+ run(harness);
191
+ expect(Object.keys(recorded.bindings)).toEqual(["requestId"]);
192
+ });
193
+
194
+ test("collapse a repeated header to a single string", () => {
195
+ // Node hands back an array when a header appears twice; binding the array
196
+ // itself would break the log schema.
197
+ const harness = build({
198
+ headers: { "prosopo-user": ["alice", "bob"] as unknown as string },
199
+ });
200
+ run(harness);
201
+ expect(recorded.bindings.user).toBe("alice,bob");
202
+ });
203
+
204
+ test("carry the session id off the request body", () => {
205
+ const harness = build({ body: { sessionId: "sess-9" } });
206
+ run(harness);
207
+ expect(recorded.bindings.sessionId).toBe("sess-9");
208
+ });
209
+
210
+ test("tolerate a missing body", () => {
211
+ // Body parsing is mounted per-route, so the middleware can run before a
212
+ // body exists at all.
213
+ const harness = build({ body: undefined });
214
+ expect(() => run(harness)).not.toThrow();
215
+ expect(recorded.bindings.sessionId).toBeUndefined();
216
+ });
217
+
218
+ test("tolerate a body that is not an object", () => {
219
+ const harness = build({ body: "raw text" });
220
+ expect(() => run(harness)).not.toThrow();
221
+ expect(recorded.bindings.sessionId).toBeUndefined();
222
+ });
223
+
224
+ test("omit an empty session id", () => {
225
+ const harness = build({ body: { sessionId: "" } });
226
+ run(harness);
227
+ expect(recorded.bindings.sessionId).toBeUndefined();
228
+ });
229
+
230
+ test("attach the bound logger to the request for handlers downstream", () => {
231
+ const harness = build();
232
+ run(harness);
233
+ expect(harness.req.logger).toBeDefined();
234
+ });
235
+ });
236
+
237
+ describe("envelope logging", () => {
238
+ test("logs a request-received line before calling next", () => {
239
+ const harness = build({ path: "/verify", method: "POST" });
240
+ run(harness);
241
+ expect(recorded.infos[0]).toEqual({
242
+ msg: "Request received",
243
+ data: { method: "POST", path: "/verify" },
244
+ });
245
+ expect(harness.next.calls).toHaveLength(1);
246
+ });
247
+
248
+ test("logs a response-sent line with status and outcome when finish fires", () => {
249
+ const harness = build({ statusCode: 201 });
250
+ run(harness);
251
+ harness.res.emit("finish");
252
+
253
+ const sent = recorded.infos[1];
254
+ expect(sent?.msg).toBe("Response sent");
255
+ expect(sent?.data?.status).toBe(201);
256
+ expect(sent?.data?.outcome).toBe("finish");
257
+ expect(typeof sent?.data?.durationMs).toBe("number");
258
+ });
259
+
260
+ test("records a client disconnect as a close outcome", () => {
261
+ const harness = build();
262
+ run(harness);
263
+ harness.res.emit("close");
264
+ expect(recorded.infos[1]?.data?.outcome).toBe("close");
265
+ });
266
+
267
+ test("logs the response only once when both events fire", () => {
268
+ // Express emits close after finish on a normal response, so without the
269
+ // guard every request would be counted twice.
270
+ const harness = build();
271
+ run(harness);
272
+ harness.res.emit("finish");
273
+ harness.res.emit("close");
274
+ expect(
275
+ recorded.infos.filter((e) => e.msg === "Response sent"),
276
+ ).toHaveLength(1);
277
+ });
278
+
279
+ test("keeps the first outcome when close follows finish", () => {
280
+ const harness = build();
281
+ run(harness);
282
+ harness.res.emit("finish");
283
+ harness.res.emit("close");
284
+ expect(recorded.infos[1]?.data?.outcome).toBe("finish");
285
+ });
286
+
287
+ test("reads the status at emit time, not at middleware time", () => {
288
+ // The handler sets the status after this middleware has already run, so a
289
+ // value captured up front would always read 200.
290
+ const harness = build({ statusCode: 200 });
291
+ run(harness);
292
+ harness.res.statusCode = 500;
293
+ harness.res.emit("finish");
294
+ expect(recorded.infos[1]?.data?.status).toBe(500);
295
+ });
296
+
297
+ test("reports a non-negative duration", () => {
298
+ const harness = build();
299
+ run(harness);
300
+ harness.res.emit("finish");
301
+ expect(recorded.infos[1]?.data?.durationMs).toBeGreaterThanOrEqual(0);
302
+ });
303
+ });
304
+
305
+ describe("health probes", () => {
306
+ test.each(["/healthz", "/health", "/readyz"])(
307
+ "%s is not envelope-logged",
308
+ (path: string) => {
309
+ // These are polled continuously; logging them would bury real traffic.
310
+ const harness = build({ path });
311
+ run(harness);
312
+ harness.res.emit("finish");
313
+ expect(recorded.infos).toEqual([]);
314
+ expect(harness.next.calls).toHaveLength(1);
315
+ },
316
+ );
317
+
318
+ test("a health probe still gets a request id and a logger", () => {
319
+ const harness = build({ path: "/healthz" });
320
+ run(harness);
321
+ expect(harness.req.requestId).toMatch(/^e-/);
322
+ expect(harness.req.logger).toBeDefined();
323
+ expect(harness.setHeader).toHaveBeenCalledWith(
324
+ "x-request-id",
325
+ harness.req.requestId,
326
+ );
327
+ });
328
+
329
+ test("a path that merely looks like a probe is still logged", () => {
330
+ // The match is exact, so nothing under a probe-like prefix is silently
331
+ // dropped from the logs.
332
+ const harness = build({ path: "/healthz/deep" });
333
+ run(harness);
334
+ expect(recorded.infos[0]?.msg).toBe("Request received");
335
+ });
336
+
337
+ test("registers no response listeners for a probe", () => {
338
+ const harness = build({ path: "/healthz" });
339
+ run(harness);
340
+ expect(harness.res.listenerCount("finish")).toBe(0);
341
+ expect(harness.res.listenerCount("close")).toBe(0);
342
+ });
343
+ });
344
+
345
+ describe("the environment it is built from", () => {
346
+ test("builds its logger under the provider:request scope at the configured level", () => {
347
+ const harness = build();
348
+ run(harness);
349
+ expect(getLoggerMock).toHaveBeenCalledWith("info", "provider:request");
350
+ });
351
+
352
+ test("reads the log level from the environment on every request", () => {
353
+ // The middleware is built once at start-up but the level is read per
354
+ // request, so a config change takes effect without a restart.
355
+ const mutable = {
356
+ config: { logLevel: "info" },
357
+ } as unknown as ProviderEnvironment;
358
+ const middleware = requestLoggerMiddleware(mutable);
359
+ const first = build();
360
+ middleware(first.req, first.res, first.next.fn);
361
+
362
+ mutable.config.logLevel = "debug";
363
+ const second = build();
364
+ middleware(second.req, second.res, second.next.fn);
365
+
366
+ expect(getLoggerMock).toHaveBeenLastCalledWith("debug", "provider:request");
367
+ });
368
+ });
@@ -0,0 +1,39 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { NextFunction } from "express";
16
+
17
+ /**
18
+ * A recording stand-in for express's `next`.
19
+ *
20
+ * `vi.fn<NextFunction>()` cannot be passed where a `NextFunction` is wanted —
21
+ * the type is overloaded and Mock collapses it to a single signature — so the
22
+ * spy is a plain closure that records its arguments instead.
23
+ */
24
+ interface NextCapture {
25
+ fn: NextFunction;
26
+ calls: unknown[][];
27
+ }
28
+
29
+ const captureNext = (): NextCapture => {
30
+ const calls: unknown[][] = [];
31
+ return {
32
+ fn: (...args: unknown[]): void => {
33
+ calls.push(args);
34
+ },
35
+ calls,
36
+ };
37
+ };
38
+
39
+ export { captureNext, type NextCapture };