@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.
- package/CHANGELOG.md +25 -0
- package/dist/api.test.js +18 -8
- package/dist/auth.d.ts +5 -5
- package/dist/auth.js +123 -131
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/expressServer.test.js +0 -1
- package/dist/openApi.d.ts +6 -6
- package/dist/openApi.js +21 -21
- package/dist/populate.test.js +23 -0
- package/dist/realtime/queryMatcher.js +0 -6
- package/dist/realtime/queryStore.js +3 -11
- package/dist/realtime/realtime.test.js +41 -34
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/actions.openApi.test.ts +1 -1
- package/src/actions.ts +0 -1
- package/src/api.test.ts +10 -2
- package/src/auth.ts +19 -19
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/expressServer.test.ts +0 -1
- package/src/openApi.ts +21 -21
- package/src/populate.test.ts +25 -0
- package/src/realtime/queryMatcher.ts +0 -6
- package/src/realtime/queryStore.ts +1 -10
- package/src/realtime/realtime.test.ts +24 -24
- package/src/realtime/realtimeApp.ts +0 -1
- package/src/realtime/registry.ts +0 -1
- package/src/realtime/types.ts +0 -4
- package/src/secretProviders.test.ts +186 -0
- package/src/secretProviders.ts +145 -5
package/src/configurationApp.ts
CHANGED
|
@@ -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}/
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
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 —
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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:
|
|
451
|
+
message: `${resolved.size}/${secretFields.length} secrets resolvable.`,
|
|
283
452
|
resolved: resolved.size,
|
|
284
|
-
secretFields:
|
|
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("
|
|
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
|
});
|