@terreno/api 0.18.0 → 0.20.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 (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/api.test.js +18 -8
  3. package/dist/auth.d.ts +5 -5
  4. package/dist/auth.js +123 -131
  5. package/dist/configuration.test.js +289 -10
  6. package/dist/configurationApp.d.ts +72 -5
  7. package/dist/configurationApp.js +168 -48
  8. package/dist/configurationPlugin.d.ts +64 -7
  9. package/dist/configurationPlugin.js +161 -39
  10. package/dist/configurationPlugin.test.js +238 -1
  11. package/dist/expressServer.test.js +0 -1
  12. package/dist/openApi.d.ts +6 -6
  13. package/dist/openApi.js +21 -21
  14. package/dist/populate.test.js +23 -0
  15. package/dist/realtime/queryMatcher.js +0 -6
  16. package/dist/realtime/queryStore.js +3 -11
  17. package/dist/realtime/realtime.test.js +41 -34
  18. package/dist/secretProviders.d.ts +79 -2
  19. package/dist/secretProviders.js +177 -9
  20. package/dist/secretProviders.test.d.ts +1 -0
  21. package/dist/secretProviders.test.js +391 -0
  22. package/package.json +1 -1
  23. package/src/actions.openApi.test.ts +1 -1
  24. package/src/actions.ts +0 -1
  25. package/src/api.test.ts +10 -2
  26. package/src/auth.ts +19 -19
  27. package/src/configuration.test.ts +171 -7
  28. package/src/configurationApp.ts +213 -30
  29. package/src/configurationPlugin.test.ts +174 -2
  30. package/src/configurationPlugin.ts +157 -28
  31. package/src/expressServer.test.ts +0 -1
  32. package/src/openApi.ts +21 -21
  33. package/src/populate.test.ts +25 -0
  34. package/src/realtime/queryMatcher.ts +0 -6
  35. package/src/realtime/queryStore.ts +1 -10
  36. package/src/realtime/realtime.test.ts +24 -24
  37. package/src/realtime/realtimeApp.ts +0 -1
  38. package/src/realtime/registry.ts +0 -1
  39. package/src/realtime/types.ts +0 -4
  40. package/src/secretProviders.test.ts +186 -0
  41. package/src/secretProviders.ts +145 -5
@@ -1,16 +1,18 @@
1
1
  import type express from "express";
2
2
  import type {Model, Schema} from "mongoose";
3
3
 
4
- import {asyncHandler} from "./api";
4
+ import {asyncHandler, type RESTMethod} from "./api";
5
5
  import {authenticateMiddleware} from "./auth";
6
6
  import type {SecretFieldMeta} from "./configurationPlugin";
7
7
  import {APIError} from "./errors";
8
8
  import {logger} from "./logger";
9
+ import {checkPermissions, type PermissionMethod} from "./permissions";
9
10
  import {getOpenApiSpecForModel} from "./populate";
10
11
  import type {TerrenoPlugin} from "./terrenoPlugin";
11
12
 
12
13
  /**
13
- * Middleware that requires the user to be an admin.
14
+ * Middleware that requires the user to be an admin. Used as the default guard
15
+ * for every configuration route when no custom `permissions` are supplied.
14
16
  */
15
17
  const requireAdmin = (
16
18
  req: express.Request,
@@ -24,6 +26,29 @@ const requireAdmin = (
24
26
  next();
25
27
  };
26
28
 
29
+ /**
30
+ * Builds an Express middleware that AND-combines terreno permission functions
31
+ * (the same {@link PermissionMethod} contract used by `modelRouter`). The
32
+ * configuration singleton has no per-object ownership, so the loaded config
33
+ * document is passed as the permission object.
34
+ */
35
+ const buildPermissionMiddleware = (
36
+ perms: PermissionMethod<unknown>[],
37
+ method: RESTMethod,
38
+ loadObj?: () => Promise<unknown>
39
+ ): express.RequestHandler =>
40
+ asyncHandler(async (req: express.Request, _res: express.Response, next: express.NextFunction) => {
41
+ const obj = loadObj ? await loadObj() : undefined;
42
+ const allowed = await checkPermissions(method, perms, req.user, obj);
43
+ if (!allowed) {
44
+ throw new APIError({
45
+ status: 403,
46
+ title: "Access to configuration denied",
47
+ });
48
+ }
49
+ next();
50
+ });
51
+
27
52
  /**
28
53
  * Metadata for a single configuration field, sent to the frontend.
29
54
  */
@@ -54,6 +79,54 @@ export interface ConfigurationMetaResponse {
54
79
  sections: ConfigSectionMeta[];
55
80
  }
56
81
 
82
+ /**
83
+ * Per-route permission overrides for ConfigurationApp. Each value is an array of
84
+ * terreno permission functions ({@link PermissionMethod}), AND-combined like
85
+ * `modelRouter` permissions. When a route is omitted, the default admin-only
86
+ * guard applies.
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * permissions: {
91
+ * read: [IsStaff],
92
+ * update: [IsSuperUser],
93
+ * }
94
+ * ```
95
+ */
96
+ export interface ConfigurationPermissions {
97
+ /** Guards `GET {basePath}` (current values). */
98
+ read?: PermissionMethod<unknown>[];
99
+ /** Guards `PATCH {basePath}` (update values). */
100
+ update?: PermissionMethod<unknown>[];
101
+ /** Guards `GET {basePath}/meta` (schema metadata). */
102
+ meta?: PermissionMethod<unknown>[];
103
+ /** Guards `POST {basePath}/list-secrets` and `/validate-secrets`. */
104
+ listSecrets?: PermissionMethod<unknown>[];
105
+ }
106
+
107
+ /**
108
+ * Hook invoked before a configuration update is applied. Receives the incoming
109
+ * (already system-field- and secret-field-stripped) body and the request, and
110
+ * returns the body to apply. Use it to validate or normalize input. Throw an
111
+ * {@link APIError} to reject the update.
112
+ */
113
+ export type ConfigurationPreUpdateHook = (
114
+ body: Record<string, unknown>,
115
+ req: express.Request
116
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
117
+
118
+ /**
119
+ * Hook invoked after a configuration update is applied. Receives the updated
120
+ * configuration and the previous value (both with secret values redacted) plus
121
+ * the request, enabling audit logging of who changed what. Secret values are
122
+ * never included.
123
+ */
124
+ export type ConfigurationPostUpdateHook = (
125
+ config: Record<string, unknown>,
126
+ prevValue: Record<string, unknown>,
127
+ req: express.Request
128
+ ) => void | Promise<void>;
129
+
57
130
  /**
58
131
  * Options for ConfigurationApp.
59
132
  */
@@ -65,6 +138,16 @@ export interface ConfigurationAppOptions {
65
138
  basePath?: string;
66
139
  /** Per-field widget overrides (e.g., {"ai.systemPrompt": "markdown"}). */
67
140
  fieldOverrides?: Record<string, {widget?: string}>;
141
+ /**
142
+ * Per-route permission overrides. Defaults to admin-only for every route when
143
+ * omitted. Supply terreno permission functions (e.g. `[IsStaff]`) to expose
144
+ * configuration to a consumer's own permission system.
145
+ */
146
+ permissions?: ConfigurationPermissions;
147
+ /** Hook run before an update is applied (validation/normalization). */
148
+ preUpdate?: ConfigurationPreUpdateHook;
149
+ /** Hook run after an update is applied (audit logging). */
150
+ postUpdate?: ConfigurationPostUpdateHook;
68
151
  }
69
152
 
70
153
  /**
@@ -152,6 +235,41 @@ const redactSecrets = (
152
235
  return redacted;
153
236
  };
154
237
 
238
+ /**
239
+ * Removes secret field values from an incoming update body so a secret value can
240
+ * never be written to the configuration document through the update path.
241
+ * Copies nodes along each secret path to avoid mutating the caller's object.
242
+ */
243
+ const stripSecretFields = (
244
+ obj: Record<string, unknown>,
245
+ secretFields: SecretFieldMeta[]
246
+ ): Record<string, unknown> => {
247
+ const stripped: Record<string, unknown> = {...obj};
248
+ for (const field of secretFields) {
249
+ const parts = field.path.split(".");
250
+ let current: Record<string, unknown> | null = stripped;
251
+ for (let i = 0; i < parts.length - 1; i++) {
252
+ if (!current) {
253
+ break;
254
+ }
255
+ const part = parts[i];
256
+ const nested = current[part];
257
+ if (nested != null && typeof nested === "object") {
258
+ const copy = {...(nested as Record<string, unknown>)};
259
+ current[part] = copy;
260
+ current = copy;
261
+ } else {
262
+ current = null;
263
+ break;
264
+ }
265
+ }
266
+ if (current != null) {
267
+ delete current[parts[parts.length - 1]];
268
+ }
269
+ }
270
+ return stripped;
271
+ };
272
+
155
273
  /**
156
274
  * Converts a camelCase or PascalCase string into a display-friendly title.
157
275
  */
@@ -167,11 +285,21 @@ const toDisplayName = (name: string): string => {
167
285
  *
168
286
  * Inspects the Mongoose configuration model to auto-generate:
169
287
  * - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
170
- * - `GET {basePath}` — Current configuration values
171
- * - `PATCH {basePath}` — Update configuration values
172
- * - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
288
+ * - `GET {basePath}` — Current configuration values (secret values redacted)
289
+ * - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
290
+ * - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`)
291
+ * Read-only status of each secret field (whether the provider can resolve it).
292
+ * This endpoint never resolves values into the document and returns no secret values.
293
+ *
294
+ * By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
295
+ * to gate routes with a consumer's own permission functions, and `preUpdate`/
296
+ * `postUpdate` hooks to validate and audit-log changes. This makes
297
+ * `ConfigurationApp` suitable as a single, consumer-owned configuration surface
298
+ * that can replace a bespoke config router.
173
299
  *
174
- * All endpoints require `Permissions.IsAdmin`.
300
+ * Secret values never touch the database, logs, audit payloads, or API
301
+ * responses: secret fields are stripped from incoming updates and redacted on
302
+ * every read.
175
303
  *
176
304
  * Nested subschemas in the model become separate sections in the metadata,
177
305
  * making them renderable as cards/accordions in the admin UI.
@@ -193,7 +321,10 @@ const toDisplayName = (name: string): string => {
193
321
  * const AppConfig = mongoose.model("AppConfig", configSchema);
194
322
  *
195
323
  * new TerrenoApp({ userModel: User })
196
- * .configure(AppConfig)
324
+ * .configure(AppConfig, {
325
+ * permissions: {read: [IsStaff], update: [IsSuperUser]},
326
+ * postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
327
+ * })
197
328
  * .start();
198
329
  * ```
199
330
  */
@@ -204,6 +335,21 @@ export class ConfigurationApp implements TerrenoPlugin {
204
335
  this.options = options;
205
336
  }
206
337
 
338
+ /**
339
+ * Resolves the guard middleware for a route: the consumer's terreno permission
340
+ * functions when supplied, otherwise the default admin-only guard.
341
+ */
342
+ private guardFor(
343
+ route: keyof ConfigurationPermissions,
344
+ method: RESTMethod
345
+ ): express.RequestHandler {
346
+ const perms = this.options.permissions?.[route];
347
+ if (perms && perms.length > 0) {
348
+ return buildPermissionMiddleware(perms, method);
349
+ }
350
+ return requireAdmin;
351
+ }
352
+
207
353
  register(app: express.Application): void {
208
354
  const basePath = this.options.basePath ?? "/configuration";
209
355
  const ConfigModel = this.options.model;
@@ -216,7 +362,7 @@ export class ConfigurationApp implements TerrenoPlugin {
216
362
  app.get(
217
363
  `${basePath}/meta`,
218
364
  authenticateMiddleware(),
219
- requireAdmin,
365
+ this.guardFor("meta", "read"),
220
366
  (_req: express.Request, res: express.Response) => {
221
367
  return res.json(meta);
222
368
  }
@@ -239,7 +385,7 @@ export class ConfigurationApp implements TerrenoPlugin {
239
385
  app.get(
240
386
  `${basePath}`,
241
387
  authenticateMiddleware(),
242
- requireAdmin,
388
+ this.guardFor("read", "read"),
243
389
  asyncHandler(async (_req: express.Request, res: express.Response) => {
244
390
  const config = await ConfigStatics.getConfig();
245
391
  const data = redactSecrets(config.toJSON(), secretFields);
@@ -247,44 +393,81 @@ export class ConfigurationApp implements TerrenoPlugin {
247
393
  })
248
394
  );
249
395
 
250
- // PATCH /configuration — update values (secrets redacted in response)
396
+ // PATCH /configuration — update values (secret fields stripped; secrets redacted in response)
251
397
  app.patch(
252
398
  `${basePath}`,
253
399
  authenticateMiddleware(),
254
- requireAdmin,
400
+ this.guardFor("update", "update"),
255
401
  asyncHandler(async (req: express.Request, res: express.Response) => {
256
- // Strip internal system fields that should never be updated via the API
257
- const {_singleton: _s, _id: _i, __v: _v, ...safeBody} = req.body;
402
+ // Strip internal system fields that should never be updated via the API.
403
+ const {_singleton: _s, _id: _i, __v: _v, ...rest} = req.body;
404
+ // Strip secret fields so a secret value can never be persisted via update.
405
+ let safeBody = stripSecretFields(rest, secretFields);
406
+
407
+ // Allow consumers to validate/normalize before applying.
408
+ if (this.options.preUpdate) {
409
+ safeBody = await this.options.preUpdate(safeBody, req);
410
+ // Re-strip after the hook: preUpdate receives the raw request and could
411
+ // otherwise (re)introduce secret paths. Secrets must never persist here.
412
+ safeBody = stripSecretFields(safeBody, secretFields);
413
+ }
414
+
415
+ // Capture the previous (redacted) value for audit hooks.
416
+ let prevValue: Record<string, unknown> = {};
417
+ if (this.options.postUpdate) {
418
+ const before = await ConfigStatics.getConfig();
419
+ prevValue = redactSecrets(before.toJSON(), secretFields);
420
+ }
421
+
258
422
  const config = await ConfigStatics.updateConfig(safeBody);
259
423
  logger.info(`Configuration updated by ${req.user?.email ?? "unknown"}`);
260
424
  const data = redactSecrets(config.toJSON(), secretFields);
425
+
426
+ if (this.options.postUpdate) {
427
+ await this.options.postUpdate(data, prevValue, req);
428
+ }
429
+
261
430
  return res.json({data});
262
431
  })
263
432
  );
264
433
 
265
- // POST /configuration/list-secrets — list secret fields and optionally resolve from provider
266
- app.post(
267
- `${basePath}/list-secrets`,
268
- authenticateMiddleware(),
269
- requireAdmin,
270
- asyncHandler(async (_req: express.Request, res: express.Response) => {
434
+ // POST /configuration/list-secrets — read-only status of each secret field.
435
+ // Never resolves values into the document and never returns secret values.
436
+ const validateSecretsHandler = asyncHandler(
437
+ async (_req: express.Request, res: express.Response) => {
438
+ // In-memory resolution only — used to report whether each secret is
439
+ // configured/resolvable. Values are never persisted or returned.
271
440
  const resolved: Map<string, string> = await ConfigStatics.resolveSecrets();
272
- if (resolved.size > 0) {
273
- const updates: Record<string, unknown> = {};
274
- for (const [path, value] of resolved) {
275
- updates[path] = value;
276
- }
277
- await ConfigStatics.updateConfig(updates);
278
- logger.info(`Refreshed ${resolved.size}/${secretFields.length} secrets`);
279
- }
441
+ const fields = secretFields.map((s) => ({
442
+ isConfigured: resolved.has(s.path),
443
+ path: s.path,
444
+ resolvable: resolved.has(s.path),
445
+ secretName: s.secretName,
446
+ version: s.version,
447
+ }));
448
+ logger.info(`Validated ${resolved.size}/${secretFields.length} secrets (read-only)`);
280
449
 
281
450
  return res.json({
282
- message: `Resolved ${resolved.size}/${secretFields.length} secrets.`,
451
+ message: `${resolved.size}/${secretFields.length} secrets resolvable.`,
283
452
  resolved: resolved.size,
284
- secretFields: secretFields.map((s) => ({path: s.path, secretName: s.secretName})),
453
+ secretFields: fields,
285
454
  total: secretFields.length,
286
455
  });
287
- })
456
+ }
457
+ );
458
+
459
+ app.post(
460
+ `${basePath}/list-secrets`,
461
+ authenticateMiddleware(),
462
+ this.guardFor("listSecrets", "read"),
463
+ validateSecretsHandler
464
+ );
465
+ // Accurate alias for the read-only validation semantics.
466
+ app.post(
467
+ `${basePath}/validate-secrets`,
468
+ authenticateMiddleware(),
469
+ this.guardFor("listSecrets", "read"),
470
+ validateSecretsHandler
288
471
  );
289
472
 
290
473
  logger.info(`Configuration routes mounted at ${basePath}`);
@@ -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
  });