@terreno/api 0.3.1 → 0.4.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/dist/api.js +9 -8
- package/dist/betterAuthSetup.js +1 -1
- package/dist/configuration.test.d.ts +1 -0
- package/dist/configuration.test.js +699 -0
- package/dist/configurationApp.d.ts +91 -0
- package/dist/configurationApp.js +407 -0
- package/dist/configurationPlugin.d.ts +102 -0
- package/dist/configurationPlugin.js +285 -0
- package/dist/configurationPlugin.test.d.ts +1 -0
- package/dist/configurationPlugin.test.js +509 -0
- package/dist/example.js +1 -1
- package/dist/expressServer.js +5 -1
- package/dist/githubAuth.js +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/openApiCompat.d.ts +23 -0
- package/dist/openApiCompat.js +198 -0
- package/dist/scriptRunner.d.ts +52 -0
- package/dist/scriptRunner.js +231 -0
- package/dist/secretProviders.d.ts +47 -0
- package/dist/secretProviders.js +214 -0
- package/dist/terrenoApp.d.ts +25 -0
- package/dist/terrenoApp.js +49 -2
- package/dist/tests.d.ts +27 -9
- package/dist/tests.js +10 -1
- package/package.json +13 -13
- package/src/api.ts +9 -8
- package/src/betterAuthSetup.ts +2 -2
- package/src/configuration.test.ts +398 -0
- package/src/configurationApp.ts +359 -0
- package/src/configurationPlugin.test.ts +299 -0
- package/src/configurationPlugin.ts +288 -0
- package/src/example.ts +1 -1
- package/src/expressServer.ts +6 -1
- package/src/githubAuth.ts +4 -4
- package/src/index.ts +5 -0
- package/src/openApiCompat.ts +147 -0
- package/src/permissions.ts +1 -1
- package/src/scriptRunner.ts +219 -0
- package/src/secretProviders.ts +109 -0
- package/src/terrenoApp.ts +44 -2
- package/src/tests.ts +12 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type express from "express";
|
|
2
|
+
import type {Model, Schema} from "mongoose";
|
|
3
|
+
|
|
4
|
+
import {asyncHandler} from "./api";
|
|
5
|
+
import {authenticateMiddleware} from "./auth";
|
|
6
|
+
import type {SecretFieldMeta} from "./configurationPlugin";
|
|
7
|
+
import {APIError} from "./errors";
|
|
8
|
+
import {logger} from "./logger";
|
|
9
|
+
import {getOpenApiSpecForModel} from "./populate";
|
|
10
|
+
import type {TerrenoPlugin} from "./terrenoPlugin";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Middleware that requires the user to be an admin.
|
|
14
|
+
*/
|
|
15
|
+
const requireAdmin = (
|
|
16
|
+
req: express.Request,
|
|
17
|
+
_res: express.Response,
|
|
18
|
+
next: express.NextFunction
|
|
19
|
+
): void => {
|
|
20
|
+
if (!(req as any).user?.admin) {
|
|
21
|
+
next(new APIError({status: 403, title: "Admin access required"}));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
next();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Metadata for a single configuration field, sent to the frontend.
|
|
29
|
+
*/
|
|
30
|
+
interface ConfigFieldMeta {
|
|
31
|
+
type: string;
|
|
32
|
+
required: boolean;
|
|
33
|
+
description?: string;
|
|
34
|
+
enum?: string[];
|
|
35
|
+
default?: any;
|
|
36
|
+
secret?: boolean;
|
|
37
|
+
widget?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Metadata for a configuration section (nested subschema).
|
|
42
|
+
*/
|
|
43
|
+
interface ConfigSectionMeta {
|
|
44
|
+
name: string;
|
|
45
|
+
displayName: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
fields: Record<string, ConfigFieldMeta>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The config metadata response shape sent to the frontend.
|
|
52
|
+
*/
|
|
53
|
+
export interface ConfigurationMetaResponse {
|
|
54
|
+
sections: ConfigSectionMeta[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Options for ConfigurationApp.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConfigurationAppOptions {
|
|
61
|
+
/** The Mongoose model with configurationPlugin applied. */
|
|
62
|
+
model: Model<any>;
|
|
63
|
+
/** Base path for configuration routes. Defaults to "/configuration". */
|
|
64
|
+
basePath?: string;
|
|
65
|
+
/** Per-field widget overrides (e.g., {"ai.systemPrompt": "markdown"}). */
|
|
66
|
+
fieldOverrides?: Record<string, {widget?: string}>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extracts field metadata from an OpenAPI properties object, augmented with
|
|
71
|
+
* secret info from the Mongoose schema.
|
|
72
|
+
*/
|
|
73
|
+
const extractFieldMeta = (
|
|
74
|
+
properties: Record<string, any>,
|
|
75
|
+
required: string[],
|
|
76
|
+
schema: Schema,
|
|
77
|
+
prefix: string,
|
|
78
|
+
fieldOverrides?: Record<string, {widget?: string}>
|
|
79
|
+
): Record<string, ConfigFieldMeta> => {
|
|
80
|
+
const fields: Record<string, ConfigFieldMeta> = {};
|
|
81
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
82
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
83
|
+
const schemaPath = schema.path(fullPath);
|
|
84
|
+
const opts = schemaPath?.options as any;
|
|
85
|
+
|
|
86
|
+
fields[key] = {
|
|
87
|
+
default: prop.default,
|
|
88
|
+
description: opts?.description ?? prop.description,
|
|
89
|
+
enum: prop.enum,
|
|
90
|
+
required: required.includes(key),
|
|
91
|
+
secret: opts?.secret === true,
|
|
92
|
+
type: prop.type ?? "string",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Apply field overrides
|
|
96
|
+
if (fieldOverrides?.[fullPath]?.widget) {
|
|
97
|
+
fields[key].widget = fieldOverrides[fullPath].widget;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return fields;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* System fields to skip in configuration sections.
|
|
105
|
+
*/
|
|
106
|
+
const SYSTEM_FIELDS = new Set(["_id", "_singleton", "id", "__v", "created", "updated", "deleted"]);
|
|
107
|
+
|
|
108
|
+
const SECRET_REDACTED = "********";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Redacts secret field values in a configuration object.
|
|
112
|
+
* Replaces values at secret paths with a placeholder string.
|
|
113
|
+
*/
|
|
114
|
+
const redactSecrets = (
|
|
115
|
+
obj: Record<string, any>,
|
|
116
|
+
secretFields: SecretFieldMeta[]
|
|
117
|
+
): Record<string, any> => {
|
|
118
|
+
const redacted = {...obj};
|
|
119
|
+
for (const field of secretFields) {
|
|
120
|
+
const parts = field.path.split(".");
|
|
121
|
+
let current: any = redacted;
|
|
122
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
123
|
+
if (current[parts[i]] != null && typeof current[parts[i]] === "object") {
|
|
124
|
+
current[parts[i]] = {...current[parts[i]]};
|
|
125
|
+
current = current[parts[i]];
|
|
126
|
+
} else {
|
|
127
|
+
current = null;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const lastKey = parts[parts.length - 1];
|
|
132
|
+
if (current != null && current[lastKey] != null && current[lastKey] !== "") {
|
|
133
|
+
current[lastKey] = SECRET_REDACTED;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return redacted;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Converts a camelCase or PascalCase string into a display-friendly title.
|
|
141
|
+
*/
|
|
142
|
+
const toDisplayName = (name: string): string => {
|
|
143
|
+
return name
|
|
144
|
+
.replace(/([A-Z])/g, " $1")
|
|
145
|
+
.replace(/^./, (s) => s.toUpperCase())
|
|
146
|
+
.trim();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* TerrenoPlugin that provides configuration management endpoints.
|
|
151
|
+
*
|
|
152
|
+
* Inspects the Mongoose configuration model to auto-generate:
|
|
153
|
+
* - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
|
|
154
|
+
* - `GET {basePath}` — Current configuration values
|
|
155
|
+
* - `PATCH {basePath}` — Update configuration values
|
|
156
|
+
* - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
|
|
157
|
+
*
|
|
158
|
+
* All endpoints require `Permissions.IsAdmin`.
|
|
159
|
+
*
|
|
160
|
+
* Nested subschemas in the model become separate sections in the metadata,
|
|
161
|
+
* making them renderable as cards/accordions in the admin UI.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* import {ConfigurationApp, configurationPlugin} from "@terreno/api";
|
|
166
|
+
*
|
|
167
|
+
* const configSchema = new Schema({
|
|
168
|
+
* general: { type: new Schema({
|
|
169
|
+
* appName: { type: String, description: "App display name", default: "My App" },
|
|
170
|
+
* maintenanceMode: { type: Boolean, description: "Enable maintenance mode", default: false },
|
|
171
|
+
* })},
|
|
172
|
+
* integrations: { type: new Schema({
|
|
173
|
+
* openAiKey: { type: String, description: "OpenAI API key", secret: true, secretName: "openai-key" },
|
|
174
|
+
* })},
|
|
175
|
+
* });
|
|
176
|
+
* configSchema.plugin(configurationPlugin);
|
|
177
|
+
* const AppConfig = mongoose.model("AppConfig", configSchema);
|
|
178
|
+
*
|
|
179
|
+
* new TerrenoApp({ userModel: User })
|
|
180
|
+
* .configure(AppConfig)
|
|
181
|
+
* .start();
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export class ConfigurationApp implements TerrenoPlugin {
|
|
185
|
+
private options: ConfigurationAppOptions;
|
|
186
|
+
|
|
187
|
+
constructor(options: ConfigurationAppOptions) {
|
|
188
|
+
this.options = options;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
register(app: express.Application): void {
|
|
192
|
+
const basePath = this.options.basePath ?? "/configuration";
|
|
193
|
+
const ConfigModel = this.options.model;
|
|
194
|
+
const schema = ConfigModel.schema;
|
|
195
|
+
|
|
196
|
+
// Build metadata by inspecting the schema
|
|
197
|
+
const meta = this.buildMetadata(ConfigModel, schema);
|
|
198
|
+
|
|
199
|
+
// GET /configuration/meta — schema metadata for the frontend
|
|
200
|
+
app.get(
|
|
201
|
+
`${basePath}/meta`,
|
|
202
|
+
authenticateMiddleware(),
|
|
203
|
+
requireAdmin,
|
|
204
|
+
(_req: express.Request, res: express.Response) => {
|
|
205
|
+
return res.json(meta);
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Discover secret fields once at registration time
|
|
210
|
+
const secretFields: SecretFieldMeta[] = (ConfigModel as any).getSecretFields?.() ?? [];
|
|
211
|
+
|
|
212
|
+
// GET /configuration — current values (secrets redacted)
|
|
213
|
+
app.get(
|
|
214
|
+
`${basePath}`,
|
|
215
|
+
authenticateMiddleware(),
|
|
216
|
+
requireAdmin,
|
|
217
|
+
asyncHandler(async (_req: express.Request, res: express.Response) => {
|
|
218
|
+
const config = await (ConfigModel as any).getConfig();
|
|
219
|
+
const data = redactSecrets(config.toJSON(), secretFields);
|
|
220
|
+
return res.json({data});
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// PATCH /configuration — update values (secrets redacted in response)
|
|
225
|
+
app.patch(
|
|
226
|
+
`${basePath}`,
|
|
227
|
+
authenticateMiddleware(),
|
|
228
|
+
requireAdmin,
|
|
229
|
+
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
230
|
+
// Strip internal system fields that should never be updated via the API
|
|
231
|
+
const {_singleton: _s, _id: _i, __v: _v, ...safeBody} = req.body;
|
|
232
|
+
const config = await (ConfigModel as any).updateConfig(safeBody);
|
|
233
|
+
logger.info(`Configuration updated by ${(req as any).user?.email ?? "unknown"}`);
|
|
234
|
+
const data = redactSecrets(config.toJSON(), secretFields);
|
|
235
|
+
return res.json({data});
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// POST /configuration/list-secrets — list secret fields and optionally resolve from provider
|
|
240
|
+
app.post(
|
|
241
|
+
`${basePath}/list-secrets`,
|
|
242
|
+
authenticateMiddleware(),
|
|
243
|
+
requireAdmin,
|
|
244
|
+
asyncHandler(async (_req: express.Request, res: express.Response) => {
|
|
245
|
+
const resolved: Map<string, string> = await (ConfigModel as any).resolveSecrets();
|
|
246
|
+
if (resolved.size > 0) {
|
|
247
|
+
const updates: Record<string, unknown> = {};
|
|
248
|
+
for (const [path, value] of resolved) {
|
|
249
|
+
updates[path] = value;
|
|
250
|
+
}
|
|
251
|
+
await (ConfigModel as any).updateConfig(updates);
|
|
252
|
+
logger.info(`Refreshed ${resolved.size}/${secretFields.length} secrets`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return res.json({
|
|
256
|
+
message: `Resolved ${resolved.size}/${secretFields.length} secrets.`,
|
|
257
|
+
resolved: resolved.size,
|
|
258
|
+
secretFields: secretFields.map((s) => ({path: s.path, secretName: s.secretName})),
|
|
259
|
+
total: secretFields.length,
|
|
260
|
+
});
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
logger.info(`Configuration routes mounted at ${basePath}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Builds the metadata response by inspecting the model schema.
|
|
269
|
+
* Top-level fields with subschemas become sections.
|
|
270
|
+
* Top-level scalar fields go into a "General" section.
|
|
271
|
+
*/
|
|
272
|
+
private buildMetadata(_model: Model<any>, schema: Schema): ConfigurationMetaResponse {
|
|
273
|
+
const sections: ConfigSectionMeta[] = [];
|
|
274
|
+
const generalFields: Record<string, ConfigFieldMeta> = {};
|
|
275
|
+
|
|
276
|
+
// Walk top-level paths
|
|
277
|
+
schema.eachPath((pathName, schemaType) => {
|
|
278
|
+
if (SYSTEM_FIELDS.has(pathName)) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const subSchema = (schemaType as any).schema as Schema | undefined;
|
|
283
|
+
|
|
284
|
+
if (subSchema) {
|
|
285
|
+
// This is a nested subschema — make it a section
|
|
286
|
+
const {properties, required} = getOpenApiSpecForModel({
|
|
287
|
+
modelName: pathName,
|
|
288
|
+
schema: subSchema,
|
|
289
|
+
} as any);
|
|
290
|
+
|
|
291
|
+
// Filter out system fields from the subschema too
|
|
292
|
+
const filteredProperties: Record<string, any> = {};
|
|
293
|
+
const filteredRequired: string[] = [];
|
|
294
|
+
for (const [key, val] of Object.entries(properties)) {
|
|
295
|
+
if (!SYSTEM_FIELDS.has(key)) {
|
|
296
|
+
filteredProperties[key] = val;
|
|
297
|
+
if (required.includes(key)) {
|
|
298
|
+
filteredRequired.push(key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const sectionFields = extractFieldMeta(
|
|
304
|
+
filteredProperties,
|
|
305
|
+
filteredRequired,
|
|
306
|
+
schema,
|
|
307
|
+
pathName,
|
|
308
|
+
this.options.fieldOverrides
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Get description from the parent path options
|
|
312
|
+
const opts = schemaType.options as any;
|
|
313
|
+
|
|
314
|
+
sections.push({
|
|
315
|
+
description: opts?.description,
|
|
316
|
+
displayName: toDisplayName(pathName),
|
|
317
|
+
fields: sectionFields,
|
|
318
|
+
name: pathName,
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
// Scalar top-level field — goes into "General" section
|
|
322
|
+
const opts = schemaType.options as any;
|
|
323
|
+
const fullPath = pathName;
|
|
324
|
+
|
|
325
|
+
generalFields[pathName] = {
|
|
326
|
+
default: opts?.default,
|
|
327
|
+
description: opts?.description,
|
|
328
|
+
enum: opts?.enum,
|
|
329
|
+
required: opts?.required === true,
|
|
330
|
+
secret: opts?.secret === true,
|
|
331
|
+
type: this.mongooseTypeToString(schemaType),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (this.options.fieldOverrides?.[fullPath]?.widget) {
|
|
335
|
+
generalFields[pathName].widget = this.options.fieldOverrides[fullPath].widget;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Add general fields section if there are any
|
|
341
|
+
if (Object.keys(generalFields).length > 0) {
|
|
342
|
+
sections.unshift({
|
|
343
|
+
displayName: "General",
|
|
344
|
+
fields: generalFields,
|
|
345
|
+
name: "__root__",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {sections};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private mongooseTypeToString(schemaType: any): string {
|
|
353
|
+
const instance = schemaType.instance?.toLowerCase();
|
|
354
|
+
if (instance === "objectid") {
|
|
355
|
+
return "string";
|
|
356
|
+
}
|
|
357
|
+
return instance ?? "string";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {afterAll, beforeAll, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import mongoose, {model, Schema} from "mongoose";
|
|
3
|
+
import type {SecretProvider} from "./configurationPlugin";
|
|
4
|
+
import {configurationPlugin} from "./configurationPlugin";
|
|
5
|
+
|
|
6
|
+
// --- Test schema with secret fields ---
|
|
7
|
+
|
|
8
|
+
interface TestConfig {
|
|
9
|
+
appName: string;
|
|
10
|
+
maintenanceMode: boolean;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
nested: {
|
|
13
|
+
webhookUrl: string;
|
|
14
|
+
secretToken: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const testConfigSchema = new Schema<TestConfig>({
|
|
19
|
+
apiKey: {
|
|
20
|
+
default: "",
|
|
21
|
+
description: "External API key",
|
|
22
|
+
secret: true,
|
|
23
|
+
secretName: "ext-api-key",
|
|
24
|
+
type: String,
|
|
25
|
+
},
|
|
26
|
+
appName: {
|
|
27
|
+
default: "Test App",
|
|
28
|
+
description: "Application name",
|
|
29
|
+
type: String,
|
|
30
|
+
},
|
|
31
|
+
maintenanceMode: {
|
|
32
|
+
default: false,
|
|
33
|
+
description: "Whether maintenance mode is on",
|
|
34
|
+
type: Boolean,
|
|
35
|
+
},
|
|
36
|
+
nested: {
|
|
37
|
+
type: new Schema({
|
|
38
|
+
secretToken: {
|
|
39
|
+
default: "",
|
|
40
|
+
description: "A nested secret token",
|
|
41
|
+
secret: true,
|
|
42
|
+
secretName: "nested-token",
|
|
43
|
+
secretProvider: "vault",
|
|
44
|
+
type: String,
|
|
45
|
+
},
|
|
46
|
+
webhookUrl: {
|
|
47
|
+
default: "https://example.com/hook",
|
|
48
|
+
description: "Webhook URL",
|
|
49
|
+
type: String,
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
testConfigSchema.plugin(configurationPlugin);
|
|
56
|
+
|
|
57
|
+
const TestConfigModel = model("TestConfiguration", testConfigSchema) as any;
|
|
58
|
+
|
|
59
|
+
// --- Simple schema for singleton tests ---
|
|
60
|
+
|
|
61
|
+
const simpleSchema = new Schema({
|
|
62
|
+
value: {default: "default", description: "A value", type: String},
|
|
63
|
+
});
|
|
64
|
+
simpleSchema.plugin(configurationPlugin);
|
|
65
|
+
const SimpleConfigModel = model("SimpleConfiguration", simpleSchema) as any;
|
|
66
|
+
|
|
67
|
+
describe("configurationPlugin", () => {
|
|
68
|
+
describe("schema setup", () => {
|
|
69
|
+
it("adds a _singleton field with unique index", () => {
|
|
70
|
+
const indexes = SimpleConfigModel.schema.indexes();
|
|
71
|
+
const singletonIndex = indexes.find(
|
|
72
|
+
([fields]: [Record<string, any>]) => fields._singleton !== undefined
|
|
73
|
+
);
|
|
74
|
+
expect(singletonIndex).toBeDefined();
|
|
75
|
+
expect(singletonIndex[1].unique).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("adds getConfig static", () => {
|
|
79
|
+
expect(typeof SimpleConfigModel.getConfig).toBe("function");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("adds updateConfig static", () => {
|
|
83
|
+
expect(typeof SimpleConfigModel.updateConfig).toBe("function");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("adds getSecretFields static", () => {
|
|
87
|
+
expect(typeof TestConfigModel.getSecretFields).toBe("function");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("adds resolveSecrets static", () => {
|
|
91
|
+
expect(typeof TestConfigModel.resolveSecrets).toBe("function");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("getSecretFields", () => {
|
|
96
|
+
it("discovers top-level secret fields", () => {
|
|
97
|
+
const secrets = TestConfigModel.getSecretFields();
|
|
98
|
+
const apiKeySecret = secrets.find((s: {path: string}) => s.path === "apiKey");
|
|
99
|
+
expect(apiKeySecret).toBeDefined();
|
|
100
|
+
expect(apiKeySecret.secretName).toBe("ext-api-key");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("discovers nested secret fields", () => {
|
|
104
|
+
const secrets = TestConfigModel.getSecretFields();
|
|
105
|
+
const nestedSecret = secrets.find((s: {path: string}) => s.path === "nested.secretToken");
|
|
106
|
+
expect(nestedSecret).toBeDefined();
|
|
107
|
+
expect(nestedSecret.secretName).toBe("nested-token");
|
|
108
|
+
expect(nestedSecret.secretProvider).toBe("vault");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not include non-secret fields", () => {
|
|
112
|
+
const secrets = TestConfigModel.getSecretFields();
|
|
113
|
+
const nonSecret = secrets.find((s: {path: string}) => s.path === "appName");
|
|
114
|
+
expect(nonSecret).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns the correct total count of secret fields", () => {
|
|
118
|
+
const secrets = TestConfigModel.getSecretFields();
|
|
119
|
+
expect(secrets.length).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("resolveSecrets", () => {
|
|
124
|
+
it("resolves secrets from a provider", async () => {
|
|
125
|
+
const provider: SecretProvider = {
|
|
126
|
+
getSecret: async (name: string) => {
|
|
127
|
+
if (name === "ext-api-key") {
|
|
128
|
+
return "resolved-api-key";
|
|
129
|
+
}
|
|
130
|
+
if (name === "nested-token") {
|
|
131
|
+
return "resolved-token";
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
},
|
|
135
|
+
name: "test-provider",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const resolved = await TestConfigModel.resolveSecrets(provider);
|
|
139
|
+
expect(resolved.get("apiKey")).toBe("resolved-api-key");
|
|
140
|
+
expect(resolved.get("nested.secretToken")).toBe("resolved-token");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("handles provider failures gracefully", async () => {
|
|
144
|
+
const provider: SecretProvider = {
|
|
145
|
+
getSecret: async () => {
|
|
146
|
+
throw new Error("provider down");
|
|
147
|
+
},
|
|
148
|
+
name: "failing-provider",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const resolved = await TestConfigModel.resolveSecrets(provider);
|
|
152
|
+
expect(resolved.size).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles partial resolution", async () => {
|
|
156
|
+
const provider: SecretProvider = {
|
|
157
|
+
getSecret: async (name: string) => {
|
|
158
|
+
if (name === "ext-api-key") {
|
|
159
|
+
return "resolved-key";
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
},
|
|
163
|
+
name: "partial-provider",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const resolved = await TestConfigModel.resolveSecrets(provider);
|
|
167
|
+
expect(resolved.size).toBe(1);
|
|
168
|
+
expect(resolved.get("apiKey")).toBe("resolved-key");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("singleton behavior (requires MongoDB)", () => {
|
|
173
|
+
let dbConnected = false;
|
|
174
|
+
|
|
175
|
+
beforeAll(async () => {
|
|
176
|
+
try {
|
|
177
|
+
if (mongoose.connection.readyState === 1) {
|
|
178
|
+
dbConnected = true;
|
|
179
|
+
} else {
|
|
180
|
+
await mongoose.connect("mongodb://127.0.0.1/terreno-config-test", {
|
|
181
|
+
connectTimeoutMS: 3000,
|
|
182
|
+
serverSelectionTimeoutMS: 3000,
|
|
183
|
+
});
|
|
184
|
+
dbConnected = true;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
dbConnected = false;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterAll(async () => {
|
|
192
|
+
if (dbConnected && mongoose.connection.readyState === 1) {
|
|
193
|
+
try {
|
|
194
|
+
await mongoose.connection.db?.dropDatabase();
|
|
195
|
+
} catch {
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
beforeEach(async () => {
|
|
202
|
+
if (!dbConnected) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await SimpleConfigModel.collection.drop();
|
|
207
|
+
} catch {
|
|
208
|
+
// Collection may not exist yet
|
|
209
|
+
}
|
|
210
|
+
await SimpleConfigModel.ensureIndexes();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("creates a document via getConfig when none exists", async () => {
|
|
214
|
+
if (!dbConnected) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const config = await SimpleConfigModel.getConfig();
|
|
218
|
+
expect(config).toBeDefined();
|
|
219
|
+
expect(config.value).toBe("default");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns the same document on subsequent getConfig calls", async () => {
|
|
223
|
+
if (!dbConnected) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const first = await SimpleConfigModel.getConfig();
|
|
227
|
+
const second = await SimpleConfigModel.getConfig();
|
|
228
|
+
expect(first._id.toString()).toBe(second._id.toString());
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("prevents creating a second document via save", async () => {
|
|
232
|
+
if (!dbConnected) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
await SimpleConfigModel.getConfig();
|
|
236
|
+
const duplicate = new SimpleConfigModel({value: "duplicate"});
|
|
237
|
+
await expect(duplicate.save()).rejects.toThrow();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("updates an existing document via updateConfig", async () => {
|
|
241
|
+
if (!dbConnected) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await SimpleConfigModel.getConfig();
|
|
245
|
+
const updated = await SimpleConfigModel.updateConfig({value: "updated"});
|
|
246
|
+
expect(updated.value).toBe("updated");
|
|
247
|
+
|
|
248
|
+
const count = await SimpleConfigModel.countDocuments();
|
|
249
|
+
expect(count).toBe(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("creates a document with values if none exists via updateConfig", async () => {
|
|
253
|
+
if (!dbConnected) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const config = await SimpleConfigModel.updateConfig({value: "custom"});
|
|
257
|
+
expect(config.value).toBe("custom");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("prevents deleteOne", async () => {
|
|
261
|
+
if (!dbConnected) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
await SimpleConfigModel.getConfig();
|
|
265
|
+
try {
|
|
266
|
+
await SimpleConfigModel.deleteOne({}).exec();
|
|
267
|
+
expect.unreachable("Should have thrown");
|
|
268
|
+
} catch (err: any) {
|
|
269
|
+
expect(err.title).toMatch(/Cannot hard-delete the configuration document/);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("prevents findOneAndDelete", async () => {
|
|
274
|
+
if (!dbConnected) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await SimpleConfigModel.getConfig();
|
|
278
|
+
try {
|
|
279
|
+
await SimpleConfigModel.findOneAndDelete({}).exec();
|
|
280
|
+
expect.unreachable("Should have thrown");
|
|
281
|
+
} catch (err: any) {
|
|
282
|
+
expect(err.title).toMatch(/Cannot hard-delete the configuration document/);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("prevents deleteMany", async () => {
|
|
287
|
+
if (!dbConnected) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
await SimpleConfigModel.getConfig();
|
|
291
|
+
try {
|
|
292
|
+
await SimpleConfigModel.deleteMany({}).exec();
|
|
293
|
+
expect.unreachable("Should have thrown");
|
|
294
|
+
} catch (err: any) {
|
|
295
|
+
expect(err.title).toMatch(/Cannot hard-delete the configuration document/);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|