@terreno/api 0.11.7 → 0.11.9

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 (44) hide show
  1. package/dist/betterAuthSetup.js +10 -3
  2. package/dist/configurationPlugin.d.ts +2 -1
  3. package/dist/configurationPlugin.js +16 -9
  4. package/dist/errors.d.ts +6 -6
  5. package/dist/errors.js +22 -22
  6. package/dist/errors.test.d.ts +1 -0
  7. package/dist/errors.test.js +280 -0
  8. package/dist/githubAuth.d.ts +3 -3
  9. package/dist/githubAuth.js +16 -16
  10. package/dist/middleware.d.ts +1 -1
  11. package/dist/middleware.js +4 -3
  12. package/dist/middleware.test.d.ts +1 -0
  13. package/dist/middleware.test.js +82 -0
  14. package/dist/notifiers/googleChatNotifier.js +4 -3
  15. package/dist/notifiers/zoomNotifier.js +12 -11
  16. package/dist/openApiCompat.js +2 -1
  17. package/dist/openApiEtag.d.ts +1 -1
  18. package/dist/openApiEtag.js +4 -3
  19. package/dist/openApiValidator.d.ts +12 -12
  20. package/dist/openApiValidator.js +59 -58
  21. package/dist/plugins.d.ts +12 -1
  22. package/dist/plugins.js +34 -1
  23. package/dist/plugins.test.js +212 -8
  24. package/dist/scriptRunner.d.ts +8 -7
  25. package/dist/secretProviders.js +17 -7
  26. package/dist/types/consentForm.d.ts +4 -2
  27. package/package.json +1 -1
  28. package/src/betterAuthSetup.ts +10 -3
  29. package/src/configurationPlugin.ts +18 -9
  30. package/src/errors.test.ts +302 -0
  31. package/src/errors.ts +18 -13
  32. package/src/githubAuth.ts +11 -10
  33. package/src/middleware.test.ts +71 -0
  34. package/src/middleware.ts +6 -2
  35. package/src/notifiers/googleChatNotifier.ts +4 -3
  36. package/src/notifiers/zoomNotifier.ts +4 -3
  37. package/src/openApiCompat.ts +2 -1
  38. package/src/openApiEtag.ts +2 -2
  39. package/src/openApiValidator.ts +46 -46
  40. package/src/plugins.test.ts +130 -0
  41. package/src/plugins.ts +35 -0
  42. package/src/scriptRunner.ts +23 -27
  43. package/src/secretProviders.ts +27 -9
  44. package/src/types/consentForm.ts +6 -4
@@ -25,11 +25,11 @@ interface BackgroundTaskLog {
25
25
  level: "info" | "warn" | "error";
26
26
  message: string;
27
27
  }
