@terreno/api 0.19.0 → 0.20.1

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.
@@ -2,7 +2,8 @@
2
2
  import {afterAll, beforeAll, beforeEach, describe, expect, it} from "bun:test";
3
3
  import mongoose, {model, Schema} from "mongoose";
4
4
  import type {SecretProvider} from "./configurationPlugin";
5
- import {configurationPlugin} from "./configurationPlugin";
5
+ import {configurationPlugin, flattenToDotPaths} from "./configurationPlugin";
6
+ import {isDeletedPlugin} from "./plugins";
6
7
 
7
8
  // --- Test schema with secret fields ---
8
9
 
@@ -65,13 +66,52 @@ const simpleSchema = new Schema({
65
66
  simpleSchema.plugin(configurationPlugin);
66
67
  const SimpleConfigModel = model("SimpleConfiguration", simpleSchema) as any;
67
68
 
69
+ // --- Schema opting into the _singleton unique index ---
70
+
71
+ const indexedSchema = new Schema({
72
+ value: {default: "default", description: "A value", type: String},
73
+ });
74
+ indexedSchema.plugin(configurationPlugin, {enforceSingletonIndex: true});
75
+ const IndexedConfigModel = model("IndexedConfiguration", indexedSchema) as any;
76
+
77
+ // --- Soft-delete-aware schema ---
78
+
79
+ const softDeleteSchema = new Schema({
80
+ value: {default: "default", description: "A value", type: String},
81
+ });
82
+ softDeleteSchema.plugin(configurationPlugin);
83
+ softDeleteSchema.plugin(isDeletedPlugin);
84
+ const SoftDeleteConfigModel = model("SoftDeleteConfiguration", softDeleteSchema) as any;
85
+
86
+ // --- Schema with a validated field (enum) for runValidators coverage ---
87
+
88
+ const validatedSchema = new Schema({
89
+ level: {
90
+ default: "low",
91
+ description: "Severity level",
92
+ enum: ["low", "medium", "high"],
93
+ type: String,
94
+ },
95
+ });
96
+ validatedSchema.plugin(configurationPlugin);
97
+ const ValidatedConfigModel = model("ValidatedConfiguration", validatedSchema) as any;
98
+
68
99
  describe("configurationPlugin", () => {
69
100
  describe("schema setup", () => {
70
- it("adds a _singleton field with unique index", () => {
101
+ it("does not add a _singleton index by default", () => {
71
102
  const indexes = SimpleConfigModel.schema.indexes();
72
103
  const singletonIndex = indexes.find(
73
104
  ([fields]: [Record<string, any>]) => fields._singleton !== undefined
74
105
  );
106
+ expect(singletonIndex).toBeUndefined();
107
+ expect(SimpleConfigModel.schema.path("_singleton")).toBeUndefined();
108
+ });
109
+
110
+ it("adds a _singleton field with unique index when enforceSingletonIndex is true", () => {
111
+ const indexes = IndexedConfigModel.schema.indexes();
112
+ const singletonIndex = indexes.find(
113
+ ([fields]: [Record<string, any>]) => fields._singleton !== undefined
114
+ );
75
115
  expect(singletonIndex).toBeDefined();
76
116
  expect(singletonIndex[1].unique).toBe(true);
77
117
  });
@@ -168,6 +208,50 @@ describe("configurationPlugin", () => {
168
208
  expect(resolved.size).toBe(1);
169
209
  expect(resolved.get("apiKey")).toBe("resolved-key");
170
210
  });
211
+
212
+ it("passes the discovered secret version to the provider", async () => {
213
+ const versionedSchema = new Schema({
214
+ token: {
215
+ default: "",
216
+ description: "Pinned secret",
217
+ secret: true,
218
+ secretName: "pinned-token",
219
+ secretVersion: "5",
220
+ type: String,
221
+ },
222
+ });
223
+ versionedSchema.plugin(configurationPlugin);
224
+ const VersionedModel = model("VersionedConfiguration", versionedSchema) as any;
225
+
226
+ const received: Array<{name: string; version?: string}> = [];
227
+ const provider: SecretProvider = {
228
+ getSecret: async (name: string, version?: string) => {
229
+ received.push({name, version});
230
+ return "value";
231
+ },
232
+ name: "versioned-provider",
233
+ };
234
+
235
+ await VersionedModel.resolveSecrets(provider);
236
+ expect(received).toEqual([{name: "pinned-token", version: "5"}]);
237
+ });
238
+ });
239
+
240
+ describe("flattenToDotPaths", () => {
241
+ it("flattens nested plain objects into dotted paths", () => {
242
+ expect(flattenToDotPaths({a: {b: 1}})).toEqual([["a.b", 1]]);
243
+ });
244
+
245
+ it("treats arrays as leaves", () => {
246
+ expect(flattenToDotPaths({a: [1, 2]})).toEqual([["a", [1, 2]]]);
247
+ });
248
+
249
+ it("treats null as a leaf and keeps top-level keys", () => {
250
+ expect(flattenToDotPaths({a: null, b: "x"})).toEqual([
251
+ ["a", null],
252
+ ["b", "x"],
253
+ ]);
254
+ });
171
255
  });
172
256
 
173
257
  describe("singleton behavior (requires MongoDB)", () => {
@@ -258,6 +342,22 @@ describe("configurationPlugin", () => {
258
342
  expect(config.value).toBe("custom");
259
343
  });
260
344
 
345
+ it("runs schema validators on updateConfig (rejects invalid enum)", async () => {
346
+ if (!dbConnected) {
347
+ return;
348
+ }
349
+ try {
350
+ await ValidatedConfigModel.collection.drop();
351
+ } catch {
352
+ // Collection may not exist yet
353
+ }
354
+ await ValidatedConfigModel.getConfig();
355
+ await expect(ValidatedConfigModel.updateConfig({level: "bogus"})).rejects.toThrow();
356
+ // A valid value still applies.
357
+ const ok = await ValidatedConfigModel.updateConfig({level: "high"});
358
+ expect(ok.level).toBe("high");
359
+ });
360
+
261
361
  it("prevents deleteOne", async () => {
262
362
  if (!dbConnected) {
263
363
  return;
@@ -297,4 +397,76 @@ describe("configurationPlugin", () => {
297
397
  }
298
398
  });
299
399
  });
400
+
401
+ describe("soft-delete-aware singleton (requires MongoDB)", () => {
402
+ let dbConnected = false;
403
+
404
+ beforeAll(async () => {
405
+ try {
406
+ if (mongoose.connection.readyState === 1) {
407
+ dbConnected = true;
408
+ } else {
409
+ await mongoose.connect("mongodb://127.0.0.1/terreno-config-test", {
410
+ connectTimeoutMS: 3000,
411
+ serverSelectionTimeoutMS: 3000,
412
+ });
413
+ dbConnected = true;
414
+ }
415
+ } catch {
416
+ dbConnected = false;
417
+ }
418
+ });
419
+
420
+ beforeEach(async () => {
421
+ if (!dbConnected) {
422
+ return;
423
+ }
424
+ try {
425
+ await SoftDeleteConfigModel.collection.drop();
426
+ } catch {
427
+ // Collection may not exist yet
428
+ }
429
+ });
430
+
431
+ it("operates on the non-deleted singleton", async () => {
432
+ if (!dbConnected) {
433
+ return;
434
+ }
435
+ const config = await SoftDeleteConfigModel.getConfig();
436
+ expect(config.value).toBe("default");
437
+ const updated = await SoftDeleteConfigModel.updateConfig({value: "live"});
438
+ expect(updated.value).toBe("live");
439
+ expect(updated.deleted).toBe(false);
440
+ });
441
+
442
+ it("allows a new singleton after the existing one is soft-deleted", async () => {
443
+ if (!dbConnected) {
444
+ return;
445
+ }
446
+ const first = await SoftDeleteConfigModel.getConfig();
447
+ // Soft delete by setting deleted: true (allowed)
448
+ first.deleted = true;
449
+ await first.save();
450
+
451
+ // A new non-deleted singleton can now be created
452
+ const second = await SoftDeleteConfigModel.getConfig();
453
+ expect(second.deleted).toBe(false);
454
+ expect(second._id.toString()).not.toBe(first._id.toString());
455
+ });
456
+
457
+ it("does not let updateConfig touch a soft-deleted document", async () => {
458
+ if (!dbConnected) {
459
+ return;
460
+ }
461
+ const first = await SoftDeleteConfigModel.getConfig();
462
+ first.deleted = true;
463
+ await first.save();
464
+
465
+ // updateConfig creates and targets a fresh non-deleted singleton
466
+ const updated = await SoftDeleteConfigModel.updateConfig({value: "fresh"});
467
+ expect(updated.deleted).toBe(false);
468
+ expect(updated.value).toBe("fresh");
469
+ expect(updated._id.toString()).not.toBe(first._id.toString());
470
+ });
471
+ });
300
472
  });
@@ -11,6 +11,12 @@ export interface SecretFieldMeta {
11
11
  path: string;
12
12
  secretProvider?: string;
13
13
  secretName: string;
14
+ /**
15
+ * Optional secret version to pin resolution to. When omitted the provider
16
+ * resolves the latest version. Discovered from the `secretVersion` schema
17
+ * path option.
18
+ */
19
+ version?: string;
14
20
  }
15
21
 
16
22
  /**
@@ -18,7 +24,15 @@ export interface SecretFieldMeta {
18
24
  */
19
25
  export interface SecretProvider {
20
26
  name: string;
21
- getSecret(secretName: string): Promise<string | null>;
27
+ /**
28
+ * Resolve a secret value by name. Returns `null` when the secret is not found.
29
+ *
30
+ * @param secretName - The secret identifier (short name or provider-specific path).
31
+ * @param version - Optional version to pin resolution to. Providers that do not
32
+ * support versioning (e.g. environment variables) ignore this parameter. When
33
+ * omitted, the latest version is resolved.
34
+ */
35
+ getSecret(secretName: string, version?: string): Promise<string | null>;
22
36
  }
23
37
 
24
38
  /**
@@ -30,6 +44,18 @@ export interface ConfigurationPluginOptions {
30
44
  * Typically set during app startup so the model can resolve secrets on demand.
31
45
  */
32
46
  secretProvider?: SecretProvider;
47
+ /**
48
+ * When `true`, adds a `_singleton` sentinel field with a unique index to
49
+ * enforce the singleton constraint at the database level.
50
+ *
51
+ * Defaults to `false`. Leave this off when the consuming app already enforces
52
+ * a single non-deleted document via the pre-save guard (the default behavior)
53
+ * or via its own indexes/soft-delete plugin, to avoid double-enforcement and
54
+ * conflicting indexes.
55
+ *
56
+ * @defaultValue false
57
+ */
58
+ enforceSingletonIndex?: boolean;
33
59
  }
34
60
 
35
61
  // ---------------------------------------------------------------------------
@@ -75,14 +101,26 @@ export interface ConfigurationStatics<T extends object> {
75
101
  getConfig(): Promise<T & Document>;
76
102
  /** Get a specific value by dot-notation key. */
77
103
  getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
78
- /** Update the singleton configuration document (deep merge). */
104
+ /**
105
+ * Update the singleton configuration document.
106
+ *
107
+ * The patch is flattened into MongoDB dotted paths and applied with
108
+ * `findOneAndUpdate({$set})`. This preserves sibling fields inside nested
109
+ * subdocuments when a partial nested patch is supplied, and tolerates legacy /
110
+ * out-of-schema fields already persisted on the document (unlike a full
111
+ * `doc.save()`, which throws under `strict: "throw"`).
112
+ */
79
113
  updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
80
114
  /** Get secret field metadata discovered from the schema. */
81
115
  getSecretFields(): SecretFieldMeta[];
82
116
  /**
83
117
  * Resolve all secret field values from a provider.
84
118
  * Uses the provider passed here, or falls back to the one configured in the plugin options.
85
- * Returns a map of path -> value.
119
+ * Returns an **in-memory** map of path -> value for programmatic use (startup
120
+ * self-checks, request-time resolution).
121
+ *
122
+ * This method never persists resolved values. Secret material must never be
123
+ * written to the configuration document.
86
124
  */
87
125
  resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
88
126
  }
@@ -103,6 +141,47 @@ export interface ConfigurationStatics<T extends object> {
103
141
  */
104
142
  export interface ConfigurationModel<T extends object> extends Model<T>, ConfigurationStatics<T> {}
105
143
 
144
+ // ---------------------------------------------------------------------------
145
+ // Helpers
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Flattens a nested patch into MongoDB-style dotted paths, recursing into plain
150
+ * objects only; arrays and other values are treated as leaves.
151
+ *
152
+ * @example
153
+ * flattenToDotPaths({a: {b: 1}}) // => [["a.b", 1]]
154
+ */
155
+ export const flattenToDotPaths = (
156
+ obj: Record<string, unknown>,
157
+ prefix = ""
158
+ ): Array<[string, unknown]> => {
159
+ const out: Array<[string, unknown]> = [];
160
+ for (const [key, value] of Object.entries(obj)) {
161
+ const path = prefix ? `${prefix}.${key}` : key;
162
+ const isPlainObject =
163
+ value !== null &&
164
+ typeof value === "object" &&
165
+ !Array.isArray(value) &&
166
+ Object.getPrototypeOf(value) === Object.prototype;
167
+ if (isPlainObject) {
168
+ out.push(...flattenToDotPaths(value as Record<string, unknown>, path));
169
+ } else {
170
+ out.push([path, value]);
171
+ }
172
+ }
173
+ return out;
174
+ };
175
+
176
+ /**
177
+ * Builds the filter used to locate the singleton document. When the schema is
178
+ * soft-delete aware (has a `deleted` path, e.g. via `isDeletedPlugin`), the
179
+ * singleton is "the one non-deleted document"; otherwise any document matches.
180
+ */
181
+ const buildSingletonFilter = (schema: Schema): Record<string, unknown> => {
182
+ return schema.path("deleted") ? {deleted: false} : {};
183
+ };
184
+
106
185
  // ---------------------------------------------------------------------------
107
186
  // Plugin
108
187
  // ---------------------------------------------------------------------------
@@ -111,13 +190,23 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
111
190
  * Mongoose schema plugin that adds singleton configuration behavior.
112
191
  *
113
192
  * Adds:
114
- * - Pre-save hook enforcing exactly one document
193
+ * - Pre-save hook enforcing exactly one non-deleted document (soft-delete aware
194
+ * when the schema has a `deleted` path, e.g. via `isDeletedPlugin`)
115
195
  * - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
116
- * - `updateConfig(updates)` static: patches the singleton
196
+ * - `updateConfig(updates)` static: patches the singleton via `findOneAndUpdate({$set})`
197
+ * with dotted paths (preserves sibling subdoc fields; tolerates legacy fields)
117
198
  * - `getSecretFields()` static: returns metadata for fields with `secret: true`
118
- * - `resolveSecrets(provider?)` static: fetches secret values, using the plugin provider by default
199
+ * - `resolveSecrets(provider?)` static: resolves secret values into an in-memory map,
200
+ * using the plugin provider by default (never persists values)
201
+ * - Hard-delete blockers (`deleteOne`/`deleteMany`/`findOneAndDelete`); soft deletes
202
+ * (setting `deleted: true`) are allowed
203
+ *
204
+ * Soft deletes are allowed and a soft-deleted document does not block creating a
205
+ * new singleton. The `_singleton` unique index is opt-in via
206
+ * `enforceSingletonIndex` (default off).
119
207
  *
120
- * Mark fields as secrets using schema path options:
208
+ * Mark fields as secrets using schema path options. Pin a version with the
209
+ * optional `secretVersion` option:
121
210
  * ```typescript
122
211
  * const configSchema = new Schema({
123
212
  * apiKey: {
@@ -125,6 +214,7 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
125
214
  * description: "Third-party API key",
126
215
  * secret: true,
127
216
  * secretName: "my-api-key",
217
+ * secretVersion: "3", // optional — resolves "latest" when omitted
128
218
  * },
129
219
  * });
130
220
  * configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});
@@ -136,24 +226,31 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
136
226
  // Apply findOneOrNone so the singleton lookup avoids bare Model.findOne (idempotent).
137
227
  findOneOrNone(schema);
138
228
 
139
- // Add a sentinel field with a unique index to enforce singleton at the DB level.
140
- // All config documents get _singleton: "config", and the unique index prevents duplicates.
141
- schema.add({
142
- _singleton: {
143
- default: "config",
144
- description: "Sentinel field enforcing singleton constraint",
145
- immutable: true,
146
- select: false,
147
- type: String,
148
- },
149
- });
150
- schema.index({_singleton: 1}, {unique: true});
229
+ // Optionally add a sentinel field with a unique index to enforce the singleton
230
+ // at the database level. This is opt-in (default off) so it does not conflict
231
+ // with consumers that already enforce a single non-deleted document via the
232
+ // pre-save guard below or via their own soft-delete plugin/indexes.
233
+ if (pluginOptions.enforceSingletonIndex) {
234
+ schema.add({
235
+ _singleton: {
236
+ default: "config",
237
+ description: "Sentinel field enforcing singleton constraint",
238
+ immutable: true,
239
+ select: false,
240
+ type: String,
241
+ },
242
+ });
243
+ schema.index({_singleton: 1}, {unique: true});
244
+ }
151
245
 
152
- // Enforce singleton: only one document allowed (application-level guard)
246
+ // Enforce singleton: only one non-deleted document allowed (application-level
247
+ // guard). Soft-delete-aware: a soft-deleted document does not block creating a
248
+ // new singleton.
153
249
  schema.pre("save", async function () {
154
250
  if (this.isNew) {
251
+ const filter = buildSingletonFilter(schema);
155
252
  // Cheap existence check — no document needs to be returned.
156
- const existing = await (this.constructor as Model<unknown>).exists({});
253
+ const existing = await (this.constructor as Model<unknown>).exists(filter);
157
254
  if (existing) {
158
255
  throw new APIError({
159
256
  status: 409,
@@ -183,9 +280,10 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
183
280
 
184
281
  // Static: get the singleton configuration document or a value at a path (race-safe via upsert)
185
282
  schema.statics.getConfig = async function (key?: string): Promise<unknown> {
283
+ const singletonFilter = buildSingletonFilter(this.schema);
186
284
  const findSingleton = (): Promise<Document | null> =>
187
285
  (this as unknown as FindOneOrNonePlugin<unknown>).findOneOrNone(
188
- {}
286
+ singletonFilter
189
287
  ) as Promise<Document | null>;
190
288
  let config: Document | null = await findSingleton();
191
289
  if (!config) {
@@ -222,14 +320,44 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
222
320
  return value;
223
321
  };
224
322
 
225
- // Static: update the singleton configuration document (race-safe)
323
+ // Static: update the singleton configuration document via $set dotted paths.
324
+ // Flattening to dotted paths preserves sibling subdoc fields and tolerates
325
+ // legacy/out-of-schema fields already persisted on the document.
226
326
  schema.statics.updateConfig = async function (
227
327
  updates: Record<string, unknown>
228
328
  ): Promise<unknown> {
229
- const config = await (this as ConfigurationModel<Record<string, unknown>>).getConfig();
230
- Object.assign(config, updates);
231
- await (config as Document).save();
232
- return config;
329
+ const singletonFilter = buildSingletonFilter(this.schema);
330
+ const setFields: Record<string, unknown> = {};
331
+ for (const [path, value] of flattenToDotPaths(updates)) {
332
+ setFields[path] = value;
333
+ }
334
+
335
+ // Nothing to set — return the current singleton (creating it if missing).
336
+ if (Object.keys(setFields).length === 0) {
337
+ return (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
338
+ }
339
+
340
+ // runValidators keeps schema validation (enum/min/custom validators) on the
341
+ // patched paths, matching the prior doc.save() behavior. Legacy/out-of-schema
342
+ // fields already on the document are untouched (not in $set), so they are not
343
+ // re-validated.
344
+ const updated = await this.findOneAndUpdate(
345
+ singletonFilter,
346
+ {$set: setFields},
347
+ {new: true, runValidators: true}
348
+ );
349
+ if (updated) {
350
+ return updated;
351
+ }
352
+
353
+ // No singleton yet — create one (with subdocument defaults applied), then
354
+ // apply the patch.
355
+ await (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
356
+ return this.findOneAndUpdate(
357
+ singletonFilter,
358
+ {$set: setFields},
359
+ {new: true, runValidators: true}
360
+ ).orFail();
233
361
  };
234
362
 
235
363
  // Static: discover secret fields from schema options
@@ -243,6 +371,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
243
371
  path: prefix ? `${prefix}.${pathName}` : pathName,
244
372
  secretName: (opts.secretName as string) ?? pathName,
245
373
  secretProvider: opts.secretProvider as string | undefined,
374
+ version: opts.secretVersion as string | undefined,
246
375
  });
247
376
  }
248
377
  // Recurse into subschemas
@@ -275,7 +404,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
275
404
 
276
405
  const results = await Promise.allSettled(
277
406
  secrets.map(async (meta: SecretFieldMeta) => {
278
- const value = await resolvedProvider.getSecret(meta.secretName);
407
+ const value = await resolvedProvider.getSecret(meta.secretName, meta.version);
279
408
  if (value !== null) {
280
409
  resolved.set(meta.path, value);
281
410
  }
@@ -0,0 +1,186 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+
3
+ import type {SecretProvider} from "./configurationPlugin";
4
+ import {CachingSecretProvider, CompositeSecretProvider, EnvSecretProvider} from "./secretProviders";
5
+
6
+ describe("EnvSecretProvider", () => {
7
+ beforeEach(() => {
8
+ delete process.env.MY_SECRET_KEY;
9
+ });
10
+
11
+ it("resolves from a SCREAMING_SNAKE_CASE env var", async () => {
12
+ process.env.MY_SECRET_KEY = "from-env";
13
+ const provider = new EnvSecretProvider();
14
+ expect(await provider.getSecret("my-secret-key")).toBe("from-env");
15
+ });
16
+
17
+ it("returns null when the env var is missing", async () => {
18
+ const provider = new EnvSecretProvider();
19
+ expect(await provider.getSecret("my-secret-key")).toBeNull();
20
+ });
21
+
22
+ it("ignores the version parameter", async () => {
23
+ process.env.MY_SECRET_KEY = "value";
24
+ const provider = new EnvSecretProvider();
25
+ expect(await provider.getSecret("my-secret-key", "5")).toBe("value");
26
+ });
27
+ });
28
+
29
+ describe("CompositeSecretProvider", () => {
30
+ it("throws when constructed with no providers", () => {
31
+ expect(() => new CompositeSecretProvider([])).toThrow();
32
+ });
33
+
34
+ it("returns the first non-null result", async () => {
35
+ const a: SecretProvider = {getSecret: async () => null, name: "a"};
36
+ const b: SecretProvider = {getSecret: async () => "from-b", name: "b"};
37
+ const c: SecretProvider = {getSecret: async () => "from-c", name: "c"};
38
+ const provider = new CompositeSecretProvider([a, b, c]);
39
+ expect(await provider.getSecret("x")).toBe("from-b");
40
+ });
41
+
42
+ it("falls through to the next provider when one throws", async () => {
43
+ const failing: SecretProvider = {
44
+ getSecret: async () => {
45
+ throw new Error("provider down");
46
+ },
47
+ name: "failing",
48
+ };
49
+ const fallback: SecretProvider = {getSecret: async () => "from-fallback", name: "fallback"};
50
+ const provider = new CompositeSecretProvider([failing, fallback]);
51
+ expect(await provider.getSecret("x")).toBe("from-fallback");
52
+ });
53
+
54
+ it("returns null when every provider yields null", async () => {
55
+ const a: SecretProvider = {getSecret: async () => null, name: "a"};
56
+ const b: SecretProvider = {getSecret: async () => null, name: "b"};
57
+ const provider = new CompositeSecretProvider([a, b]);
58
+ expect(await provider.getSecret("x")).toBeNull();
59
+ });
60
+
61
+ it("forwards the version parameter to each provider", async () => {
62
+ const seen: Array<string | undefined> = [];
63
+ const a: SecretProvider = {
64
+ getSecret: async (_name, version) => {
65
+ seen.push(version);
66
+ return null;
67
+ },
68
+ name: "a",
69
+ };
70
+ const b: SecretProvider = {
71
+ getSecret: async (_name, version) => {
72
+ seen.push(version);
73
+ return "value";
74
+ },
75
+ name: "b",
76
+ };
77
+ const provider = new CompositeSecretProvider([a, b]);
78
+ await provider.getSecret("x", "7");
79
+ expect(seen).toEqual(["7", "7"]);
80
+ });
81
+
82
+ it("builds a composite name from the underlying providers", () => {
83
+ const provider = new CompositeSecretProvider([
84
+ {getSecret: async () => null, name: "gcp"},
85
+ {getSecret: async () => null, name: "env"},
86
+ ]);
87
+ expect(provider.name).toBe("composite(gcp,env)");
88
+ });
89
+ });
90
+
91
+ describe("CachingSecretProvider", () => {
92
+ it("memoizes a value within the TTL (single underlying call)", async () => {
93
+ let calls = 0;
94
+ const underlying: SecretProvider = {
95
+ getSecret: async () => {
96
+ calls++;
97
+ return "value";
98
+ },
99
+ name: "underlying",
100
+ };
101
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
102
+ expect(await provider.getSecret("x")).toBe("value");
103
+ expect(await provider.getSecret("x")).toBe("value");
104
+ expect(calls).toBe(1);
105
+ });
106
+
107
+ it("re-fetches after clear()", async () => {
108
+ let calls = 0;
109
+ const underlying: SecretProvider = {
110
+ getSecret: async () => {
111
+ calls++;
112
+ return `value-${calls}`;
113
+ },
114
+ name: "underlying",
115
+ };
116
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
117
+ expect(await provider.getSecret("x")).toBe("value-1");
118
+ provider.clear();
119
+ expect(await provider.getSecret("x")).toBe("value-2");
120
+ expect(calls).toBe(2);
121
+ });
122
+
123
+ it("re-fetches after the TTL expires", async () => {
124
+ let calls = 0;
125
+ const underlying: SecretProvider = {
126
+ getSecret: async () => {
127
+ calls++;
128
+ return `value-${calls}`;
129
+ },
130
+ name: "underlying",
131
+ };
132
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 1});
133
+ expect(await provider.getSecret("x")).toBe("value-1");
134
+ await new Promise((resolve) => setTimeout(resolve, 5));
135
+ expect(await provider.getSecret("x")).toBe("value-2");
136
+ expect(calls).toBe(2);
137
+ });
138
+
139
+ it("caches different versions independently", async () => {
140
+ const seen: Array<string | undefined> = [];
141
+ const underlying: SecretProvider = {
142
+ getSecret: async (_name, version) => {
143
+ seen.push(version);
144
+ return `v-${version ?? "latest"}`;
145
+ },
146
+ name: "underlying",
147
+ };
148
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
149
+ expect(await provider.getSecret("x", "1")).toBe("v-1");
150
+ expect(await provider.getSecret("x", "2")).toBe("v-2");
151
+ // Cached hits, no additional underlying calls.
152
+ expect(await provider.getSecret("x", "1")).toBe("v-1");
153
+ expect(seen).toEqual(["1", "2"]);
154
+ });
155
+
156
+ it("clearKey invalidates a single secret", async () => {
157
+ let calls = 0;
158
+ const underlying: SecretProvider = {
159
+ getSecret: async () => {
160
+ calls++;
161
+ return `value-${calls}`;
162
+ },
163
+ name: "underlying",
164
+ };
165
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
166
+ await provider.getSecret("x");
167
+ provider.clearKey("x");
168
+ await provider.getSecret("x");
169
+ expect(calls).toBe(2);
170
+ });
171
+
172
+ it("caches null results", async () => {
173
+ let calls = 0;
174
+ const underlying: SecretProvider = {
175
+ getSecret: async () => {
176
+ calls++;
177
+ return null;
178
+ },
179
+ name: "underlying",
180
+ };
181
+ const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
182
+ expect(await provider.getSecret("missing")).toBeNull();
183
+ expect(await provider.getSecret("missing")).toBeNull();
184
+ expect(calls).toBe(1);
185
+ });
186
+ });