@terreno/api 0.10.0 → 0.11.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.
@@ -0,0 +1,149 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import winston from "winston";
6
+
7
+ import {logger, setupLogging} from "./logger";
8
+
9
+ describe("logger", () => {
10
+ const OLD_ENV = process.env;
11
+
12
+ beforeEach(() => {
13
+ process.env = {...OLD_ENV};
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.env = OLD_ENV;
18
+ });
19
+
20
+ it("logger.info writes a log entry", () => {
21
+ expect(() => logger.info("test info message")).not.toThrow();
22
+ });
23
+
24
+ it("logger.warn writes a log entry", () => {
25
+ expect(() => logger.warn("test warn message")).not.toThrow();
26
+ });
27
+
28
+ it("logger.error writes a log entry", () => {
29
+ expect(() => logger.error("test error message")).not.toThrow();
30
+ });
31
+
32
+ it("logger.debug writes a log entry", () => {
33
+ expect(() => logger.debug("test debug message")).not.toThrow();
34
+ });
35
+
36
+ it("logger.catch handles Error instance", () => {
37
+ expect(() => logger.catch(new Error("caught error"))).not.toThrow();
38
+ });
39
+
40
+ it("logger.catch handles non-Error value", () => {
41
+ expect(() => logger.catch("string error")).not.toThrow();
42
+ });
43
+
44
+ it("logger.catch with Sentry logging enabled and Error", () => {
45
+ process.env.USE_SENTRY_LOGGING = "true";
46
+ expect(() => logger.catch(new Error("captured"))).not.toThrow();
47
+ });
48
+ });
49
+
50
+ describe("setupLogging", () => {
51
+ let tempDir: string;
52
+
53
+ beforeEach(() => {
54
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logger-test-"));
55
+ });
56
+
57
+ afterEach(() => {
58
+ try {
59
+ fs.rmSync(tempDir, {force: true, recursive: true});
60
+ } catch {}
61
+ // Restore a default logger config
62
+ setupLogging({disableFileLogging: true});
63
+ });
64
+
65
+ it("disables console logging when disableConsoleLogging is true", () => {
66
+ expect(() =>
67
+ setupLogging({
68
+ disableConsoleLogging: true,
69
+ disableFileLogging: true,
70
+ })
71
+ ).not.toThrow();
72
+ });
73
+
74
+ it("disables console colors when disableConsoleColors is true", () => {
75
+ expect(() =>
76
+ setupLogging({
77
+ disableConsoleColors: true,
78
+ disableFileLogging: true,
79
+ })
80
+ ).not.toThrow();
81
+ });
82
+
83
+ it("adds timestamps when showConsoleTimestamps is true", () => {
84
+ expect(() =>
85
+ setupLogging({
86
+ disableFileLogging: true,
87
+ showConsoleTimestamps: true,
88
+ })
89
+ ).not.toThrow();
90
+ });
91
+
92
+ it("respects logLevel option", () => {
93
+ expect(() =>
94
+ setupLogging({
95
+ disableFileLogging: true,
96
+ level: "info",
97
+ })
98
+ ).not.toThrow();
99
+ });
100
+
101
+ it("creates log directory if it does not exist", () => {
102
+ const nonExistentDir = path.join(tempDir, "nested", "logs");
103
+ setupLogging({
104
+ disableConsoleLogging: true,
105
+ logDirectory: nonExistentDir,
106
+ });
107
+ expect(fs.existsSync(nonExistentDir)).toBe(true);
108
+ });
109
+
110
+ it("uses existing log directory if it exists", () => {
111
+ const existingDir = path.join(tempDir, "existing");
112
+ fs.mkdirSync(existingDir);
113
+ expect(() =>
114
+ setupLogging({
115
+ disableConsoleLogging: true,
116
+ logDirectory: existingDir,
117
+ })
118
+ ).not.toThrow();
119
+ });
120
+
121
+ it("adds custom transports when provided", () => {
122
+ const customTransport = new winston.transports.Console({level: "error"});
123
+ expect(() =>
124
+ setupLogging({
125
+ disableConsoleLogging: true,
126
+ disableFileLogging: true,
127
+ transports: [customTransport],
128
+ })
129
+ ).not.toThrow();
130
+ });
131
+
132
+ it("uses file logging at info level when level is info (no debug file)", () => {
133
+ setupLogging({
134
+ disableConsoleLogging: true,
135
+ level: "info",
136
+ logDirectory: tempDir,
137
+ });
138
+ // No assertion needed - just verifying branch coverage with level=info
139
+ expect(true).toBe(true);
140
+ });
141
+
142
+ it("uses file logging at debug level by default (with debug file)", () => {
143
+ setupLogging({
144
+ disableConsoleLogging: true,
145
+ logDirectory: tempDir,
146
+ });
147
+ expect(true).toBe(true);
148
+ });
149
+ });
@@ -111,4 +111,28 @@ describe("sendToGoogleChat", () => {
111
111
  await sendToGoogleChat("err", {shouldThrow: false});
112
112
  expect(mockAxiosPost.mock.calls.length).toBe(1);
113
113
  });
