@terreno/api 0.3.1 → 0.4.2

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,398 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import type express from "express";
3
+ import mongoose, {Schema} from "mongoose";
4
+ import supertest from "supertest";
5
+ import type TestAgent from "supertest/lib/agent";
6
+
7
+ import {addAuthRoutes, setupAuth} from "./auth";
8
+ import {ConfigurationApp} from "./configurationApp";
9
+ import {type ConfigurationModel, configurationPlugin} from "./configurationPlugin";
10
+ import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
11
+ import {createdUpdatedPlugin} from "./plugins";
12
+ import {authAsUser, getBaseServer, setupDb, UserModel} from "./tests";
13
+
14
+ // -- Test configuration model --
15
+
16
+ const generalSchema = new Schema(
17
+ {
18
+ appName: {
19
+ default: "Test App",
20
+ description: "Display name of the application",
21
+ type: String,
22
+ },
23
+ maintenanceMode: {
24
+ default: false,
25
+ description: "Enable maintenance mode",
26
+ type: Boolean,
27
+ },
28
+ },
29
+ {_id: false}
30
+ );
31
+
32
+ const integrationsSchema = new Schema(
33
+ {
34
+ apiKey: {
35
+ default: "",
36
+ description: "External API key",
37
+ secret: true,
38
+ secretName: "external-api-key",
39
+ type: String,
40
+ },
41
+ webhookUrl: {
42
+ default: "https://example.com/hook",
43
+ description: "Webhook URL",
44
+ type: String,
45
+ },
46
+ },
47
+ {_id: false}
48
+ );
49
+
50
+ interface TestConfigDocument {
51
+ general: {appName: string; maintenanceMode: boolean};
52
+ integrations: {apiKey: string; webhookUrl: string};
53
+ }
54
+
55
+ const testConfigSchema = new Schema<TestConfigDocument>(
56
+ {
57
+ general: {default: () => ({}), description: "General settings", type: generalSchema},
58
+ integrations: {
59
+ default: () => ({}),
60
+ description: "Integration settings",
61
+ type: integrationsSchema,
62
+ },
63
+ },
64
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
65
+ );
66
+
67
+ testConfigSchema.plugin(configurationPlugin);
68
+ testConfigSchema.plugin(createdUpdatedPlugin);
69
+
70
+ const TestConfig = (mongoose.models.TestConfig ||
71
+ mongoose.model("TestConfig", testConfigSchema)) as ConfigurationModel<TestConfigDocument>;
72
+
73
+ // -- Test model with top-level scalar fields --
74
+
75
+ const scalarConfigSchema = new Schema(
76
+ {
77
+ debugMode: {default: false, description: "Enable debug", type: Boolean},
78
+ siteName: {default: "My Site", description: "Site name", type: String},
79
+ },
80
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
81
+ );
82
+ scalarConfigSchema.plugin(configurationPlugin);
83
+ scalarConfigSchema.plugin(createdUpdatedPlugin);
84
+
85
+ const ScalarConfig = (mongoose.models.ScalarConfig ||
86
+ mongoose.model("ScalarConfig", scalarConfigSchema)) as ConfigurationModel<{
87
+ siteName: string;
88
+ debugMode: boolean;
89
+ }>;
90
+
91
+ // -- Helpers --
92
+
93
+ const buildApp = (
94
+ configModel: mongoose.Model<any>,
95
+ options?: {basePath?: string; fieldOverrides?: Record<string, {widget?: string}>}
96
+ ): express.Application => {
97
+ const app = getBaseServer();
98
+ setupAuth(app, UserModel as any);
99
+ addAuthRoutes(app, UserModel as any);
100
+
101
+ const configApp = new ConfigurationApp({
102
+ basePath: options?.basePath,
103
+ fieldOverrides: options?.fieldOverrides,
104
+ model: configModel,
105
+ });
106
+ configApp.register(app);
107
+
108
+ app.use(apiUnauthorizedMiddleware);
109
+ app.use(apiErrorMiddleware);
110
+ return app;
111
+ };
112
+
113
+ // -- Tests --
114
+
115
+ describe("configurationPlugin", () => {
116
+ beforeEach(async () => {
117
+ // Direct MongoDB collection delete to bypass plugin hooks
118
+ await mongoose.connection.db?.collection("testconfigs").deleteMany({});
119
+ await mongoose.connection.db?.collection("scalarconfigs").deleteMany({});
120
+ });
121
+
122
+ describe("getConfig", () => {
123
+ it("creates a default document when none exists", async () => {
124
+ const config = await TestConfig.getConfig();
125
+ expect(config).toBeDefined();
126
+ expect(config.general.appName).toBe("Test App");
127
+ expect(config.general.maintenanceMode).toBe(false);
128
+ });
129
+
130
+ it("returns existing document on subsequent calls", async () => {
131
+ const first = await TestConfig.getConfig();
132
+ const second = await TestConfig.getConfig();
133
+ expect(first._id.toString()).toBe(second._id.toString());
134
+ });
135
+
136
+ it("returns a top-level section by key", async () => {
137
+ const general = await TestConfig.getConfig("general");
138
+ expect(general.appName).toBe("Test App");
139
+ expect(general.maintenanceMode).toBe(false);
140
+ });
141
+
142
+ it("returns a nested value by dot-notation key", async () => {
143
+ const appName = await TestConfig.getConfig("general.appName");
144
+ expect(appName).toBe("Test App");
145
+ });
146
+
147
+ it("returns updated value after updateConfig", async () => {
148
+ await TestConfig.updateConfig({general: {appName: "Updated", maintenanceMode: false}});
149
+ const appName = await TestConfig.getConfig("general.appName");
150
+ expect(appName).toBe("Updated");
151
+ });
152
+ });
153
+
154
+ describe("updateConfig", () => {
155
+ it("creates and sets values when no document exists", async () => {
156
+ const config = await TestConfig.updateConfig({
157
+ general: {appName: "Updated", maintenanceMode: false},
158
+ });
159
+ expect(config.general.appName).toBe("Updated");
160
+ });
161
+
162
+ it("updates existing document", async () => {
163
+ await TestConfig.getConfig();
164
+ const updated = await TestConfig.updateConfig({
165
+ general: {appName: "Changed", maintenanceMode: true},
166
+ });
167
+ expect(updated.general.appName).toBe("Changed");
168
+ expect(updated.general.maintenanceMode).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe("singleton enforcement", () => {
173
+ it("prevents creating a second document via save", async () => {
174
+ await TestConfig.getConfig();
175
+ try {
176
+ await TestConfig.create({});
177
+ throw new Error("Should have thrown");
178
+ } catch (error: any) {
179
+ expect(error.title || error.message).toInclude("Only one configuration document");
180
+ }
181
+ });
182
+ });
183
+
184
+ describe("hard delete prevention", () => {
185
+ it("blocks deleteOne", async () => {
186
+ await TestConfig.getConfig();
187
+ try {
188
+ await TestConfig.deleteOne({});
189
+ throw new Error("Should have thrown");
190
+ } catch (error: any) {
191
+ expect(error.title || error.message).toInclude("Cannot hard-delete");
192
+ }
193
+ });
194
+
195
+ it("blocks deleteMany", async () => {
196
+ await TestConfig.getConfig();
197
+ try {
198
+ await TestConfig.deleteMany({});
199
+ throw new Error("Should have thrown");
200
+ } catch (error: any) {
201
+ expect(error.title || error.message).toInclude("Cannot hard-delete");
202
+ }
203
+ });
204
+
205
+ it("blocks findOneAndDelete", async () => {
206
+ await TestConfig.getConfig();
207
+ try {
208
+ await TestConfig.findOneAndDelete({});
209
+ throw new Error("Should have thrown");
210
+ } catch (error: any) {
211
+ expect(error.title || error.message).toInclude("Cannot hard-delete");
212
+ }
213
+ });
214
+ });
215
+
216
+ describe("getSecretFields", () => {
217
+ it("discovers secret fields from nested schemas", () => {
218
+ const secrets = TestConfig.getSecretFields();
219
+ expect(secrets).toHaveLength(1);
220
+ expect(secrets[0].path).toBe("integrations.apiKey");
221
+ expect(secrets[0].secretName).toBe("external-api-key");
222
+ });
223
+
224
+ it("returns empty array when no secrets", () => {
225
+ const secrets = ScalarConfig.getSecretFields();
226
+ expect(secrets).toHaveLength(0);
227
+ });
228
+ });
229
+
230
+ describe("resolveSecrets", () => {
231
+ it("resolves secrets from a provider", async () => {
232
+ const provider = {
233
+ getSecret: async (name: string) => (name === "external-api-key" ? "resolved-key" : null),
234
+ name: "test-provider",
235
+ };
236
+ const resolved = await TestConfig.resolveSecrets(provider);
237
+ expect(resolved.get("integrations.apiKey")).toBe("resolved-key");
238
+ });
239
+
240
+ it("handles provider failures gracefully", async () => {
241
+ const provider = {
242
+ getSecret: async () => {
243
+ throw new Error("Provider down");
244
+ },
245
+ name: "failing-provider",
246
+ };
247
+ const resolved = await TestConfig.resolveSecrets(provider);
248
+ expect(resolved.size).toBe(0);
249
+ });
250
+ });
251
+ });
252
+
253
+ describe("ConfigurationApp routes", () => {
254
+ let app: express.Application;
255
+ let adminAgent: TestAgent;
256
+ let notAdminAgent: TestAgent;
257
+
258
+ beforeEach(async () => {
259
+ await setupDb();
260
+ await mongoose.connection.db?.collection("testconfigs").deleteMany({});
261
+ app = buildApp(TestConfig);
262
+ adminAgent = await authAsUser(app, "admin");
263
+ notAdminAgent = await authAsUser(app, "notAdmin");
264
+ });
265
+
266
+ describe("GET /configuration/meta", () => {
267
+ it("returns schema metadata for admin", async () => {
268
+ const res = await adminAgent.get("/configuration/meta").expect(200);
269
+ expect(res.body.sections).toBeDefined();
270
+ expect(res.body.sections.length).toBeGreaterThan(0);
271
+
272
+ const generalSection = res.body.sections.find((s: any) => s.name === "general");
273
+ expect(generalSection).toBeDefined();
274
+ expect(generalSection.fields.appName).toBeDefined();
275
+ expect(generalSection.fields.appName.type).toBe("string");
276
+ });
277
+
278
+ it("marks secret fields in metadata", async () => {
279
+ const res = await adminAgent.get("/configuration/meta").expect(200);
280
+ const intSection = res.body.sections.find((s: any) => s.name === "integrations");
281
+ expect(intSection.fields.apiKey.secret).toBe(true);
282
+ expect(intSection.fields.webhookUrl.secret).toBeFalsy();
283
+ });
284
+
285
+ it("returns 403 for non-admin", async () => {
286
+ await notAdminAgent.get("/configuration/meta").expect(403);
287
+ });
288
+
289
+ it("returns 401 for unauthenticated user", async () => {
290
+ await supertest(app).get("/configuration/meta").expect(401);
291
+ });
292
+ });
293
+
294
+ describe("GET /configuration", () => {
295
+ it("returns config values with defaults", async () => {
296
+ const res = await adminAgent.get("/configuration").expect(200);
297
+ expect(res.body.data.general.appName).toBe("Test App");
298
+ expect(res.body.data.general.maintenanceMode).toBe(false);
299
+ });
300
+
301
+ it("redacts secret fields", async () => {
302
+ // Set a secret value first
303
+ await (TestConfig as any).updateConfig({integrations: {apiKey: "super-secret-key"}});
304
+ const res = await adminAgent.get("/configuration").expect(200);
305
+ expect(res.body.data.integrations.apiKey).toBe("********");
306
+ expect(res.body.data.integrations.webhookUrl).toBe("https://example.com/hook");
307
+ });
308
+
309
+ it("does not redact empty secret fields", async () => {
310
+ const res = await adminAgent.get("/configuration").expect(200);
311
+ // Empty string should not be redacted
312
+ expect(res.body.data.integrations.apiKey).toBe("");
313
+ });
314
+
315
+ it("returns 403 for non-admin", async () => {
316
+ await notAdminAgent.get("/configuration").expect(403);
317
+ });
318
+ });
319
+
320
+ describe("PATCH /configuration", () => {
321
+ it("updates configuration values", async () => {
322
+ const res = await adminAgent
323
+ .patch("/configuration")
324
+ .send({general: {appName: "New Name"}})
325
+ .expect(200);
326
+ expect(res.body.data.general.appName).toBe("New Name");
327
+ });
328
+
329
+ it("redacts secrets in the response", async () => {
330
+ const res = await adminAgent
331
+ .patch("/configuration")
332
+ .send({integrations: {apiKey: "new-secret"}})
333
+ .expect(200);
334
+ expect(res.body.data.integrations.apiKey).toBe("********");
335
+ });
336
+
337
+ it("returns 403 for non-admin", async () => {
338
+ await notAdminAgent
339
+ .patch("/configuration")
340
+ .send({general: {appName: "Hack"}})
341
+ .expect(403);
342
+ });
343
+ });
344
+
345
+ describe("POST /configuration/list-secrets", () => {
346
+ it("returns discovered secret fields", async () => {
347
+ const res = await adminAgent.post("/configuration/list-secrets").expect(200);
348
+ expect(res.body.secretFields).toHaveLength(1);
349
+ expect(res.body.secretFields[0].path).toBe("integrations.apiKey");
350
+ expect(res.body.secretFields[0].secretName).toBe("external-api-key");
351
+ });
352
+
353
+ it("returns 403 for non-admin", async () => {
354
+ await notAdminAgent.post("/configuration/list-secrets").expect(403);
355
+ });
356
+ });
357
+ });
358
+
359
+ describe("ConfigurationApp with scalar fields", () => {
360
+ let app: express.Application;
361
+ let adminAgent: TestAgent;
362
+
363
+ beforeEach(async () => {
364
+ await setupDb();
365
+ await mongoose.connection.db?.collection("scalarconfigs").deleteMany({});
366
+ app = buildApp(ScalarConfig);
367
+ adminAgent = await authAsUser(app, "admin");
368
+ });
369
+
370
+ it("puts scalar fields into __root__ section", async () => {
371
+ const res = await adminAgent.get("/configuration/meta").expect(200);
372
+ const rootSection = res.body.sections.find((s: any) => s.name === "__root__");
373
+ expect(rootSection).toBeDefined();
374
+ expect(rootSection.displayName).toBe("General");
375
+ expect(rootSection.fields.siteName).toBeDefined();
376
+ expect(rootSection.fields.debugMode).toBeDefined();
377
+ });
378
+ });
379
+
380
+ describe("ConfigurationApp with field overrides", () => {
381
+ let app: express.Application;
382
+ let adminAgent: TestAgent;
383
+
384
+ beforeEach(async () => {
385
+ await setupDb();
386
+ await mongoose.connection.db?.collection("testconfigs").deleteMany({});
387
+ app = buildApp(TestConfig, {
388
+ fieldOverrides: {"integrations.webhookUrl": {widget: "url"}},
389
+ });
390
+ adminAgent = await authAsUser(app, "admin");
391
+ });
392
+
393
+ it("applies widget overrides to metadata", async () => {
394
+ const res = await adminAgent.get("/configuration/meta").expect(200);
395
+ const intSection = res.body.sections.find((s: any) => s.name === "integrations");
396
+ expect(intSection.fields.webhookUrl.widget).toBe("url");
397
+ });
398
+ });