@terreno/api 0.20.2 → 0.22.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 (107) hide show
  1. package/.ai/guidelines/core.md +71 -0
  2. package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
  3. package/README.md +54 -1
  4. package/bunfig.toml +1 -1
  5. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  6. package/dist/actions.openApi.test.js +13 -11
  7. package/dist/api.js +98 -11
  8. package/dist/api.query.test.js +31 -1
  9. package/dist/api.test.js +211 -0
  10. package/dist/auth.test.js +418 -43
  11. package/dist/betterAuth.d.ts +1 -1
  12. package/dist/consentApp.test.js +1 -0
  13. package/dist/example.js +4 -4
  14. package/dist/expressServer.d.ts +0 -22
  15. package/dist/expressServer.js +1 -125
  16. package/dist/expressServer.test.js +90 -91
  17. package/dist/githubAuth.test.js +22 -22
  18. package/dist/logger.d.ts +154 -0
  19. package/dist/logger.js +445 -26
  20. package/dist/logger.test.js +435 -0
  21. package/dist/middleware.d.ts +7 -0
  22. package/dist/middleware.js +58 -1
  23. package/dist/middleware.test.js +159 -0
  24. package/dist/models/consentForm.js +2 -1
  25. package/dist/models/consentResponse.js +2 -1
  26. package/dist/models/versionConfig.js +2 -1
  27. package/dist/openApi.test.js +10 -17
  28. package/dist/openApiBuilder.d.ts +18 -0
  29. package/dist/openApiBuilder.js +21 -0
  30. package/dist/openApiBuilder.test.js +34 -10
  31. package/dist/permissions.test.js +10 -43
  32. package/dist/populate.test.js +10 -42
  33. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  34. package/dist/realtime/changeStreamWatcher.js +2 -4
  35. package/dist/realtime/queryMatcher.d.ts +1 -1
  36. package/dist/realtime/queryMatcher.js +39 -14
  37. package/dist/realtime/types.d.ts +3 -3
  38. package/dist/requestContext.d.ts +61 -0
  39. package/dist/requestContext.js +74 -0
  40. package/dist/secretProviders.test.js +335 -0
  41. package/dist/syncConsents.test.js +2 -2
  42. package/dist/terrenoApp.d.ts +27 -15
  43. package/dist/terrenoApp.js +24 -14
  44. package/dist/terrenoApp.test.js +52 -0
  45. package/dist/tests/bunSetup.js +66 -262
  46. package/dist/tests/createTestData.d.ts +9 -0
  47. package/dist/tests/createTestData.js +272 -0
  48. package/dist/tests/models.d.ts +71 -0
  49. package/dist/tests/models.js +134 -0
  50. package/dist/tests/mongoTestSetup.d.ts +7 -0
  51. package/dist/tests/mongoTestSetup.js +150 -0
  52. package/dist/tests/testEnv.d.ts +0 -0
  53. package/dist/tests/testEnv.js +6 -0
  54. package/dist/tests/testHelper.d.ts +22 -0
  55. package/dist/tests/testHelper.js +115 -0
  56. package/dist/tests/types.d.ts +29 -0
  57. package/dist/tests/types.js +2 -0
  58. package/dist/tests.d.ts +10 -78
  59. package/dist/tests.js +24 -241
  60. package/dist/transformers.test.js +14 -50
  61. package/package.json +18 -4
  62. package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
  63. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  64. package/src/actions.openApi.test.ts +12 -10
  65. package/src/api.query.test.ts +24 -1
  66. package/src/api.test.ts +169 -0
  67. package/src/api.ts +71 -0
  68. package/src/auth.test.ts +287 -39
  69. package/src/betterAuth.ts +1 -1
  70. package/src/consentApp.test.ts +1 -0
  71. package/src/example.ts +4 -4
  72. package/src/expressServer.test.ts +82 -85
  73. package/src/expressServer.ts +1 -213
  74. package/src/githubAuth.test.ts +22 -22
  75. package/src/logger.test.ts +466 -1
  76. package/src/logger.ts +477 -14
  77. package/src/middleware.test.ts +74 -2
  78. package/src/middleware.ts +57 -0
  79. package/src/models/consentForm.ts +3 -4
  80. package/src/models/consentResponse.ts +6 -4
  81. package/src/models/versionConfig.ts +3 -4
  82. package/src/openApi.test.ts +10 -17
  83. package/src/openApiBuilder.test.ts +27 -10
  84. package/src/openApiBuilder.ts +24 -0
  85. package/src/permissions.test.ts +8 -23
  86. package/src/populate.test.ts +7 -22
  87. package/src/realtime/changeStreamWatcher.ts +15 -10
  88. package/src/realtime/queryMatcher.ts +54 -27
  89. package/src/realtime/types.ts +4 -4
  90. package/src/requestContext.ts +86 -0
  91. package/src/secretProviders.test.ts +219 -1
  92. package/src/syncConsents.test.ts +1 -1
  93. package/src/terrenoApp.test.ts +38 -0
  94. package/src/terrenoApp.ts +37 -15
  95. package/src/tests/bunSetup.ts +22 -236
  96. package/src/tests/createTestData.ts +176 -0
  97. package/src/tests/models.ts +164 -0
  98. package/src/tests/mongoTestSetup.ts +69 -0
  99. package/src/tests/testEnv.ts +4 -0
  100. package/src/tests/testHelper.ts +57 -0
  101. package/src/tests/types.ts +35 -0
  102. package/src/tests.ts +40 -231
  103. package/src/transformers.test.ts +11 -30
  104. package/tsconfig.typedoc.json +4 -0
  105. package/dist/tests/index.d.ts +0 -1
  106. package/dist/tests/index.js +0 -17
  107. package/src/tests/index.ts +0 -1
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: MongoDB query matcher evaluates dynamic filter shapes
2
1
  /**
3
2
  * Simple in-memory MongoDB query matcher.
4
3
  * Evaluates a MongoDB-style query object against a document without hitting the database.
@@ -6,35 +5,52 @@
6
5
  * Supports: equality, $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, $not.
7
6
  */