114
+
115
+ it("returns early when webhook url is missing for channel and no default exists", async () => {
116
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
117
+ ops: "https://chat.example/ops",
118
+ });
119
+
120
+ await sendToGoogleChat("no default", {channel: "missing"});
121
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
122
+ expect(Sentry.captureException).toHaveBeenCalled();
123
+ });
124
+
125
+ it("prefixes message with [ENV] and posts when channel matches", async () => {
126
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
127
+ default: "https://chat.example/default",
128
+ ops: "https://chat.example/ops",
129
+ });
130
+ mockAxiosPost.mockResolvedValue({status: 200});
131
+
132
+ await sendToGoogleChat("deploy complete", {channel: "ops", env: "staging"});
133
+ const callArgs = mockAxiosPost.mock.calls[0];
134
+ const [url, payload] = callArgs;
135
+ expect(url).toBe("https://chat.example/ops");
136
+ expect(payload).toEqual({text: "[STAGING] deploy complete"});
137
+ });
114
138
  });
@@ -7,6 +7,14 @@ import type TestAgent from "supertest/lib/agent";
7
7
  import {type ModelRouterOptions, modelRouter} from "./api";
8
8
  import {addAuthRoutes, setupAuth} from "./auth";
9
9
  import {setupServer} from "./expressServer";
10
+ import {
11
+ createOpenApiMiddleware,
12
+ deleteOpenApiMiddleware,
13
+ getOpenApiMiddleware,
14
+ listOpenApiMiddleware,
15
+ patchOpenApiMiddleware,
16
+ readOpenApiMiddleware,
17
+ } from "./openApi";
10
18
  import {Permissions} from "./permissions";
11
19
  import {FoodModel, setupDb, UserModel} from "./tests";
12
20
 
@@ -336,3 +344,147 @@ describe("openApi populate", () => {
336
344
  expect(res.body).toMatchSnapshot();
337
345
  });
338
346
  });
