@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.
Files changed (42) hide show
  1. package/dist/api.js +9 -8
  2. package/dist/betterAuthSetup.js +1 -1
  3. package/dist/configuration.test.d.ts +1 -0
  4. package/dist/configuration.test.js +699 -0
  5. package/dist/configurationApp.d.ts +91 -0
  6. package/dist/configurationApp.js +407 -0
  7. package/dist/configurationPlugin.d.ts +102 -0
  8. package/dist/configurationPlugin.js +285 -0
  9. package/dist/configurationPlugin.test.d.ts +1 -0
  10. package/dist/configurationPlugin.test.js +509 -0
  11. package/dist/example.js +1 -1
  12. package/dist/expressServer.js +5 -1
  13. package/dist/githubAuth.js +2 -2
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +5 -0
  16. package/dist/openApiCompat.d.ts +23 -0
  17. package/dist/openApiCompat.js +198 -0
  18. package/dist/scriptRunner.d.ts +52 -0
  19. package/dist/scriptRunner.js +231 -0
  20. package/dist/secretProviders.d.ts +47 -0
  21. package/dist/secretProviders.js +214 -0
  22. package/dist/terrenoApp.d.ts +25 -0
  23. package/dist/terrenoApp.js +49 -2
  24. package/dist/tests.d.ts +27 -9
  25. package/dist/tests.js +10 -1
  26. package/package.json +13 -13
  27. package/src/api.ts +9 -8
  28. package/src/betterAuthSetup.ts +2 -2
  29. package/src/configuration.test.ts +398 -0
  30. package/src/configurationApp.ts +359 -0
  31. package/src/configurationPlugin.test.ts +299 -0
  32. package/src/configurationPlugin.ts +288 -0
  33. package/src/example.ts +1 -1
  34. package/src/expressServer.ts +6 -1
  35. package/src/githubAuth.ts +4 -4
  36. package/src/index.ts +5 -0
  37. package/src/openApiCompat.ts +147 -0
  38. package/src/permissions.ts +1 -1
  39. package/src/scriptRunner.ts +219 -0
  40. package/src/secretProviders.ts +109 -0
  41. package/src/terrenoApp.ts +44 -2
  42. 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
+ });