@terreno/api 0.9.2 → 0.10.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/src/openApi.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import type express from "express";
1
2
  import flatten from "lodash/flatten";
2
3
  import merge from "lodash/merge";
3
4
  import type {Model} from "mongoose";
4
5
  import m2s from "mongoose-to-swagger";
5
6
 
6
- import type {ModelRouterOptions} from "./api";
7
+ import type {ModelRouterOptions, OpenApiMiddleware} from "./api";
7
8
  import {logger} from "./logger";
8
9
  import {getOpenApiSpecForModel} from "./populate";
9
10
 
@@ -43,7 +44,7 @@ export const defaultOpenApiErrorResponses = {
43
44
  };
44
45
 
45
46
  // We repeat this constantly, so we make it a component so we only have to define it once.
46
- function createAPIErrorComponent(openApi: any) {
47
+ function createAPIErrorComponent(openApi?: OpenApiMiddleware) {
47
48
  // Create a schema component called APIError
48
49
  openApi?.component("schemas", "APIError", {
49
50
  properties: {
@@ -112,7 +113,10 @@ function createAPIErrorComponent(openApi: any) {
112
113
  });
113
114
  }
114
115
 
115
- export function getOpenApiMiddleware<T>(model: Model<T>, options: Partial<ModelRouterOptions<T>>) {
116
+ export function getOpenApiMiddleware<T>(
117
+ model: Model<T>,
118
+ options: Partial<ModelRouterOptions<T>>
119
+ ): express.RequestHandler {
116
120
  createAPIErrorComponent(options.openApi);
117
121
  if (!options.openApi?.path) {
118
122
  // Just log this once rather than for each middleware.
@@ -154,7 +158,10 @@ export function getOpenApiMiddleware<T>(model: Model<T>, options: Partial<ModelR
154
158
  );
155
159
  }
156
160
 
157
- export function listOpenApiMiddleware<T>(model: Model<T>, options: Partial<ModelRouterOptions<T>>) {
161
+ export function listOpenApiMiddleware<T>(
162
+ model: Model<T>,
163
+ options: Partial<ModelRouterOptions<T>>
164
+ ): express.RequestHandler {
158
165
  if (!options.openApi?.path) {
159
166
  return noop;
160
167
  }
@@ -320,7 +327,7 @@ export function listOpenApiMiddleware<T>(model: Model<T>, options: Partial<Model
320
327
  export function createOpenApiMiddleware<T>(
321
328
  model: Model<T>,
322
329
  options: Partial<ModelRouterOptions<T>>
323
- ) {
330
+ ): express.RequestHandler {
324
331
  if (!options.openApi?.path) {
325
332
  return noop;
326
333
  }
@@ -372,7 +379,7 @@ export function createOpenApiMiddleware<T>(
372
379
  export function patchOpenApiMiddleware<T>(
373
380
  model: Model<T>,
374
381
  options: Partial<ModelRouterOptions<T>>
375
- ) {
382
+ ): express.RequestHandler {
376
383
  if (!options.openApi?.path) {
377
384
  return noop;
378
385
  }
@@ -424,7 +431,7 @@ export function patchOpenApiMiddleware<T>(
424
431
  export function deleteOpenApiMiddleware<T>(
425
432
  model: Model<T>,
426
433
  options: Partial<ModelRouterOptions<T>>
427
- ) {
434
+ ): express.RequestHandler {
428
435
  if (!options.openApi?.path) {
429
436
  return noop;
430
437
  }
@@ -283,7 +283,7 @@ export interface OpenApiBuildResult {
283
283
  */
284
284
  export class OpenApiMiddlewareBuilder {
285
285
  /** Router options containing OpenAPI configuration */
286
- private options: Partial<ModelRouterOptions<any>>;
286
+ private options: Partial<ModelRouterOptions<unknown>>;
287
287
 
288
288
  /** Accumulated OpenAPI configuration from builder methods */
289
289
  private config: OpenApiConfig;
@@ -302,7 +302,7 @@ export class OpenApiMiddlewareBuilder {
302
302
  *
303
303
  * @param options - Router options containing the OpenAPI path configuration
304
304
  */
305
- constructor(options: Partial<ModelRouterOptions<any>>) {
305
+ constructor(options: Partial<ModelRouterOptions<unknown>>) {
306
306
  this.options = options;
307
307
  this.config = {
308
308
  responses: {},
@@ -803,7 +803,7 @@ export class OpenApiMiddlewareBuilder {
803
803
  * ```
804
804
  */
805
805
  export function createOpenApiBuilder(
806
- options: Partial<ModelRouterOptions<any>>
806
+ options: Partial<ModelRouterOptions<unknown>>
807
807
  ): OpenApiMiddlewareBuilder {
808
808
  return new OpenApiMiddlewareBuilder(options);
809
809
  }
@@ -0,0 +1,112 @@
1
+ import {describe, expect, it, mock} from "bun:test";
2
+ import crypto from "node:crypto";
3
+ import type {NextFunction, Request, Response} from "express";
4
+
5
+ import {openApiEtagMiddleware} from "./openApiEtag";
6
+
7
+ interface BuildRequestOptions {
8
+ ifNoneMatch?: string;
9
+ method?: string;
10
+ path?: string;
11
+ }
12
+
13
+ const buildRequest = (options: BuildRequestOptions = {}): Request => {
14
+ const {ifNoneMatch, method = "GET", path = "/openapi.json"} = options;
15
+ return {
16
+ get: (header: string) => {
17
+ return header === "If-None-Match" ? ifNoneMatch : undefined;
18
+ },
19
+ method,
20
+ path,
21
+ } as Request;
22
+ };
23
+
24
+ const buildResponse = (): {
25
+ originalJson: ReturnType<typeof mock>;
26
+ res: Response;
27
+ set: ReturnType<typeof mock>;
28
+ status: ReturnType<typeof mock>;
29
+ end: ReturnType<typeof mock>;
30
+ } => {
31
+ const originalJson = mock((body: unknown) => ({body}));
32
+ const resObject = {
33
+ json: originalJson,
34
+ } as unknown as Response & Record<string, unknown>;
35
+ const set = mock(() => resObject);
36
+ const status = mock(() => resObject);
37
+ const end = mock(() => resObject);
38
+
39
+ resObject.set = set;
40
+ resObject.status = status;
41
+ resObject.end = end;
42
+
43
+ return {
44
+ end,
45
+ originalJson,
46
+ res: resObject,
47
+ set,
48
+ status,
49
+ };
50
+ };
51
+
52
+ describe("openApiEtagMiddleware", () => {
53
+ it("skips non-openapi requests", () => {
54
+ const req = buildRequest({method: "POST", path: "/health"});
55
+ const {res, originalJson} = buildResponse();
56
+ const next = mock(() => {}) as NextFunction;
57
+
58
+ openApiEtagMiddleware(req, res, next);
59
+
60
+ expect(next).toHaveBeenCalledTimes(1);
61
+ expect(res.json).toBe(originalJson);
62
+ });
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
+
75
+ it("sets ETag and returns json body when no matching If-None-Match header is provided", () => {
76
+ const req = buildRequest();
77
+ const {res, originalJson, set, status, end} = buildResponse();
78
+ const next = mock(() => {}) as NextFunction;
79
+ const body = {openapi: "3.0.0", paths: {"/todos": {get: {}}}};
80
+
81
+ openApiEtagMiddleware(req, res, next);
82
+
83
+ const result = res.json(body) as unknown as {body: typeof body};
84
+ const expectedEtag = `"${crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex").substring(0, 16)}"`;
85
+
86
+ expect(next).toHaveBeenCalledTimes(1);
87
+ expect(set).toHaveBeenCalledWith("ETag", expectedEtag);
88
+ expect(originalJson).toHaveBeenCalledWith(body);
89
+ expect(status).toHaveBeenCalledTimes(0);
90
+ expect(end).toHaveBeenCalledTimes(0);
91
+ expect(result).toEqual({body});
92
+ });
93
+
94
+ it("returns 304 when If-None-Match matches generated ETag", () => {
95
+ const body = {openapi: "3.0.0", paths: {"/users": {post: {}}}};
96
+ const etag = `"${crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex").substring(0, 16)}"`;
97
+ const req = buildRequest({ifNoneMatch: etag});
98
+ const {res, originalJson, set, status, end} = buildResponse();
99
+ const next = mock(() => {}) as NextFunction;
100
+
101
+ openApiEtagMiddleware(req, res, next);
102
+
103
+ const result = res.json(body);
104
+
105
+ expect(next).toHaveBeenCalledTimes(1);
106
+ expect(set).toHaveBeenCalledWith("ETag", etag);
107
+ expect(status).toHaveBeenCalledWith(304);
108
+ expect(end).toHaveBeenCalledTimes(1);
109
+ expect(originalJson).toHaveBeenCalledTimes(0);
110
+ expect(result).toBe(res);
111
+ });
112
+ });
@@ -0,0 +1,197 @@
1
+ import {describe, expect, it, mock, spyOn} from "bun:test";
2
+ import * as Sentry from "@sentry/bun";
3
+ import type express from "express";
4
+
5
+ import {APIError} from "./errors";
6
+ import {Permissions, permissionMiddleware} from "./permissions";
7
+
8
+ describe("permissionMiddleware", () => {
9
+ const allPermissions = {
10
+ create: [Permissions.IsAny],
11
+ delete: [Permissions.IsAny],
12
+ list: [Permissions.IsAny],
13
+ read: [Permissions.IsAny],
14
+ update: [Permissions.IsAny],
15
+ };
16
+
17
+ const buildReq = (overrides: Record<string, unknown> = {}): express.Request => {
18
+ return {
19
+ method: "GET",
20
+ params: {},
21
+ user: {id: "user-1"},
22
+ ...overrides,
23
+ } as unknown as express.Request;
24
+ };
25
+
26
+ it("calls next immediately for OPTIONS requests", async () => {
27
+ const model = {
28
+ collection: {findOne: mock(async () => null)},
29
+ findById: mock(() => ({exec: mock(async () => null)})),
30
+ modelName: "MockModel",
31
+ } as any;
32
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
33
+ const next = mock(() => {});
34
+
35
+ await middleware(buildReq({method: "OPTIONS"}), {} as express.Response, next as any);
36
+
37
+ expect(next).toHaveBeenCalledTimes(1);
38
+ expect((next as any).mock.calls[0]).toEqual([]);
39
+ expect(model.findById).toHaveBeenCalledTimes(0);
40
+ });
41
+
42
+ it("returns APIError for unsupported HTTP methods", async () => {
43
+ const model = {
44
+ collection: {findOne: mock(async () => null)},
45
+ findById: mock(() => ({exec: mock(async () => null)})),
46
+ modelName: "MockModel",
47
+ } as any;
48
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
49
+ const next = mock(() => {});
50
+
51
+ await middleware(buildReq({method: "TRACE"}), {} as express.Response, next as any);
52
+
53
+ expect(next).toHaveBeenCalledTimes(1);
54
+ const [error] = (next as any).mock.calls[0];
55
+ expect(error).toBeInstanceOf(APIError);
56
+ expect(error.status).toBe(405);
57
+ expect(error.title).toContain("Method TRACE not allowed");
58
+ });
59
+
60
+ it("wraps query execution failures in a 500 APIError", async () => {
61
+ const exec = mock(async () => {
62
+ throw new Error("query failed");
63
+ });
64
+ const model = {
65
+ collection: {findOne: mock(async () => null)},
66
+ findById: mock(() => ({exec})),
67
+ modelName: "MockModel",
68
+ } as any;
69
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
70
+ const next = mock(() => {});
71
+
72
+ await middleware(
73
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
74
+ {} as express.Response,
75
+ next as any
76
+ );
77
+
78
+ expect(exec).toHaveBeenCalledTimes(1);
79
+ const [error] = (next as any).mock.calls[0];
80
+ expect(error).toBeInstanceOf(APIError);
81
+ expect(error.status).toBe(500);
82
+ expect(error.title).toContain("GET failed on 507f1f77bcf86cd799439011");
83
+ });
84
+
85
+ it("captures sentry message when document does not exist", async () => {
86
+ const captureMessageSpy = spyOn(Sentry, "captureMessage");
87
+ const model = {
88
+ collection: {findOne: mock(async () => null)},
89
+ findById: mock(() => ({exec: mock(async () => null)})),
90
+ modelName: "MockModel",
91
+ } as any;
92
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
93
+ const next = mock(() => {});
94
+
95
+ await middleware(
96
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
97
+ {} as express.Response,
98
+ next as any
99
+ );
100
+
101
+ expect(captureMessageSpy).toHaveBeenCalledWith(
102
+ "Document 507f1f77bcf86cd799439011 not found for model MockModel"
103
+ );
104
+ const [error] = (next as any).mock.calls[0];
105
+ expect(error).toBeInstanceOf(APIError);
106
+ expect(error.status).toBe(404);
107
+ expect(error.meta).toBeUndefined();
108
+ captureMessageSpy.mockRestore();
109
+ });
110
+
111
+ it("returns hidden reason metadata when document is deleted", async () => {
112
+ const model = {
113
+ collection: {findOne: mock(async () => ({deleted: true}))},
114
+ findById: mock(() => ({exec: mock(async () => null)})),
115
+ modelName: "MockModel",
116
+ } as any;
117
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
118
+ const next = mock(() => {});
119
+
120
+ await middleware(
121
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
122
+ {} as express.Response,
123
+ next as any
124
+ );
125
+
126
+ const [error] = (next as any).mock.calls[0];
127
+ expect(error).toBeInstanceOf(APIError);
128
+ expect(error.status).toBe(404);
129
+ expect(error.meta).toEqual({deleted: "true"});
130
+ expect(error.disableExternalErrorTracking).toBe(true);
131
+ });
132
+
133
+ it("returns hidden reason metadata when document is disabled", async () => {
134
+ const model = {
135
+ collection: {findOne: mock(async () => ({disabled: true}))},
136
+ findById: mock(() => ({exec: mock(async () => null)})),
137
+ modelName: "MockModel",
138
+ } as any;
139
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
140
+ const next = mock(() => {});
141
+
142
+ await middleware(
143
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
144
+ {} as express.Response,
145
+ next as any
146
+ );
147
+
148
+ const [error] = (next as any).mock.calls[0];
149
+ expect(error).toBeInstanceOf(APIError);
150
+ expect(error.status).toBe(404);
151
+ expect(error.meta).toEqual({disabled: "true"});
152
+ expect(error.disableExternalErrorTracking).toBe(true);
153
+ });
154
+
155
+ it("returns hidden reason metadata when document is archived", async () => {
156
+ const model = {
157
+ collection: {findOne: mock(async () => ({archived: true}))},
158
+ findById: mock(() => ({exec: mock(async () => null)})),
159
+ modelName: "MockModel",
160
+ } as any;
161
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
162
+ const next = mock(() => {});
163
+
164
+ await middleware(
165
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
166
+ {} as express.Response,
167
+ next as any
168
+ );
169
+
170
+ const [error] = (next as any).mock.calls[0];
171
+ expect(error).toBeInstanceOf(APIError);
172
+ expect(error.status).toBe(404);
173
+ expect(error.meta).toEqual({archived: "true"});
174
+ expect(error.disableExternalErrorTracking).toBe(true);
175
+ });
176
+
177
+ it("returns plain not found when hidden document has no reason", async () => {
178
+ const model = {
179
+ collection: {findOne: mock(async () => ({foo: "bar"}))},
180
+ findById: mock(() => ({exec: mock(async () => null)})),
181
+ modelName: "MockModel",
182
+ } as any;
183
+ const middleware = permissionMiddleware(model, {permissions: allPermissions});
184
+ const next = mock(() => {});
185
+
186
+ await middleware(
187
+ buildReq({method: "GET", params: {id: "507f1f77bcf86cd799439011"}}),
188
+ {} as express.Response,
189
+ next as any
190
+ );
191
+
192
+ const [error] = (next as any).mock.calls[0];
193
+ expect(error).toBeInstanceOf(APIError);
194
+ expect(error.status).toBe(404);
195
+ expect(error.meta).toBeUndefined();
196
+ });
197
+ });
package/src/populate.ts CHANGED
@@ -138,8 +138,8 @@ export function getOpenApiSpecForModel(
138
138
  {
139
139
  populatePaths,
140
140
  extraModelProperties,
141
- }: {populatePaths?: PopulatePath[]; extraModelProperties?: any} = {}
142
- ): {properties: any; required: string[]} {
141
+ }: {populatePaths?: PopulatePath[]; extraModelProperties?: Record<string, unknown>} = {}
142
+ ): {properties: Record<string, unknown>; required: string[]} {
143
143
  const modelSwagger = m2s(model, {
144
144
  props: ["required", "enum"],
145
145
  });
@@ -237,7 +237,7 @@ export const syncConsents = async (
237
237
 
238
238
  // Deactivate forms that are no longer in definitions
239
239
  if (deactivateRemoved) {
240
- for (const [slug, form] of activeBySlug) {
240
+ for (const [slug] of activeBySlug) {
241
241
  if (!definitions[slug]) {
242
242
  logger.info(`syncConsents: deactivating "${slug}"`, {dryRun});
243
243
  if (!dryRun) {
@@ -6,17 +6,23 @@ import winston from "winston";
6
6
  import {setupEnvironment} from "../expressServer";
7
7
  import {logger, winstonLogger} from "../logger";
8
8
 
9
+ const shouldConnectToTestDb = process.env.BUN_TEST_DISABLE_DB !== "true";
10
+
9
11
  // Connect to MongoDB once for all tests
10
- beforeAll(async () => {
11
- await mongoose
12
- .connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
13
- .catch(logger.catch);
14
- });
12
+ if (shouldConnectToTestDb) {
13
+ beforeAll(async () => {
14
+ await mongoose
15
+ .connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
16
+ .catch(logger.catch);
17
+ });
18
+ }
15
19
 
16
20
  // Close MongoDB connection after all tests
17
- afterAll(async () => {
18
- await mongoose.connection.close();
19
- });
21
+ if (shouldConnectToTestDb) {
22
+ afterAll(async () => {
23
+ await mongoose.connection.close();
24
+ });
25
+ }
20
26
 
21
27
  let logs: string[] = [];
22
28