347
+
348
+ describe("openApi middleware no-op paths", () => {
349
+ it("getOpenApiMiddleware returns noop when openApi not configured", () => {
350
+ const mw = getOpenApiMiddleware(FoodModel as any, {});
351
+ expect(typeof mw).toBe("function");
352
+ expect(mw.length).toBe(3);
353
+ });
354
+
355
+ it("getOpenApiMiddleware returns noop when read permissions are empty", () => {
356
+ const mw = getOpenApiMiddleware(FoodModel as any, {
357
+ openApi: {component: () => {}, path: () => (() => {}) as any} as any,
358
+ permissions: {
359
+ create: [],
360
+ delete: [],
361
+ list: [],
362
+ read: [],
363
+ update: [],
364
+ },
365
+ });
366
+ expect(typeof mw).toBe("function");
367
+ expect(mw.length).toBe(3);
368
+ });
369
+
370
+ it("listOpenApiMiddleware returns noop when openApi not configured", () => {
371
+ const mw = listOpenApiMiddleware(FoodModel as any, {});
372
+ expect(mw.length).toBe(3);
373
+ });
374
+
375
+ it("listOpenApiMiddleware returns noop when list permissions are empty", () => {
376
+ const mw = listOpenApiMiddleware(FoodModel as any, {
377
+ openApi: {path: () => (() => {}) as any} as any,
378
+ permissions: {
379
+ create: [],
380
+ delete: [],
381
+ list: [],
382
+ read: [],
383
+ update: [],
384
+ },
385
+ });
386
+ expect(mw.length).toBe(3);
387
+ });
388
+
389
+ it("createOpenApiMiddleware returns noop when openApi not configured", () => {
390
+ const mw = createOpenApiMiddleware(FoodModel as any, {});
391
+ expect(mw.length).toBe(3);
392
+ });
393
+
394
+ it("createOpenApiMiddleware returns noop when create permissions are empty", () => {
395
+ const mw = createOpenApiMiddleware(FoodModel as any, {
396
+ openApi: {path: () => (() => {}) as any} as any,
397
+ permissions: {
398
+ create: [],
399
+ delete: [],
400
+ list: [],
401
+ read: [],
402
+ update: [],
403
+ },
404
+ });
405
+ expect(mw.length).toBe(3);
406
+ });
407
+
408
+ it("patchOpenApiMiddleware returns noop when openApi not configured", () => {
409
+ const mw = patchOpenApiMiddleware(FoodModel as any, {});
410
+ expect(mw.length).toBe(3);
411
+ });
412
+
413
+ it("patchOpenApiMiddleware returns noop when update permissions are empty", () => {
414
+ const mw = patchOpenApiMiddleware(FoodModel as any, {
415
+ openApi: {path: () => (() => {}) as any} as any,
416
+ permissions: {
417
+ create: [],
418
+ delete: [],
419
+ list: [],
420
+ read: [],
421
+ update: [],
422
+ },
423
+ });
424
+ expect(mw.length).toBe(3);
425
+ });
426
+
427
+ it("deleteOpenApiMiddleware returns noop when openApi not configured", () => {
428
+ const mw = deleteOpenApiMiddleware(FoodModel as any, {});
429
+ expect(mw.length).toBe(3);
430
+ });
431
+
432
+ it("deleteOpenApiMiddleware returns noop when delete permissions are empty", () => {
433
+ const mw = deleteOpenApiMiddleware(FoodModel as any, {
434
+ openApi: {path: () => (() => {}) as any} as any,
435
+ permissions: {
436
+ create: [],
437
+ delete: [],
438
+ list: [],
439
+ read: [],
440
+ update: [],
441
+ },
442
+ });
443
+ expect(mw.length).toBe(3);
444
+ });
445
+
446
+ it("readOpenApiMiddleware returns noop when openApi not configured", () => {
447
+ const mw = readOpenApiMiddleware({}, {}, [], []);
448
+ expect(mw.length).toBe(3);
449
+ });
450
+
451
+ it("readOpenApiMiddleware returns noop when read permissions are empty", () => {
452
+ const mw = readOpenApiMiddleware(
453
+ {
454
+ openApi: {path: () => (() => {}) as any} as any,
455
+ permissions: {
456
+ create: [],
457
+ delete: [],
458
+ list: [],
459
+ read: [],
460
+ update: [],
461
+ },
462
+ },
463
+ {id: {type: "string"}},
464
+ ["id"],
465
+ []
466
+ );
467
+ expect(mw.length).toBe(3);
468
+ });
469
+
470
+ it("readOpenApiMiddleware returns middleware when configured with permissions", () => {
471
+ // Use a simple path stub that returns a middleware function
472
+ const pathFn = () => ((_req: any, _res: any, next: any) => next()) as any;
473
+ const mw = readOpenApiMiddleware(
474
+ {
475
+ openApi: {path: pathFn} as any,
476
+ permissions: {
477
+ create: [],
478
+ delete: [],
479
+ list: [],
480
+ read: [Permissions.IsAny],
481
+ update: [],
482
+ },
483
+ },
484
+ {name: {type: "string"}},
485
+ ["name"],
486
+ [{in: "query", name: "search", schema: {type: "string"}}]
487
+ );
488
+ expect(typeof mw).toBe("function");
489
+ });
490
+ });
package/src/openApi.ts CHANGED
@@ -120,7 +120,9 @@ export function getOpenApiMiddleware<T>(
120
120
  createAPIErrorComponent(options.openApi);
121
121
  if (!options.openApi?.path) {
122
122
  // Just log this once rather than for each middleware.
123
- logger.debug("No options.openApi provided, skipping *OpenApiMiddleware");
123
+ logger.debug(
124
+ `No options.openApi provided for model "${model.modelName}" in getOpenApiMiddleware, skipping *OpenApiMiddleware`
125
+ );
124
126
  return noop;
125
127
  }
126
128
 
@@ -466,7 +468,9 @@ export function readOpenApiMiddleware<T>(
466
468
  ): any {
467
469
  if (!options.openApi?.path) {
468
470
  // Just log this once rather than for each middleware.
469
- logger.debug("No options.openApi provided, skipping *OpenApiMiddleware");
471
+ logger.debug(
472
+ "No options.openApi provided in readOpenApiMiddleware, skipping *OpenApiMiddleware"
473
+ );
470
474
  return noop;
471
475
  }
472
476
 
@@ -440,3 +440,84 @@ describe("OpenApiMiddlewareBuilder configuration", () => {
440
440
  expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
441
441
  });
442
442
  });