28
- export type BackgroundTaskMethods = {
28
+ export interface BackgroundTaskMethods {
29
29
  addLog: (this: BackgroundTaskDocument, level: "info" | "warn" | "error", message: string) => Promise<void>;
30
30
  updateProgress: (this: BackgroundTaskDocument, percentage: number, stage?: string, message?: string) => Promise<void>;
31
- };
32
- export type BackgroundTaskDocument = Document & BackgroundTaskMethods & {
31
+ }
32
+ export interface BackgroundTaskDocument extends Document, BackgroundTaskMethods {
33
33
  taskType: string;
34
34
  status: "pending" | "running" | "completed" | "failed" | "cancelled";
35
35
  progress?: BackgroundTaskProgress;
@@ -43,10 +43,11 @@ export type BackgroundTaskDocument = Document & BackgroundTaskMethods & {
43
43
  created: Date;
44
44
  updated: Date;
45
45
  deleted: boolean;
46
- };
47
- export type BackgroundTaskStatics = {
46
+ }
47
+ export interface BackgroundTaskStatics {
48
48
  checkCancellation: (taskId: string) => Promise<void>;
49
- };
50
- export type BackgroundTaskModel = Model<BackgroundTaskDocument, Record<string, never>, BackgroundTaskMethods> & BackgroundTaskStatics;
49
+ }
50
+ export interface BackgroundTaskModel extends Model<BackgroundTaskDocument, Record<string, never>, BackgroundTaskMethods>, BackgroundTaskStatics {
51
+ }
51
52
  export declare const BackgroundTask: BackgroundTaskModel;
52
53
  export {};
@@ -86,6 +86,7 @@ var __read = (this && this.__read) || function (o, n) {
86
86
  };
87
87
  Object.defineProperty(exports, "__esModule", { value: true });
88
88
  exports.GcpSecretProvider = exports.EnvSecretProvider = void 0;
89
+ var errors_1 = require("./errors");
89
90
  var logger_1 = require("./logger");
90
91
  /**
91
92
  * Secret provider that reads secrets from environment variables.
@@ -143,12 +144,13 @@ var GcpSecretProvider = /** @class */ (function () {
143
144
  }
144
145
  GcpSecretProvider.prototype.getClient = function () {
145
146
  return __awaiter(this, void 0, void 0, function () {
146
- var moduleName, mod, SecretManagerServiceClient, _a;
147
+ var mod, moduleName, _a, SecretManagerServiceClient;
147
148
  var _b, _c;
148
149
  return __generator(this, function (_d) {
149
150
  switch (_d.label) {
150
151
  case 0:
151
- if (!!this.client) return [3 /*break*/, 4];
152
+ if (!!this.client) return [3 /*break*/, 5];
153
+ mod = void 0;
152
154
  _d.label = 1;
153
155
  case 1:
154
156
  _d.trys.push([1, 3, , 4]);
@@ -156,13 +158,21 @@ var GcpSecretProvider = /** @class */ (function () {
156
158
  return [4 /*yield*/, Promise.resolve("".concat(/* webpackIgnore: true */ moduleName)).then(function (s) { return __importStar(require(s)); })];
157
159
  case 2:
158
160
  mod = _d.sent();
159
- SecretManagerServiceClient = (_b = mod.SecretManagerServiceClient) !== null && _b !== void 0 ? _b : (_c = mod.default) === null || _c === void 0 ? void 0 : _c.SecretManagerServiceClient;
160
- this.client = new SecretManagerServiceClient();
161
161
  return [3 /*break*/, 4];
162
162
  case 3:
163
163
  _a = _d.sent();
164
- throw new Error("GcpSecretProvider requires @google-cloud/secret-manager. Install it with: bun add @google-cloud/secret-manager");
165
- case 4: return [2 /*return*/, this.client];
164
+ throw new errors_1.APIError({
165
+ status: 500,
166
+ title: "GcpSecretProvider requires @google-cloud/secret-manager. Install it with: bun add @google-cloud/secret-manager",
167
+ });
168
+ case 4:
169
+ SecretManagerServiceClient = (_b = mod.SecretManagerServiceClient) !== null && _b !== void 0 ? _b : (_c = mod.default) === null || _c === void 0 ? void 0 : _c.SecretManagerServiceClient;
170
+ if (!SecretManagerServiceClient) {
171
+ throw new Error("SecretManagerServiceClient not found in @google-cloud/secret-manager module");
172
+ }
173
+ this.client = new SecretManagerServiceClient();
174
+ _d.label = 5;
175
+ case 5: return [2 /*return*/, this.client];
166
176
  }
167
177
  });
168
178
  });
@@ -198,7 +208,7 @@ var GcpSecretProvider = /** @class */ (function () {
198
208
  return [2 /*return*/, typeof payload === "string" ? payload : new TextDecoder().decode(payload)];
199
209
  case 4:
200
210
  error_1 = _c.sent();
201
- if ((error_1 === null || error_1 === void 0 ? void 0 : error_1.code) === 5) {
211
+ if (error_1 instanceof Error && "code" in error_1 && error_1.code === 5) {
202
212
  // NOT_FOUND
203
213
  logger_1.logger.warn("GcpSecretProvider: secret ".concat(secretName, " not found"));
204
214
  return [2 /*return*/, null];
@@ -7,8 +7,10 @@ export interface ConsentFormCheckbox {
7
7
  }
8
8
  export type ConsentFormType = "agreement" | "privacy" | "hipaa" | "research" | "terms" | "custom";
9
9
  export type ConsentFormMethods = {};
10
- export type ConsentFormStatics = FindExactlyOnePlugin<ConsentFormDocument> & FindOneOrNonePlugin<ConsentFormDocument>;
11
- export type ConsentFormModel = mongoose.Model<ConsentFormDocument, object, ConsentFormMethods> & ConsentFormStatics;
10
+ export interface ConsentFormStatics extends FindExactlyOnePlugin<ConsentFormDocument>, FindOneOrNonePlugin<ConsentFormDocument> {
11
+ }
12
+ export interface ConsentFormModel extends mongoose.Model<ConsentFormDocument, object, ConsentFormMethods>, ConsentFormStatics {
13
+ }
12
14
  export interface ConsentFormDocument extends mongoose.Document {
13
15
  _id: mongoose.Types.ObjectId;
14
16
  title: string;
package/package.json CHANGED
@@ -104,5 +104,5 @@
104
104
  "updateSnapshot": "bun test --update-snapshots"
105
105
  },
106
106
  "types": "dist/index.d.ts",
107
- "version": "0.11.7"
107
+ "version": "0.11.9"
108
108
  }
@@ -13,6 +13,7 @@ import mongoose from "mongoose";
13
13
  import type {UserModel} from "./auth";
14
14
  import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
15
  import {logger} from "./logger";
16
+ import {findOneOrNoneFor} from "./plugins";
16
17
 
17
18
  /**
18
19
  * The Better Auth instance type.
@@ -109,7 +110,9 @@ export const createBetterAuthSessionMiddleware = (
109
110
 
110
111
  if (userModel) {
111
112
  // Look up the application user by betterAuthId
112
- const appUser = await userModel.findOne({betterAuthId: betterAuthUser.id});
113
+ const appUser = await findOneOrNoneFor(userModel, {
114
+ betterAuthId: betterAuthUser.id,
115
+ });
113
116
  if (appUser) {
114
117
  (req as any).user = appUser;
115
118
  (req as any).betterAuthSession = session;
@@ -151,7 +154,9 @@ export const syncBetterAuthUser = async (
151
154
  oauthProvider?: string
152
155
  ): Promise<any> => {
153
156
  try {
154
- const existingUser: any = await userModel.findOne({betterAuthId: betterAuthUser.id});
157
+ const existingUser: any = await findOneOrNoneFor(userModel, {
158
+ betterAuthId: betterAuthUser.id,
159
+ });
155
160
 
156
161
  if (existingUser) {
157
162
  // Update existing user if needed
@@ -164,7 +169,9 @@ export const syncBetterAuthUser = async (
164
169
  }
165
170
 
166
171
  // Check if user exists by email (migration case)
167
- const userByEmail: any = await userModel.findOne({email: betterAuthUser.email});
172
+ const userByEmail: any = await findOneOrNoneFor(userModel, {
173
+ email: betterAuthUser.email,
174
+ });
168
175
  if (userByEmail) {
169
176
  // Link existing user to Better Auth
170
177
  userByEmail.betterAuthId = betterAuthUser.id;
@@ -2,6 +2,7 @@ import type {Document, Model, Schema} from "mongoose";
2
2
 
3
3
  import {APIError} from "./errors";
4
4
  import {logger} from "./logger";
5
+ import {type FindOneOrNonePlugin, findOneOrNone} from "./plugins";
5
6
 
6
7
  /**
7
8
  * Metadata for a secret field discovered by the configuration plugin.
@@ -100,7 +101,7 @@ export interface ConfigurationStatics<T extends object> {
100
101
  * const full = await AppConfig.getConfig(); // typed as AppConfigDocument
101
102
  * ```
102
103
  */
103
- export type ConfigurationModel<T extends object> = Model<T> & ConfigurationStatics<T>;
104
+ export interface ConfigurationModel<T extends object> extends Model<T>, ConfigurationStatics<T> {}
104
105
 
105
106
  // ---------------------------------------------------------------------------
106
107
  // Plugin
@@ -132,6 +133,9 @@ export type ConfigurationModel<T extends object> = Model<T> & ConfigurationStati
132
133
  export const configurationPlugin = (schema: Schema, options?: ConfigurationPluginOptions): void => {
133
134
  const pluginOptions = options ?? {};
134
135
 
136
+ // Apply findOneOrNone so the singleton lookup avoids bare Model.findOne (idempotent).
137
+ findOneOrNone(schema);
138
+
135
139
  // Add a sentinel field with a unique index to enforce singleton at the DB level.
136
140
  // All config documents get _singleton: "config", and the unique index prevents duplicates.
137
141
  schema.add({
@@ -142,8 +146,8 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
142
146
  // Enforce singleton: only one document allowed (application-level guard)
143
147
  schema.pre("save", async function () {
144
148
  if (this.isNew) {
145
- // Intentional unfiltered findOnechecking if any singleton document exists
146
- const existing = await (this.constructor as Model<unknown>).findOne({});
149
+ // Cheap existence checkno document needs to be returned.
150
+ const existing = await (this.constructor as Model<unknown>).exists({});
147
151
  if (existing) {
148
152
  throw new APIError({
149
153
  status: 409,
@@ -173,18 +177,23 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
173
177
 
174
178
  // Static: get the singleton configuration document or a value at a path (race-safe via upsert)
175
179
  schema.statics.getConfig = async function (key?: string): Promise<unknown> {
176
- let config = await this.findOne({});
180
+ const findSingleton = (): Promise<Document | null> =>
181
+ (this as unknown as FindOneOrNonePlugin<unknown>).findOneOrNone(
182
+ {}
183
+ ) as Promise<Document | null>;
184
+ let config: Document | null = await findSingleton();
177
185
  if (!config) {
178
186
  try {
179
187
  // Use `new` + `save` instead of `create({})` so Mongoose initializes
180
188
  // nested subdocument defaults (create({}) skips them).
181
- config = new this();
182
- await config.save();
189
+ const created = new this();
190
+ await created.save();
191
+ config = created;
183
192
  } catch (err: unknown) {
184
- // If another process created the document between findOne and create,
193
+ // If another process created the document between the lookup and create,
185
194
  // the pre-save hook will throw a 409. Just fetch the existing one.
186
195
  if ((err as {status?: number})?.status === 409) {
187
- config = await this.findOne({});
196
+ config = await findSingleton();
188
197
  } else {
189
198
  throw err;
190
199
  }
@@ -197,7 +206,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
197
206
 
198
207
  // Resolve dot-notation key into the document
199
208
  const parts = key.split(".");
200
- let value: unknown = config.toObject();
209
+ let value: unknown = config?.toObject();
201
210
  for (const part of parts) {
202
211
  if (value == null || typeof value !== "object") {
203
212
  return undefined;
@@ -0,0 +1,302 @@
1
+ import {beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import * as Sentry from "@sentry/bun";
3
+ import type {NextFunction, Request, Response} from "express";
4
+ import {Schema} from "mongoose";
5
+
6
+ import {
7
+ APIError,
8
+ apiErrorMiddleware,
9
+ apiUnauthorizedMiddleware,
10
+ errorsPlugin,
11
+ getAPIErrorBody,
12
+ getDisableExternalErrorTracking,
13
+ isAPIError,
14
+ } from "./errors";
15
+
16
+ interface MockResponse {
17
+ status: ReturnType<typeof mock>;
18
+ json: ReturnType<typeof mock>;
19
+ send: ReturnType<typeof mock>;
20
+ }
21
+
22
+ const buildResponse = (): MockResponse => {
23
+ const res: MockResponse = {
24
+ json: mock(() => res),
25
+ send: mock(() => res),
26
+ status: mock(() => res),
27
+ };
28
+ return res;
29
+ };
30
+
31
+ describe("APIError", () => {
32
+ it("creates an error with the provided fields", () => {
33
+ const error = new APIError({
34
+ code: "validation-failed",
35
+ detail: "Email is invalid",
36
+ id: "abc-123",
37
+ links: {about: "https://example.com/help", type: "https://example.com/types/validation"},
38
+ meta: {requestId: "req-1"},
39
+ source: {header: "x-foo", parameter: "limit", pointer: "/data/email"},
40
+ status: 400,
41
+ title: "Validation failed",
42
+ });
43
+
44
+ expect(error).toBeInstanceOf(Error);
45
+ expect(error.name).toBe("APIError");
46
+ expect(error.title).toBe("Validation failed");
47
+ expect(error.detail).toBe("Email is invalid");
48
+ expect(error.code).toBe("validation-failed");
49
+ expect(error.status).toBe(400);
50
+ expect(error.id).toBe("abc-123");
51
+ expect(error.links).toEqual({
52
+ about: "https://example.com/help",
53
+ type: "https://example.com/types/validation",
54
+ });
55
+ expect(error.source).toEqual({
56
+ header: "x-foo",
57
+ parameter: "limit",
58
+ pointer: "/data/email",
59
+ });
60
+ expect(error.meta).toEqual({requestId: "req-1"});
61
+ });
62
+
63
+ it("includes the title and detail in the error message", () => {
64
+ const error = new APIError({detail: "Something exploded", title: "Boom"});
65
+ expect(error.message).toBe("Boom: Something exploded");
66
+ });
67
+
68
+ it("includes the wrapped error stack in the message", () => {
69
+ const wrapped = new Error("inner");
70
+ const error = new APIError({error: wrapped, title: "Outer"});
71
+ expect(error.message).toContain("Outer");
72
+ expect(error.message).toContain(wrapped.stack ?? "");
73
+ });
74
+
75
+ it("defaults status to 500 when status is omitted", () => {
76
+ const error = new APIError({title: "No status"});
77
+ expect(error.status).toBe(500);
78
+ });
79
+
80
+ it("forces status to 500 when below 400", () => {
81
+ const error = new APIError({status: 200, title: "Too low"});
82
+ expect(error.status).toBe(500);
83
+ });
84
+
85
+ it("forces status to 500 when above 599", () => {
86
+ const error = new APIError({status: 600, title: "Too high"});
87
+ expect(error.status).toBe(500);
88
+ });
89
+
90
+ it("defaults meta to an empty object when not provided", () => {
91
+ const error = new APIError({title: "No meta"});
92
+ expect(error.meta).toEqual({});
93
+ });
94
+
95
+ it("merges fields into meta", () => {
96
+ const error = new APIError({
97
+ fields: {email: "Required", name: "Required"},
98
+ title: "Validation",
99
+ });
100
+ expect(error.meta?.fields).toEqual({email: "Required", name: "Required"});
101
+ });
102
+
103
+ it("respects disableExternalErrorTracking", () => {
104
+ const trackedError = new APIError({title: "Tracked"});
105
+ const untrackedError = new APIError({
106
+ disableExternalErrorTracking: true,
107
+ title: "Untracked",
108
+ });
109
+ expect(trackedError.disableExternalErrorTracking).toBeUndefined();
110
+ expect(untrackedError.disableExternalErrorTracking).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe("isAPIError", () => {
115
+ it("returns true for an APIError instance", () => {
116
+ expect(isAPIError(new APIError({title: "Boom"}))).toBe(true);
117
+ });
118
+
119
+ it("returns false for a regular Error", () => {
120
+ expect(isAPIError(new Error("nope"))).toBe(false);
121
+ });
122
+
123
+ it("returns true for any error whose name is APIError", () => {
124
+ const err = new Error("custom");
125
+ err.name = "APIError";
126
+ expect(isAPIError(err)).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe("getDisableExternalErrorTracking", () => {
131
+ it("returns the flag from an APIError", () => {
132
+ const error = new APIError({disableExternalErrorTracking: true, title: "Test"});
133
+ expect(getDisableExternalErrorTracking(error)).toBe(true);
134
+ });
135
+
136
+ it("returns undefined for a plain Error without the flag", () => {
137
+ expect(getDisableExternalErrorTracking(new Error("plain"))).toBeUndefined();
138
+ });
139
+
140
+ it("returns the flag when attached to a non-APIError object", () => {
141
+ const error = {disableExternalErrorTracking: false};
142
+ expect(getDisableExternalErrorTracking(error)).toBe(false);
143
+ });
144
+
145
+ it("returns undefined for primitives and null", () => {
146
+ expect(getDisableExternalErrorTracking(null)).toBeUndefined();
147
+ expect(getDisableExternalErrorTracking(undefined)).toBeUndefined();
148
+ expect(getDisableExternalErrorTracking("string")).toBeUndefined();
149
+ expect(getDisableExternalErrorTracking(42)).toBeUndefined();
150
+ });
151
+
152
+ it("returns undefined for an object missing the property", () => {
153
+ expect(getDisableExternalErrorTracking({foo: "bar"})).toBeUndefined();
154
+ });
155
+ });
156
+
157
+ describe("getAPIErrorBody", () => {
158
+ it("returns title and status by default", () => {
159
+ const error = new APIError({status: 404, title: "Not Found"});
160
+ const body = getAPIErrorBody(error);
161
+ expect(body).toEqual({meta: {}, status: 404, title: "Not Found"});
162
+ });
163
+
164
+ it("includes optional fields when set", () => {
165
+ const error = new APIError({
166
+ code: "not-found",
167
+ detail: "Could not find resource",
168
+ disableExternalErrorTracking: true,
169
+ id: "err-1",
170
+ links: {about: "https://example.com/help"},
171
+ source: {pointer: "/data/id"},
172
+ status: 404,
173
+ title: "Not Found",
174
+ });
175
+ const body = getAPIErrorBody(error);
176
+ expect(body).toEqual({
177
+ code: "not-found",
178
+ detail: "Could not find resource",
179
+ disableExternalErrorTracking: true,
180
+ id: "err-1",
181
+ links: {about: "https://example.com/help"},
182
+ meta: {},
183
+ source: {pointer: "/data/id"},
184
+ status: 404,
185
+ title: "Not Found",
186
+ });
187
+ });
188
+
189
+ it("omits empty meta and unset optional fields", () => {
190
+ const error = new APIError({status: 400, title: "Bad"});
191
+ // meta defaults to {} which is truthy, so it is included.
192
+ const body = getAPIErrorBody(error);
193
+ expect(body.meta).toEqual({});
194
+ expect(body.code).toBeUndefined();
195
+ expect(body.detail).toBeUndefined();
196
+ expect(body.id).toBeUndefined();
197
+ expect(body.links).toBeUndefined();
198
+ expect(body.source).toBeUndefined();
199
+ });
200
+ });
201
+
202
+ describe("errorsPlugin", () => {
203
+ it("adds an apiErrors array field to the schema", () => {
204
+ const schema = new Schema({name: String});
205
+ errorsPlugin(schema);
206
+ const path = schema.path("apiErrors");
207
+ expect(path).toBeDefined();
208
+ });
209
+
210
+ it("requires title on each error subdocument", () => {
211
+ const schema = new Schema({name: String});
212
+ errorsPlugin(schema);
213
+ const path = schema.path("apiErrors");
214
+ // Inspect the embedded error schema for the title definition.
215
+ const embedded = path as unknown as {schema: Schema};
216
+ const titlePath = embedded.schema.path("title");
217
+ expect(titlePath).toBeDefined();
218
+ expect(titlePath.isRequired).toBe(true);
219
+ });
220
+ });
221
+
222
+ describe("apiUnauthorizedMiddleware", () => {
223
+ let res: MockResponse;
224
+ let next: ReturnType<typeof mock>;
225
+ const req = {} as Request;
226
+
227
+ beforeEach(() => {
228
+ res = buildResponse();
229
+ next = mock(() => {});
230
+ });
231
+
232
+ it("returns a 401 JSON response when the message is Unauthorized", () => {
233
+ apiUnauthorizedMiddleware(
234
+ new Error("Unauthorized"),
235
+ req,
236
+ res as unknown as Response,
237
+ next as unknown as NextFunction
238
+ );
239
+ expect(res.status).toHaveBeenCalledWith(401);
240
+ expect(res.json).toHaveBeenCalledWith({status: 401, title: "Unauthorized"});
241
+ expect(res.send).toHaveBeenCalled();
242
+ expect(next).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it("forwards other errors to next", () => {
246
+ const err = new Error("Something else");
247
+ apiUnauthorizedMiddleware(
248
+ err,
249
+ req,
250
+ res as unknown as Response,
251
+ next as unknown as NextFunction
252
+ );
253
+ expect(next).toHaveBeenCalledWith(err);
254
+ expect(res.status).not.toHaveBeenCalled();
255
+ });
256
+ });
257
+
258
+ describe("apiErrorMiddleware", () => {
259
+ let res: MockResponse;
260
+ let next: ReturnType<typeof mock>;
261
+ const req = {} as Request;
262
+ const captureExceptionSpy = Sentry.captureException as unknown as ReturnType<typeof mock>;
263
+
264
+ beforeEach(() => {
265
+ res = buildResponse();
266
+ next = mock(() => {});
267
+ captureExceptionSpy.mockClear?.();
268
+ });
269
+
270
+ it("responds with the APIError status and body", () => {
271
+ const err = new APIError({detail: "missing", status: 404, title: "Not Found"});
272
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
273
+ expect(res.status).toHaveBeenCalledWith(404);
274
+ expect(res.json).toHaveBeenCalledWith(getAPIErrorBody(err));
275
+ expect(res.send).toHaveBeenCalled();
276
+ expect(next).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it("captures the exception with Sentry by default", () => {
280
+ const err = new APIError({status: 500, title: "Boom"});
281
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
282
+ expect(captureExceptionSpy).toHaveBeenCalledWith(err);
283
+ });
284
+
285
+ it("does not capture the exception when disableExternalErrorTracking is true", () => {
286
+ const err = new APIError({
287
+ disableExternalErrorTracking: true,
288
+ status: 500,
289
+ title: "Quiet",
290
+ });
291
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
292
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
293
+ expect(res.status).toHaveBeenCalledWith(500);
294
+ });
295
+
296
+ it("forwards non-APIError errors to next", () => {
297
+ const err = new Error("not an api error");
298
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
299
+ expect(next).toHaveBeenCalledWith(err);
300
+ expect(res.status).not.toHaveBeenCalled();
301
+ });
302
+ });
package/src/errors.ts CHANGED
@@ -137,7 +137,7 @@ export class APIError extends Error {
137
137
 
138
138
  // Create an errors field for storing error information in a JSONAPI compatible form directly on a
139
139
  // model.
140
- export function errorsPlugin(schema: Schema): void {
140
+ export const errorsPlugin = (schema: Schema): void => {
141
141
  const errorSchema = new Schema({
142
142
  code: {description: "Application-specific error code", type: String},
143
143
  detail: {description: "Human-readable explanation of the error", type: String},
@@ -160,18 +160,18 @@ export function errorsPlugin(schema: Schema): void {
160
160
  });
161
161
 
162
162
  schema.add({apiErrors: errorSchema});
163
- }
163
+ };
164
164
 
165
- export function isAPIError(error: Error): error is APIError {
165
+ export const isAPIError = (error: Error): error is APIError => {
166
166
  return error.name === "APIError";
167
- }
167
+ };
168
168
 
169
169
  /**
170
170
  * Safely extracts the disableExternalErrorTracking property from an error.
171
171
  * Works with both APIError instances and regular Error objects that may have
172
172
  * this property attached.
173
173
  */
174
- export function getDisableExternalErrorTracking(error: unknown): boolean | undefined {
174
+ export const getDisableExternalErrorTracking = (error: unknown): boolean | undefined => {
175
175
  if (error instanceof Error) {
176
176
  if (isAPIError(error)) {
177
177
  return error.disableExternalErrorTracking;
@@ -181,12 +181,12 @@ export function getDisableExternalErrorTracking(error: unknown): boolean | undef
181
181
  return (error as {disableExternalErrorTracking?: boolean}).disableExternalErrorTracking;
182
182
  }
183
183
  return undefined;
184
- }
184
+ };
185
185
 
186
186
  // Creates an APIError body to send to clients as JSON. Errors don't have a toJSON defined,
187
187
  // and we want to strip out things like message, name, and stack for the client.
188
188
  // There is almost certainly a more elegant solution to this.
189
- export function getAPIErrorBody(error: APIError): {[id: string]: any} {
189
+ export const getAPIErrorBody = (error: APIError): {[id: string]: any} => {
190
190
  const errorData = {status: error.status, title: error.title};
191
191
  for (const key of [
192
192
  "id",
@@ -203,23 +203,28 @@ export function getAPIErrorBody(error: APIError): {[id: string]: any} {
203
203
  }
204
204
  }
205
205
  return errorData;
206
- }
206
+ };
207
207
 
208
- export function apiUnauthorizedMiddleware(
208
+ export const apiUnauthorizedMiddleware = (
209
209
  err: Error,
210
210
  _req: Request,
211
211
  res: Response,
212
212
  next: NextFunction
213
- ) {
213
+ ) => {
214
214
  if (err.message === "Unauthorized") {
215
215
  // not using the actual APIError class here because we don't want to log it as an error.
216
216
  res.status(401).json({status: 401, title: "Unauthorized"}).send();
217
217
  } else {
218
218
  next(err);
219
219
  }
220
- }
220
+ };
221
221
 
222
- export function apiErrorMiddleware(err: Error, _req: Request, res: Response, next: NextFunction) {
222
+ export const apiErrorMiddleware = (
223
+ err: Error,
224
+ _req: Request,
225
+ res: Response,
226
+ next: NextFunction
227
+ ) => {
223
228
  if (isAPIError(err)) {
224
229
  if (!err.disableExternalErrorTracking) {
225
230
  Sentry.captureException(err);
@@ -228,4 +233,4 @@ export function apiErrorMiddleware(err: Error, _req: Request, res: Response, nex
228
233
  } else {
229
234
  next(err);
230
235
  }
231
- }
236
+ };