8
7
 
9
- const getNestedValue = (doc: any, path: string): any => {
8
+ const getNestedValue = (doc: Record<string, unknown>, path: string): unknown => {
10
9
  const parts = path.split(".");
11
- let current = doc;
10
+ let current: unknown = doc;
12
11
  for (const part of parts) {
13
12
  if (current === null || current === undefined) {
14
13
  return undefined;
15
14
  }
16
- current = current[part];
15
+ current = (current as Record<string, unknown>)[part];
17
16
  }
18
17
  return current;
19
18
  };
20
19
 
21
- const normalize = (value: any): any => {
20
+ const normalize = (value: unknown): unknown => {
22
21
  if (value === null || value === undefined) {
23
22
  return value;
24
23
  }
25
24
  // Handle ObjectId-like objects with toString
26
- if (
27
- typeof value === "object" &&
28
- typeof value.toString === "function" &&
29
- value.constructor?.name !== "Object" &&
30
- !Array.isArray(value)
31
- ) {
32
- return value.toString();
25
+ if (typeof value === "object" && !Array.isArray(value)) {
26
+ const obj = value as Record<string, unknown>;
27
+ const ctorName = (obj.constructor as {name?: string} | undefined)?.name;
28
+ if (typeof obj.toString === "function" && ctorName !== "Object") {
29
+ return String(value);
30
+ }
33
31
  }
34
32
  return value;
35
33
  };
36
34
 
37
- const matchesCondition = (rawValue: any, condition: any): boolean => {
35
+ /**
36
+ * JS abstract relational comparison on unknown values.
37
+ * Numeric operands compare numerically; everything else compares as strings.
38
+ * This mirrors the coercion behaviour of `>` / `<` on the `any`-typed values
39
+ * that MongoDB in-memory matching historically received.
40
+ */
41
+ const compareValues = (a: unknown, b: unknown): number => {
42
+ if (typeof a === "number" && typeof b === "number") {
43
+ return a - b;
44
+ }
45
+ if (typeof a === "string" && typeof b === "string") {
46
+ return a < b ? -1 : a > b ? 1 : 0;
47
+ }
48
+ const numA = Number(a);
49
+ const numB = Number(b);
50
+ return numA - numB;
51
+ };
52
+
53
+ const matchesCondition = (rawValue: unknown, condition: unknown): boolean => {
38
54
  const value = normalize(rawValue);
39
55
 
40
56
  // Direct equality (non-object condition)
@@ -49,7 +65,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
49
65
  }
50
66
 
51
67
  // Operator object
52
- for (const [op, operand] of Object.entries(condition)) {
68
+ for (const [op, operand] of Object.entries(condition as Record<string, unknown>)) {
53
69
  const normOp = normalize(operand);
54
70
 
55
71
  switch (op) {
@@ -63,32 +79,40 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
63
79
  return false;
64
80
  }
65
81
  break;
66
- case "$gt":
67
- if (!(value > normOp)) {
82
+ case "$gt": {
83
+ const cmp = compareValues(value, normOp);
84
+ if (Number.isNaN(cmp) || cmp <= 0) {
68
85
  return false;
69
86
  }
70
87
  break;
71
- case "$gte":
72
- if (!(value >= normOp)) {
88
+ }
89
+ case "$gte": {
90
+ const cmp = compareValues(value, normOp);
91
+ if (Number.isNaN(cmp) || cmp < 0) {
73
92
  return false;
74
93
  }
75
94
  break;
76
- case "$lt":
77
- if (!(value < normOp)) {
95
+ }
96
+ case "$lt": {
97
+ const cmp = compareValues(value, normOp);
98
+ if (Number.isNaN(cmp) || cmp >= 0) {
78
99
  return false;
79
100
  }
80
101
  break;
81
- case "$lte":
82
- if (!(value <= normOp)) {
102
+ }
103
+ case "$lte": {
104
+ const cmp = compareValues(value, normOp);
105
+ if (Number.isNaN(cmp) || cmp > 0) {
83
106
  return false;
84
107
  }
85
108
  break;
109
+ }
86
110
  case "$in": {
87
111
  if (!Array.isArray(operand)) {
88
112
  return false;
89
113
  }
90
114
  const inValues = operand.map(normalize);
91
- if (!inValues.some((v: any) => v === value || String(v) === String(value))) {
115
+ if (!inValues.some((v) => v === value || String(v) === String(value))) {
92
116
  return false;
93
117
  }
94
118
  break;
@@ -98,7 +122,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
98
122
  return false;
99
123
  }
100
124
  const ninValues = operand.map(normalize);
101
- if (ninValues.some((v: any) => v === value || String(v) === String(value))) {
125
+ if (ninValues.some((v) => v === value || String(v) === String(value))) {
102
126
  return false;
103
127
  }
104
128
  break;
@@ -132,14 +156,17 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
132
156
  * @param query - MongoDB-style query object
133
157
  * @returns true if the document matches all query conditions
134
158
  */
135
- export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
159
+ export const matchesQuery = (
160
+ doc: Record<string, unknown>,
161
+ query: Record<string, unknown>
162
+ ): boolean => {
136
163
  for (const [key, condition] of Object.entries(query)) {
137
164
  if (key === "$and") {
138
165
  if (!Array.isArray(condition)) {
139
166
  return false;
140
167
  }
141
168
  for (const subQuery of condition) {
142
- if (!matchesQuery(doc, subQuery)) {
169
+ if (!matchesQuery(doc, subQuery as Record<string, unknown>)) {
143
170
  return false;
144
171
  }
145
172
  }
@@ -152,7 +179,7 @@ export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
152
179
  }
153
180
  let matched = false;
154
181
  for (const subQuery of condition) {
155
- if (matchesQuery(doc, subQuery)) {
182
+ if (matchesQuery(doc, subQuery as Record<string, unknown>)) {
156
183
  matched = true;
157
184
  break;
158
185
  }
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: realtime config callbacks receive dynamic document shapes
2
1
  import type express from "express";
3
2
 
4
3
  /**
@@ -19,9 +18,9 @@ export interface RealtimeConfig {
19
18
  | "owner"
20
19
  | "model"
21
20
  | "broadcast"
22
- | ((doc: any, method: string, req: express.Request) => string[]);
21
+ | ((doc: Record<string, unknown>, method: string, req: express.Request) => string[]);
23
22
  /** Custom serializer for real-time events. Falls back to the modelRouter responseHandler. */
24
- realtimeResponseHandler?: (doc: any, method: string) => any;
23
+ realtimeResponseHandler?: (doc: Record<string, unknown>, method: string) => unknown;
25
24
  }
26
25
 
27
26
  /**
@@ -37,6 +36,7 @@ export interface RealtimeEvent {
37
36
  /** Document ID */
38
37
  id: string;
39
38
  /** Serialized document data (omitted for hard deletes) */
39
+ // biome-ignore lint/suspicious/noExplicitAny: noExplicitAny: event data is a serialized document whose shape varies by model; consumers must narrow to their specific type
40
40
  data?: any;
41
41
  /** Fields that were updated (for update events from change streams) */
42
42
  updatedFields?: string[];
@@ -102,7 +102,7 @@ export interface QuerySubscription {
102
102
  /** Collection tag (e.g. "todos") */
103
103
  collection: string;
104
104
  /** MongoDB-style query filter (e.g. {completed: false}) */
105
- query: Record<string, any>;
105
+ query: Record<string, unknown>;
106
106
  /** Client-provided queryId (ignored — server computes a canonical ID) */
107
107
  queryId?: string;
108
108
  }
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Request/job correlation for `@terreno/api`.
3
+ *
4
+ * Correlation is how every log line emitted while handling one request (or one background job) can
5
+ * be tied back together. It is built on Node's {@link AsyncLocalStorage}: a {@link RequestContext}
6
+ * (with `requestId`, `userId`, `traceId`, etc.) is stored for the duration of a callback, and the
7
+ * logger's Winston format reads it from there and merges it into each line. Nothing needs to be
8
+ * threaded through function arguments.
9
+ *
10
+ * Two ways a scope is established:
11
+ *
12
+ * - **HTTP**: {@link requestContextMiddleware} runs first in the middleware stack. It derives a
13
+ * `requestId` from incoming headers ({@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}, `x-correlation-id`,
14
+ * Cloud Trace, or W3C `traceparent`) or generates one, echoes it back as `X-Request-ID`, and runs
15
+ * the rest of the request inside the scope.
16
+ * - **Jobs/scripts**: {@link runWithRequestContext} (or {@link runWithRequestContextAttributes})
17
+ * establishes the same scope manually so background work is just as traceable.
18
+ *
19
+ * The active context is also pushed to Sentry tags/context via {@link applyRequestContextToSentry},
20
+ * and is exposed to logging via {@link getCurrentLogContext} / {@link getCurrentRequestContext}.
21
+ *
22
+ * @see {@link runWithRequestContext}
23
+ * @see {@link getCurrentLogContext}
24
+ * @module requestContext
25
+ */
1
26
  import {AsyncLocalStorage} from "node:async_hooks";
2
27
  import {randomUUID} from "node:crypto";
3
28
  import * as Sentry from "@sentry/bun";
@@ -14,18 +39,35 @@ const TRACE_PARENT_HEADER = "traceparent";
14
39
  const TRACE_SAMPLED_HEADER = "x-trace-sampled";
15
40
  const USER_ID_HEADER = "x-user-id";
16
41
 
42
+ /**
43
+ * Correlation fields stored in AsyncLocalStorage for the lifetime of a request or job. Every log
44
+ * line emitted inside the scope is enriched with these. `requestId` is the only required field; the
45
+ * rest are populated when headers, trace context, or auth supply them.
46
+ */
17
47
  export interface RequestContext {
48
+ /** Background job identifier (from `x-job-id` or set via {@link runWithRequestContext}). */
18
49
  jobId?: string;
50
+ /** Stable id shared by all log lines for one request/job; echoed to clients as `X-Request-ID`. */
19
51
  requestId: string;
52
+ /** Auth session id, resolved from the JWT/Better Auth session or `x-session-id`. */
20
53
  sessionId?: string;
54
+ /** Distributed-tracing span id, parsed from Cloud Trace or W3C `traceparent`. */
21
55
  spanId?: string;
56
+ /** Distributed-tracing trace id, parsed from Cloud Trace or W3C `traceparent`. */
22
57
  traceId?: string;
58
+ /** Whether the trace is sampled, per the incoming trace headers. */
23
59
  traceSampled?: boolean;
60
+ /** Authenticated user id, populated after auth middleware runs. */
24
61
  userId?: string;
25
62
  }
26
63
 
27
64
  export type RequestContextAttributes = Record<string, string>;
28
65
 
66
+ /**
67
+ * Canonical HTTP header names for each correlation field. Use these to propagate context to
68
+ * downstream services (pair with {@link getCurrentRequestContextAttributes}) or to read it from an
69
+ * incoming request (pair with {@link getRequestContextFromAttributes}).
70
+ */
29
71
  export const REQUEST_CONTEXT_ATTRIBUTE_NAMES = {
30
72
  jobId: JOB_ID_HEADER,
31
73
  requestId: "x-request-id",
@@ -161,10 +203,19 @@ export const getRequestContextFromAttributes = (
161
203
  };
162
204
  };
163
205
 
206
+ /**
207
+ * Returns the full {@link RequestContext} for the active AsyncLocalStorage scope, or `undefined`
208
+ * when called outside any request/job scope. The logger uses this to enrich each line.
209
+ */
164
210
  export const getCurrentRequestContext = (): RequestContext | undefined => {
165
211
  return requestContextStorage.getStore();
166
212
  };
167
213
 
214
+ /**
215
+ * Returns the active correlation fields as a plain object (empty when outside a scope). This is the
216
+ * shape attached to Sentry log attributes and is handy when you need to log or forward the current
217
+ * context yourself.
218
+ */
168
219
  export const getCurrentLogContext = (): Partial<RequestContext> => {
169
220
  const context = getCurrentRequestContext();
170
221
  if (!context) {
@@ -254,6 +305,11 @@ const setAttribute = (
254
305
  attributes[name] = String(value);
255
306
  };
256
307
 
308
+ /**
309
+ * Serializes the active correlation context into HTTP header attributes (keyed by
310
+ * {@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}) so it can be propagated on outbound requests to other
311
+ * services, keeping the same `requestId`/`traceId` across service boundaries.
312
+ */
257
313
  export const getCurrentRequestContextAttributes = (
258
314
  overrides: Partial<RequestContext> = {}
259
315
  ): RequestContextAttributes => {
@@ -269,6 +325,23 @@ export const getCurrentRequestContextAttributes = (
269
325
  return attributes;
270
326
  };
271
327
 
328
+ /**
329
+ * Runs `callback` inside a fresh correlation scope so every log line it emits shares the same
330
+ * identifiers — the manual equivalent of {@link requestContextMiddleware} for background jobs,
331
+ * cron tasks, scripts, queue consumers, etc. A `requestId` is generated when not supplied, and the
332
+ * context is mirrored to Sentry.
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * import {createScopedLogger, runWithRequestContext} from "@terreno/api";
337
+ *
338
+ * await runWithRequestContext({jobId: "nightly-sync"}, async () => {
339
+ * const log = createScopedLogger({prefix: "[NightlySync]"});
340
+ * log.info("started"); // includes jobId + a generated requestId on every line
341
+ * await sync();
342
+ * });
343
+ * ```
344
+ */
272
345
  export const runWithRequestContext = <T>(
273
346
  context: Partial<RequestContext>,
274
347
  callback: () => T
@@ -284,6 +357,11 @@ export const runWithRequestContext = <T>(
284
357
  });
285
358
  };
286
359
 
360
+ /**
361
+ * Like {@link runWithRequestContext}, but seeds the scope from raw header attributes (for example
362
+ * those received on an incoming message or forwarded by another service). Parses Cloud Trace / W3C
363
+ * `traceparent` into `traceId`/`spanId` via {@link getRequestContextFromAttributes}.
364
+ */
287
365
  export const runWithRequestContextAttributes = <T>(
288
366
  attributes: Record<string, string | undefined> = {},
289
367
  callback: () => T
@@ -324,6 +402,14 @@ export const updateRequestContextFromRequest = (
324
402
  }
325
403
  };
326
404
 
405
+ /**
406
+ * Express middleware that opens a correlation scope for the request. Mounted early by `TerrenoApp` /
407
+ * `setupServer`, it resolves a `requestId` (from request-id/correlation headers, Cloud Trace, or
408
+ * W3C `traceparent`, else a new UUID), captures any `jobId`/`sessionId`/trace fields, echoes
409
+ * `X-Request-ID` back to the client, and runs the remaining middleware inside the scope so all
410
+ * downstream logs are correlated. A later auth-aware pass ({@link updateRequestContextFromRequest})
411
+ * fills in `userId`/`sessionId`.
412
+ */
327
413
  export const requestContextMiddleware = (
328
414
  req: express.Request,
329
415
  res: express.Response,
@@ -1,7 +1,13 @@
1
1
  import {beforeEach, describe, expect, it} from "bun:test";
2
2
 
3
3
  import type {SecretProvider} from "./configurationPlugin";
4
- import {CachingSecretProvider, CompositeSecretProvider, EnvSecretProvider} from "./secretProviders";
4
+ import {APIError} from "./errors";
5
+ import {
6
+ CachingSecretProvider,
7
+ CompositeSecretProvider,
8
+ EnvSecretProvider,
9
+ GcpSecretProvider,
10
+ } from "./secretProviders";
5
11
 
6
12
  describe("EnvSecretProvider", () => {
7
13
  beforeEach(() => {
@@ -184,3 +190,215 @@ describe("CachingSecretProvider", () => {
184
190
  expect(calls).toBe(1);
185
191
  });
186
192
  });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // GcpSecretProvider
196
+ // ---------------------------------------------------------------------------
197
+
198
+ interface MockSecretManagerClient {
199
+ accessSecretVersion: (request: {
200
+ name: string;
201
+ }) => Promise<[{payload?: {data?: string | Uint8Array}}]>;
202
+ }
203
+
204
+ /** Inject a pre-built mock client into a GcpSecretProvider, bypassing getClient(). */
205
+ const injectClient = (provider: GcpSecretProvider, client: MockSecretManagerClient): void => {
206
+ // Bypass the private `client` field for testing — avoids the dynamic import of
207
+ // @google-cloud/secret-manager which is an optional peer dependency.
208
+ Object.defineProperty(provider, "client", {configurable: true, value: client, writable: true});
209
+ };
210
+
211
+ describe("GcpSecretProvider", () => {
212
+ it("has the name 'gcp'", () => {
213
+ const provider = new GcpSecretProvider({projectId: "my-project"});
214
+ expect(provider.name).toBe("gcp");
215
+ });
216
+
217
+ it("throws APIError when @google-cloud/secret-manager is not installed", async () => {
218
+ const provider = new GcpSecretProvider({projectId: "my-project"});
219
+ try {
220
+ await provider.getSecret("some-secret");
221
+ expect.unreachable("should have thrown");
222
+ } catch (error) {
223
+ expect(error).toBeInstanceOf(APIError);
224
+ expect((error as APIError).title).toContain(
225
+ "GcpSecretProvider requires @google-cloud/secret-manager"
226
+ );
227
+ }
228
+ });
229
+
230
+ it("resolves a short secret name to the full resource path with default version", async () => {
231
+ const calls: string[] = [];
232
+ const mockClient: MockSecretManagerClient = {
233
+ accessSecretVersion: async (req) => {
234
+ calls.push(req.name);
235
+ return [{payload: {data: "secret-value"}}];
236
+ },
237
+ };
238
+ const provider = new GcpSecretProvider({projectId: "my-project"});
239
+ injectClient(provider, mockClient);
240
+
241
+ const result = await provider.getSecret("openai-api-key");
242
+ expect(result).toBe("secret-value");
243
+ expect(calls).toEqual(["projects/my-project/secrets/openai-api-key/versions/latest"]);
244
+ });
245
+
246
+ it("resolves a short secret name with an explicit version", async () => {
247
+ const calls: string[] = [];
248
+ const mockClient: MockSecretManagerClient = {
249
+ accessSecretVersion: async (req) => {
250
+ calls.push(req.name);
251
+ return [{payload: {data: "v3-value"}}];
252
+ },
253
+ };
254
+ const provider = new GcpSecretProvider({projectId: "p"});
255
+ injectClient(provider, mockClient);
256
+
257
+ const result = await provider.getSecret("my-key", "3");
258
+ expect(result).toBe("v3-value");
259
+ expect(calls).toEqual(["projects/p/secrets/my-key/versions/3"]);
260
+ });
261
+
262
+ it("honors a full resource path that already contains /versions/", async () => {
263
+ const calls: string[] = [];
264
+ const mockClient: MockSecretManagerClient = {
265
+ accessSecretVersion: async (req) => {
266
+ calls.push(req.name);
267
+ return [{payload: {data: "pinned"}}];
268
+ },
269
+ };
270
+ const provider = new GcpSecretProvider({projectId: "ignored"});
271
+ injectClient(provider, mockClient);
272
+
273
+ const result = await provider.getSecret("projects/p/secrets/s/versions/7");
274
+ expect(result).toBe("pinned");
275
+ expect(calls).toEqual(["projects/p/secrets/s/versions/7"]);
276
+ });
277
+
278
+ it("appends /versions/latest to a full resource path without a version suffix", async () => {
279
+ const calls: string[] = [];
280
+ const mockClient: MockSecretManagerClient = {
281
+ accessSecretVersion: async (req) => {
282
+ calls.push(req.name);
283
+ return [{payload: {data: "latest-value"}}];
284
+ },
285
+ };
286
+ const provider = new GcpSecretProvider({projectId: "ignored"});
287
+ injectClient(provider, mockClient);
288
+
289
+ const result = await provider.getSecret("projects/p/secrets/s");
290
+ expect(result).toBe("latest-value");
291
+ expect(calls).toEqual(["projects/p/secrets/s/versions/latest"]);
292
+ });
293
+
294
+ it("appends the explicit version when full path lacks /versions/", async () => {
295
+ const calls: string[] = [];
296
+ const mockClient: MockSecretManagerClient = {
297
+ accessSecretVersion: async (req) => {
298
+ calls.push(req.name);
299
+ return [{payload: {data: "v5"}}];
300
+ },
301
+ };
302
+ const provider = new GcpSecretProvider({projectId: "ignored"});
303
+ injectClient(provider, mockClient);
304
+
305
+ const result = await provider.getSecret("projects/p/secrets/s", "5");
306
+ expect(result).toBe("v5");
307
+ expect(calls).toEqual(["projects/p/secrets/s/versions/5"]);
308
+ });
309
+
310
+ it("decodes a Uint8Array payload", async () => {
311
+ const encoded = new TextEncoder().encode("binary-secret");
312
+ const mockClient: MockSecretManagerClient = {
313
+ accessSecretVersion: async () => [{payload: {data: encoded}}],
314
+ };
315
+ const provider = new GcpSecretProvider({projectId: "p"});
316
+ injectClient(provider, mockClient);
317
+
318
+ expect(await provider.getSecret("bin-key")).toBe("binary-secret");
319
+ });
320
+
321
+ it("returns null when the payload is empty", async () => {
322
+ const mockClient: MockSecretManagerClient = {
323
+ accessSecretVersion: async () => [{payload: {}}],
324
+ };
325
+ const provider = new GcpSecretProvider({projectId: "p"});
326
+ injectClient(provider, mockClient);
327
+
328
+ expect(await provider.getSecret("empty-payload")).toBeNull();
329
+ });
330
+
331
+ it("returns null when the payload field is missing entirely", async () => {
332
+ const mockClient: MockSecretManagerClient = {
333
+ accessSecretVersion: async () => [{}],
334
+ };
335
+ const provider = new GcpSecretProvider({projectId: "p"});
336
+ injectClient(provider, mockClient);
337
+
338
+ expect(await provider.getSecret("no-payload")).toBeNull();
339
+ });
340
+
341
+ it("returns null on NOT_FOUND (gRPC code 5)", async () => {
342
+ const notFound = Object.assign(new Error("NOT_FOUND"), {code: 5});
343
+ const mockClient: MockSecretManagerClient = {
344
+ accessSecretVersion: async () => {
345
+ throw notFound;
346
+ },
347
+ };
348
+ const provider = new GcpSecretProvider({projectId: "p"});
349
+ injectClient(provider, mockClient);
350
+
351
+ expect(await provider.getSecret("missing-secret")).toBeNull();
352
+ });
353
+
354
+ it("re-throws non-NOT_FOUND errors", async () => {
355
+ const permissionDenied = Object.assign(new Error("PERMISSION_DENIED"), {code: 7});
356
+ const mockClient: MockSecretManagerClient = {
357
+ accessSecretVersion: async () => {
358
+ throw permissionDenied;
359
+ },
360
+ };
361
+ const provider = new GcpSecretProvider({projectId: "p"});
362
+ injectClient(provider, mockClient);
363
+
364
+ try {
365
+ await provider.getSecret("forbidden-secret");
366
+ expect.unreachable("should have thrown");
367
+ } catch (error) {
368
+ expect(error).toBe(permissionDenied);
369
+ }
370
+ });
371
+
372
+ it("re-throws non-Error throwables", async () => {
373
+ const mockClient: MockSecretManagerClient = {
374
+ accessSecretVersion: async () => {
375
+ throw "string-error";
376
+ },
377
+ };
378
+ const provider = new GcpSecretProvider({projectId: "p"});
379
+ injectClient(provider, mockClient);
380
+
381
+ try {
382
+ await provider.getSecret("x");
383
+ expect.unreachable("should have thrown");
384
+ } catch (error) {
385
+ expect(error).toBe("string-error");
386
+ }
387
+ });
388
+
389
+ it("caches the client across multiple getSecret calls", async () => {
390
+ let callCount = 0;
391
+ const mockClient: MockSecretManagerClient = {
392
+ accessSecretVersion: async () => {
393
+ callCount++;
394
+ return [{payload: {data: `call-${callCount}`}}];
395
+ },
396
+ };
397
+ const provider = new GcpSecretProvider({projectId: "p"});
398
+ injectClient(provider, mockClient);
399
+
400
+ expect(await provider.getSecret("a")).toBe("call-1");
401
+ expect(await provider.getSecret("b")).toBe("call-2");
402
+ expect(callCount).toBe(2);
403
+ });
404
+ });
@@ -2,7 +2,7 @@ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
2
  import {ConsentForm} from "./models/consentForm";
3
3
  import type {ConsentFormDefinition} from "./syncConsents";
4
4
  import {syncConsents} from "./syncConsents";
5
- import {setupDb} from "./tests";
5
+ import {setupDb} from "./tests/testHelper";
6
6
 
7
7
  const baseDef: ConsentFormDefinition = {
8
8
  content: {en: "# Terms\nPlease agree."},
@@ -6,6 +6,7 @@ import supertest from "supertest";
6
6
  import {modelRouter} from "./api";
7
7
  import type {UserModel as UserModelType} from "./auth";
8
8
  import {configurationPlugin} from "./configurationPlugin";
9
+ import {APIError} from "./errors";
9
10
  import {Permissions} from "./permissions";
10
11
  import {createdUpdatedPlugin} from "./plugins";
11
12
  import {TerrenoApp} from "./terrenoApp";
@@ -42,6 +43,17 @@ describe("TerrenoApp", () => {
42
43
  expect(app).toBeDefined();
43
44
  });
44
45
 
46
+ it("does not add requestId to GET /openapi.json document bodies", async () => {
47
+ const app = new TerrenoApp({
48
+ skipListen: true,
49
+ userModel: typedUserModel,
50
+ }).build();
51
+
52
+ const res = await supertest(app).get("/openapi.json").expect(200);
53
+ expect(res.body.openapi).toBe("3.0.0");
54
+ expect(res.body.requestId).toBeUndefined();
55
+ });
56
+
45
57
  it("creates server with custom corsOrigin", () => {
46
58
  const app = new TerrenoApp({
47
59
  corsOrigin: "https://example.com",
@@ -105,6 +117,7 @@ describe("TerrenoApp", () => {
105
117
  const res = await agent.get("/food").expect(200);
106
118
  expect(res.body.data).toHaveLength(1);
107
119
  expect(res.body.data[0].name).toBe("Apple");
120
+ expect(res.body.requestId).toBe(res.headers["x-request-id"]);
108
121
  });
109
122
 
110
123
  it("supports chaining multiple registrations", async () => {
@@ -196,6 +209,7 @@ describe("TerrenoApp", () => {
196
209
  const agent = await authAsUser(app, "admin");
197
210
  const res = await agent.get("/configuration/meta");
198
211
  expect(res.status).toBe(200);
212
+ expect(res.body.requestId).toBe(res.headers["x-request-id"]);
199
213
  });
200
214
 
201
215
  it("supports custom basePath via configure options", async () => {
@@ -240,6 +254,30 @@ describe("TerrenoApp", () => {
240
254
 
241
255
  const res = await supertest(app).get("/trigger-fallthrough");
242
256
  expect(res.status).toBe(500);
257
+ expect(res.body.requestId).toBe(res.headers["x-request-id"]);
258
+ expect(res.body.status).toBe(500);
259
+ expect(res.body.title).toBe("Internal server error");
260
+ });
261
+
262
+ it("adds requestId to APIError JSON responses", async () => {
263
+ const plugin: TerrenoPlugin = {
264
+ register: (pluginApp) => {
265
+ pluginApp.get("/api-error-route", () => {
266
+ throw new APIError({status: 400, title: "Bad request test"});
267
+ });
268
+ },
269
+ };
270
+ const app = new TerrenoApp({
271
+ skipListen: true,
272
+ userModel: typedUserModel,
273
+ })
274
+ .register(plugin)
275
+ .build();
276
+
277
+ const res = await supertest(app).get("/api-error-route").set("X-Request-ID", "api-err-rid");
278
+ expect(res.status).toBe(400);
279
+ expect(res.body.requestId).toBe("api-err-rid");
280
+ expect(res.body.title).toBe("Bad request test");
243
281
  });
244
282
  });
245
283