443
+
444
+ describe("OpenApiMiddlewareBuilder withValidation / buildWithSchemas", () => {
445
+ beforeEach(() => {
446
+ const {resetOpenApiValidatorConfig} = require("./openApiValidator");
447
+ resetOpenApiValidatorConfig();
448
+ });
449
+
450
+ it("buildWithSchemas returns bodySchema and querySchema", () => {
451
+ const result = createOpenApiBuilder({})
452
+ .withRequestBody({name: {required: true, type: "string"}})
453
+ .withQueryParameter("page", {type: "number"})
454
+ .buildWithSchemas();
455
+
456
+ expect(result.bodySchema).toBeDefined();
457
+ expect(result.bodySchema?.name).toBeDefined();
458
+ expect(result.querySchema).toBeDefined();
459
+ expect(result.querySchema?.page).toBeDefined();
460
+ expect(typeof result.middleware).toBe("function");
461
+ });
462
+
463
+ it("buildWithSchemas returns undefined schemas when no body/query defined", () => {
464
+ const result = createOpenApiBuilder({}).buildWithSchemas();
465
+ expect(result.bodySchema).toBeUndefined();
466
+ expect(result.querySchema).toBeUndefined();
467
+ });
468
+
469
+ it("withValidation with defaults enables body and query validation", () => {
470
+ const result = createOpenApiBuilder({})
471
+ .withRequestBody({name: {required: true, type: "string"}})
472
+ .withQueryParameter("page", {type: "number"})
473
+ .withValidation()
474
+ .buildWithSchemas();
475
+
476
+ expect(result.validationEnabled).toBe(true);
477
+ });
478
+
479
+ it("withValidation with enabled=false disables validation", () => {
480
+ const result = createOpenApiBuilder({})
481
+ .withRequestBody({name: {required: true, type: "string"}})
482
+ .withValidation({enabled: false})
483
+ .buildWithSchemas();
484
+
485
+ expect(result.validationEnabled).toBe(false);
486
+ });
487
+
488
+ it("build() returns an array with validators when validation is enabled", () => {
489
+ const result = createOpenApiBuilder({})
490
+ .withRequestBody({name: {required: true, type: "string"}})
491
+ .withQueryParameter("page", {type: "number"})
492
+ .withValidation()
493
+ .build();
494
+
495
+ expect(Array.isArray(result)).toBe(true);
496
+ expect(result.length).toBeGreaterThan(1);
497
+ });
498
+
499
+ it("build() returns a single middleware when validation is disabled", () => {
500
+ const result = createOpenApiBuilder({})
501
+ .withRequestBody({name: {required: true, type: "string"}})
502
+ .build();
503
+
504
+ expect(typeof result).toBe("function");
505
+ });
506
+
507
+ it("build() falls back to single middleware when there is nothing to validate", () => {
508
+ const result = createOpenApiBuilder({}).withValidation().build();
509
+
510
+ expect(typeof result).toBe("function");
511
+ });
512
+
513
+ it("withValidation options body=false and query=false is respected", () => {
514
+ const result = createOpenApiBuilder({})
515
+ .withRequestBody({name: {required: true, type: "string"}})
516
+ .withQueryParameter("page", {type: "number"})
517
+ .withValidation({body: false, query: false})
518
+ .build();
519
+
520
+ // No body/query validation = just the openApi middleware (single fn)
521
+ expect(typeof result).toBe("function");
522
+ });
523
+ });
@@ -310,6 +310,17 @@ export class OpenApiMiddlewareBuilder {
310
310
  this.validationConfig = {};
311
311
  }
312
312
 
313
+ private describeRoute(): string {
314
+ const parts: string[] = [];
315
+ if (this.config.summary) {
316
+ parts.push(`"${this.config.summary}"`);
317
+ }
318
+ if (this.config.tags?.length) {
319
+ parts.push(`tags=[${this.config.tags.join(", ")}]`);
320
+ }
321
+ return parts.length > 0 ? parts.join(" ") : "unnamed route";
322
+ }
323
+
313
324
  /**
314
325
  * Sets the tags for the OpenAPI operation.
315
326
  *
@@ -678,7 +689,9 @@ export class OpenApiMiddlewareBuilder {
678
689
  )
679
690
  );
680
691
  } else {
681
- logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
692
+ logger.debug(
693
+ `No options.openApi provided in buildWithSchemas for ${this.describeRoute()}, skipping OpenApiMiddleware`
694
+ );
682
695
  }
683
696
 
684
697
  const globalConfig = getOpenApiValidatorConfig();
@@ -739,7 +752,9 @@ export class OpenApiMiddlewareBuilder {
739
752
  )
740
753
  );
741
754
  } else {
742
- logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
755
+ logger.debug(
756
+ `No options.openApi provided in build for ${this.describeRoute()}, skipping OpenApiMiddleware`
757
+ );
743
758
  }
744
759
 
745
760
  // Check if validation should be enabled