@terreno/api 0.9.3 → 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.
- package/bunfig.toml +5 -2
- package/bunfig.unit.toml +3 -0
- package/dist/auth.test.js +257 -0
- package/dist/consentApp.test.js +245 -0
- package/dist/expressServer.js +3 -9
- package/dist/expressServer.test.js +4 -7
- package/dist/githubAuth.test.js +380 -0
- package/dist/logger.test.d.ts +1 -0
- package/dist/logger.test.js +143 -0
- package/dist/notifiers/googleChatNotifier.test.js +37 -0
- package/dist/openApi.js +2 -2
- package/dist/openApi.test.js +125 -0
- package/dist/openApiBuilder.d.ts +1 -0
- package/dist/openApiBuilder.js +13 -2
- package/dist/openApiBuilder.test.js +66 -0
- package/dist/openApiEtag.test.js +8 -0
- package/dist/openApiValidator.test.js +309 -0
- package/dist/permissions.middleware.test.d.ts +1 -0
- package/dist/permissions.middleware.test.js +341 -0
- package/dist/plugins.d.ts +8 -8
- package/dist/plugins.js +38 -32
- package/dist/populate.test.js +99 -0
- package/dist/syncConsents.js +2 -2
- package/dist/syncConsents.test.js +273 -0
- package/dist/tests/bunSetup.js +27 -22
- package/dist/tests.d.ts +3 -3
- package/dist/tests.js +78 -82
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +7 -7
- package/package.json +2 -1
- package/src/__snapshots__/openApi.test.ts.snap +48 -0
- package/src/auth.test.ts +147 -0
- package/src/consentApp.test.ts +162 -0
- package/src/expressServer.test.ts +4 -11
- package/src/expressServer.ts +4 -8
- package/src/githubAuth.test.ts +307 -1
- package/src/logger.test.ts +149 -0
- package/src/notifiers/googleChatNotifier.test.ts +24 -0
- package/src/openApi.test.ts +157 -1
- package/src/openApi.ts +6 -2
- package/src/openApiBuilder.test.ts +81 -0
- package/src/openApiBuilder.ts +17 -2
- package/src/openApiEtag.test.ts +11 -0
- package/src/openApiValidator.test.ts +410 -0
- package/src/permissions.middleware.test.ts +197 -0
- package/src/plugins.ts +32 -23
- package/src/populate.test.ts +78 -2
- package/src/syncConsents.test.ts +145 -0
- package/src/syncConsents.ts +1 -1
- package/src/tests/bunSetup.ts +14 -8
- package/src/tests.ts +8 -8
- package/src/utils.ts +4 -4
|
@@ -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
|
});
|
package/src/openApi.test.ts
CHANGED
|
@@ -7,11 +7,23 @@ 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
|
|
|
13
21
|
function getMessageSummaryOpenApiMiddleware(options: Partial<ModelRouterOptions<any>>): any {
|
|
14
|
-
|
|
22
|
+
if (!options.openApi) {
|
|
23
|
+
throw new Error("Expected openApi to be configured for test routes");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return options.openApi.path({
|
|
15
27
|
parameters: [
|
|
16
28
|
{
|
|
17
29
|
in: "query",
|
|
@@ -332,3 +344,147 @@ describe("openApi populate", () => {
|
|
|
332
344
|
expect(res.body).toMatchSnapshot();
|
|
333
345
|
});
|
|
334
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(
|
|
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(
|
|
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
|
+
});
|
package/src/openApiBuilder.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
package/src/openApiEtag.test.ts
CHANGED
|
@@ -61,6 +61,17 @@ describe("openApiEtagMiddleware", () => {
|
|
|
61
61
|
expect(res.json).toBe(originalJson);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("skips GET requests for non-openapi.json paths", () => {
|
|
65
|
+
const req = buildRequest({method: "GET", path: "/health"});
|
|
66
|
+
const {res, originalJson} = buildResponse();
|
|
67
|
+
const next = mock(() => {}) as NextFunction;
|
|
68
|
+
|
|
69
|
+
openApiEtagMiddleware(req, res, next);
|
|
70
|
+
|
|
71
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(res.json).toBe(originalJson);
|
|
73
|
+
});
|
|
74
|
+
|
|
64
75
|
it("sets ETag and returns json body when no matching If-None-Match header is provided", () => {
|
|
65
76
|
const req = buildRequest();
|
|
66
77
|
const {res, originalJson, set, status, end} = buildResponse();
|