@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.
- package/dist/betterAuthSetup.js +10 -3
- package/dist/configurationPlugin.d.ts +2 -1
- package/dist/configurationPlugin.js +16 -9
- package/dist/errors.d.ts +6 -6
- package/dist/errors.js +22 -22
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +280 -0
- package/dist/githubAuth.d.ts +3 -3
- package/dist/githubAuth.js +16 -16
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +4 -3
- package/dist/middleware.test.d.ts +1 -0
- package/dist/middleware.test.js +82 -0
- package/dist/notifiers/googleChatNotifier.js +4 -3
- package/dist/notifiers/zoomNotifier.js +12 -11
- package/dist/openApiCompat.js +2 -1
- package/dist/openApiEtag.d.ts +1 -1
- package/dist/openApiEtag.js +4 -3
- package/dist/openApiValidator.d.ts +12 -12
- package/dist/openApiValidator.js +59 -58
- package/dist/plugins.d.ts +12 -1
- package/dist/plugins.js +34 -1
- package/dist/plugins.test.js +212 -8
- package/dist/scriptRunner.d.ts +8 -7
- package/dist/secretProviders.js +17 -7
- package/dist/types/consentForm.d.ts +4 -2
- package/package.json +1 -1
- package/src/betterAuthSetup.ts +10 -3
- package/src/configurationPlugin.ts +18 -9
- package/src/errors.test.ts +302 -0
- package/src/errors.ts +18 -13
- package/src/githubAuth.ts +11 -10
- package/src/middleware.test.ts +71 -0
- package/src/middleware.ts +6 -2
- package/src/notifiers/googleChatNotifier.ts +4 -3
- package/src/notifiers/zoomNotifier.ts +4 -3
- package/src/openApiCompat.ts +2 -1
- package/src/openApiEtag.ts +2 -2
- package/src/openApiValidator.ts +46 -46
- package/src/plugins.test.ts +130 -0
- package/src/plugins.ts +35 -0
- package/src/scriptRunner.ts +23 -27
- package/src/secretProviders.ts +27 -9
- package/src/types/consentForm.ts +6 -4
package/dist/scriptRunner.d.ts
CHANGED
|
@@ -25,11 +25,11 @@ interface BackgroundTaskLog {
|
|
|
25
25
|
level: "info" | "warn" | "error";
|
|
26
26
|
message: string;
|
|
27
27
|
}
|
|
28
|
-
export
|
|
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
|
|
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
|
|
46
|
+
}
|
|
47
|
+
export interface BackgroundTaskStatics {
|
|
48
48
|
checkCancellation: (taskId: string) => Promise<void>;
|
|
49
|
-
}
|
|
50
|
-
export
|
|
49
|
+
}
|
|
50
|
+
export interface BackgroundTaskModel extends Model<BackgroundTaskDocument, Record<string, never>, BackgroundTaskMethods>, BackgroundTaskStatics {
|
|
51
|
+
}
|
|
51
52
|
export declare const BackgroundTask: BackgroundTaskModel;
|
|
52
53
|
export {};
|
package/dist/secretProviders.js
CHANGED
|
@@ -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,
|
|
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*/,
|
|
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
|
|
165
|
-
|
|
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 (
|
|
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
|
|
11
|
-
|
|
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
package/src/betterAuthSetup.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
146
|
-
const existing = await (this.constructor as Model<unknown>).
|
|
149
|
+
// Cheap existence check — no 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
|
-
|
|
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
|
-
|
|
182
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|