@terreno/api 0.14.0 → 0.14.2
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/__tests__/versionCheckPlugin.test.js +119 -0
- package/dist/betterAuthSetup.js +13 -3
- package/dist/config.js +12 -3
- package/dist/errors.js +5 -2
- package/dist/example.js +3 -0
- package/dist/expressServer.test.js +231 -0
- package/dist/realtime/changeStreamWatcher.js +6 -2
- package/dist/realtime/realtime.test.js +1785 -0
- package/dist/requestContext.test.js +143 -0
- package/package.json +1 -1
- package/src/__tests__/versionCheckPlugin.test.ts +81 -0
- package/src/betterAuthSetup.ts +13 -3
- package/src/config.ts +13 -3
- package/src/errors.ts +24 -18
- package/src/example.ts +3 -0
- package/src/expressServer.test.ts +176 -0
- package/src/realtime/changeStreamWatcher.ts +6 -2
- package/src/realtime/realtime.test.ts +1415 -1
- package/src/requestContext.test.ts +125 -0
|
@@ -212,6 +212,149 @@ var requestContext_1 = require("./requestContext");
|
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
214
|
}); });
|
|
215
|
+
(0, bun_test_1.it)("parses Google Cloud trace context header in middleware", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
216
|
+
var app, res;
|
|
217
|
+
return __generator(this, function (_a) {
|
|
218
|
+
switch (_a.label) {
|
|
219
|
+
case 0:
|
|
220
|
+
app = (0, express_1.default)();
|
|
221
|
+
app.use(requestContext_1.requestContextMiddleware);
|
|
222
|
+
app.get("/trace-gcloud", function (_req, res) {
|
|
223
|
+
var ctx = (0, requestContext_1.getCurrentRequestContext)();
|
|
224
|
+
res.json({
|
|
225
|
+
spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
|
|
226
|
+
traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
|
|
227
|
+
traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
return [4 /*yield*/, (0, supertest_1.default)(app)
|
|
231
|
+
.get("/trace-gcloud")
|
|
232
|
+
.set("X-Cloud-Trace-Context", "105445aa7843bc8bf206b12000100000/1;o=1")
|
|
233
|
+
.expect(200)];
|
|
234
|
+
case 1:
|
|
235
|
+
res = _a.sent();
|
|
236
|
+
(0, bun_test_1.expect)(res.body.traceId).toBe("105445aa7843bc8bf206b12000100000");
|
|
237
|
+
(0, bun_test_1.expect)(res.body.spanId).toBe("1");
|
|
238
|
+
(0, bun_test_1.expect)(res.body.traceSampled).toBe(true);
|
|
239
|
+
return [2 /*return*/];
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}); });
|
|
243
|
+
(0, bun_test_1.it)("parses Google Cloud trace context without trace sampling", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
244
|
+
var app, res;
|
|
245
|
+
return __generator(this, function (_a) {
|
|
246
|
+
switch (_a.label) {
|
|
247
|
+
case 0:
|
|
248
|
+
app = (0, express_1.default)();
|
|
249
|
+
app.use(requestContext_1.requestContextMiddleware);
|
|
250
|
+
app.get("/trace-gcloud-nosample", function (_req, res) {
|
|
251
|
+
var ctx = (0, requestContext_1.getCurrentRequestContext)();
|
|
252
|
+
res.json({
|
|
253
|
+
spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
|
|
254
|
+
traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
|
|
255
|
+
traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
return [4 /*yield*/, (0, supertest_1.default)(app)
|
|
259
|
+
.get("/trace-gcloud-nosample")
|
|
260
|
+
.set("X-Cloud-Trace-Context", "abc123/42;o=0")
|
|
261
|
+
.expect(200)];
|
|
262
|
+
case 1:
|
|
263
|
+
res = _a.sent();
|
|
264
|
+
(0, bun_test_1.expect)(res.body.traceId).toBe("abc123");
|
|
265
|
+
(0, bun_test_1.expect)(res.body.spanId).toBe("42");
|
|
266
|
+
(0, bun_test_1.expect)(res.body.traceSampled).toBe(false);
|
|
267
|
+
return [2 /*return*/];
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}); });
|
|
271
|
+
(0, bun_test_1.it)("falls back to traceparent when cloud trace context is absent", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
272
|
+
var app, res;
|
|
273
|
+
return __generator(this, function (_a) {
|
|
274
|
+
switch (_a.label) {
|
|
275
|
+
case 0:
|
|
276
|
+
app = (0, express_1.default)();
|
|
277
|
+
app.use(requestContext_1.requestContextMiddleware);
|
|
278
|
+
app.get("/trace-parent", function (_req, res) {
|
|
279
|
+
var ctx = (0, requestContext_1.getCurrentRequestContext)();
|
|
280
|
+
res.json({
|
|
281
|
+
spanId: ctx === null || ctx === void 0 ? void 0 : ctx.spanId,
|
|
282
|
+
traceId: ctx === null || ctx === void 0 ? void 0 : ctx.traceId,
|
|
283
|
+
traceSampled: ctx === null || ctx === void 0 ? void 0 : ctx.traceSampled,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
return [4 /*yield*/, (0, supertest_1.default)(app)
|
|
287
|
+
.get("/trace-parent")
|
|
288
|
+
.set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
|
|
289
|
+
.expect(200)];
|
|
290
|
+
case 1:
|
|
291
|
+
res = _a.sent();
|
|
292
|
+
(0, bun_test_1.expect)(res.body.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
|
293
|
+
(0, bun_test_1.expect)(res.body.spanId).toBe("00f067aa0ba902b7");
|
|
294
|
+
(0, bun_test_1.expect)(res.body.traceSampled).toBe(true);
|
|
295
|
+
return [2 /*return*/];
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}); });
|
|
299
|
+
(0, bun_test_1.it)("uses trace id as request id when no explicit request id header is set", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
300
|
+
var app, res;
|
|
301
|
+
return __generator(this, function (_a) {
|
|
302
|
+
switch (_a.label) {
|
|
303
|
+
case 0:
|
|
304
|
+
app = (0, express_1.default)();
|
|
305
|
+
app.use(requestContext_1.requestContextMiddleware);
|
|
306
|
+
app.get("/trace-request-id", function (_req, res) {
|
|
307
|
+
var ctx = (0, requestContext_1.getCurrentRequestContext)();
|
|
308
|
+
res.json({ requestId: ctx === null || ctx === void 0 ? void 0 : ctx.requestId });
|
|
309
|
+
});
|
|
310
|
+
return [4 /*yield*/, (0, supertest_1.default)(app)
|
|
311
|
+
.get("/trace-request-id")
|
|
312
|
+
.set("X-Cloud-Trace-Context", "trace-as-rid/99;o=1")
|
|
313
|
+
.expect(200)];
|
|
314
|
+
case 1:
|
|
315
|
+
res = _a.sent();
|
|
316
|
+
(0, bun_test_1.expect)(res.body.requestId).toBe("trace-as-rid");
|
|
317
|
+
return [2 /*return*/];
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}); });
|
|
321
|
+
(0, bun_test_1.it)("handles traceSampled attribute values 'true', '1', 'false', '0'", function () {
|
|
322
|
+
var ctxTrue = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
323
|
+
"x-request-id": "r1",
|
|
324
|
+
"x-trace-sampled": "true",
|
|
325
|
+
});
|
|
326
|
+
(0, bun_test_1.expect)(ctxTrue.traceSampled).toBe(true);
|
|
327
|
+
var ctx1 = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
328
|
+
"x-request-id": "r2",
|
|
329
|
+
"x-trace-sampled": "1",
|
|
330
|
+
});
|
|
331
|
+
(0, bun_test_1.expect)(ctx1.traceSampled).toBe(true);
|
|
332
|
+
var ctxFalse = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
333
|
+
"x-request-id": "r3",
|
|
334
|
+
"x-trace-sampled": "false",
|
|
335
|
+
});
|
|
336
|
+
(0, bun_test_1.expect)(ctxFalse.traceSampled).toBe(false);
|
|
337
|
+
var ctx0 = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
338
|
+
"x-request-id": "r4",
|
|
339
|
+
"x-trace-sampled": "0",
|
|
340
|
+
});
|
|
341
|
+
(0, bun_test_1.expect)(ctx0.traceSampled).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
(0, bun_test_1.it)("parses Google Cloud trace context with missing span id", function () {
|
|
344
|
+
var ctx = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
345
|
+
"x-cloud-trace-context": "only-trace-id",
|
|
346
|
+
"x-request-id": "r5",
|
|
347
|
+
});
|
|
348
|
+
(0, bun_test_1.expect)(ctx.traceId).toBe("only-trace-id");
|
|
349
|
+
(0, bun_test_1.expect)(ctx.spanId).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
(0, bun_test_1.it)("returns undefined trace when traceparent has empty trace id", function () {
|
|
352
|
+
var ctx = (0, requestContext_1.getRequestContextFromAttributes)({
|
|
353
|
+
traceparent: "00--span-01",
|
|
354
|
+
"x-request-id": "r6",
|
|
355
|
+
});
|
|
356
|
+
(0, bun_test_1.expect)(ctx.traceId).toBeUndefined();
|
|
357
|
+
});
|
|
215
358
|
(0, bun_test_1.it)("adds job id to logger context", function () {
|
|
216
359
|
var output = "";
|
|
217
360
|
var stream = new node_stream_1.Writable({
|
package/package.json
CHANGED
|
@@ -153,6 +153,45 @@ describe("VersionCheckPlugin", () => {
|
|
|
153
153
|
expect(res.body.pollingIntervalMs).toBe(86400000);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
it("handles numeric version parameter directly", async () => {
|
|
157
|
+
await VersionConfig.create({
|
|
158
|
+
webRequiredVersion: 100,
|
|
159
|
+
webWarningVersion: 150,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const res = await app.get("/version-check?version=50&platform=web");
|
|
163
|
+
expect(res.status).toBe(200);
|
|
164
|
+
expect(res.body.status).toBe("required");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns default warning message when warningMessage not set", async () => {
|
|
168
|
+
await VersionConfig.create({
|
|
169
|
+
webRequiredVersion: 0,
|
|
170
|
+
webWarningVersion: 100,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const res = await app.get("/version-check").query({platform: "web", version: 50});
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(res.body.status).toBe("warning");
|
|
176
|
+
expect(res.body.message).toBe(
|
|
177
|
+
"A new version is available. Please update for the best experience."
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns default required message when requiredMessage not set", async () => {
|
|
182
|
+
await VersionConfig.create({
|
|
183
|
+
webRequiredVersion: 100,
|
|
184
|
+
webWarningVersion: 150,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const res = await app.get("/version-check").query({platform: "web", version: 50});
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
expect(res.body.status).toBe("required");
|
|
190
|
+
expect(res.body.message).toBe(
|
|
191
|
+
"This version is no longer supported. Please update to continue."
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
156
195
|
it("version equal to required returns warning not required", async () => {
|
|
157
196
|
await VersionConfig.create({
|
|
158
197
|
webRequiredVersion: 100,
|
|
@@ -163,4 +202,46 @@ describe("VersionCheckPlugin", () => {
|
|
|
163
202
|
expect(res.status).toBe(200);
|
|
164
203
|
expect(res.body.status).toBe("warning");
|
|
165
204
|
});
|
|
205
|
+
|
|
206
|
+
it("uses default messages when warningMessage/requiredMessage are not set", async () => {
|
|
207
|
+
await VersionConfig.create({
|
|
208
|
+
webRequiredVersion: 100,
|
|
209
|
+
webWarningVersion: 200,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const warningRes = await app.get("/version-check").query({platform: "web", version: 150});
|
|
213
|
+
expect(warningRes.body.status).toBe("warning");
|
|
214
|
+
expect(warningRes.body.message).toBe(
|
|
215
|
+
"A new version is available. Please update for the best experience."
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const requiredRes = await app.get("/version-check").query({platform: "web", version: 50});
|
|
219
|
+
expect(requiredRes.body.status).toBe("required");
|
|
220
|
+
expect(requiredRes.body.message).toBe(
|
|
221
|
+
"This version is no longer supported. Please update to continue."
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("handles numeric version parameter", async () => {
|
|
226
|
+
const res = await app.get("/version-check?version=50&platform=web");
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("VersionCheckPlugin direct usage", () => {
|
|
232
|
+
it("can be instantiated and register called directly on an express app", async () => {
|
|
233
|
+
const express = require("express");
|
|
234
|
+
const plugin = new VersionCheckPlugin();
|
|
235
|
+
expect(plugin).toBeDefined();
|
|
236
|
+
expect(plugin).toBeInstanceOf(VersionCheckPlugin);
|
|
237
|
+
expect(typeof plugin.register).toBe("function");
|
|
238
|
+
|
|
239
|
+
const expressApp = express();
|
|
240
|
+
plugin.register(expressApp);
|
|
241
|
+
|
|
242
|
+
const testApp = supertest(expressApp);
|
|
243
|
+
const res = await testApp.get("/version-check");
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
expect(res.body.status).toBe("ok");
|
|
246
|
+
});
|
|
166
247
|
});
|
package/src/betterAuthSetup.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {Application, NextFunction, Request, Response} from "express";
|
|
|
12
12
|
import mongoose from "mongoose";
|
|
13
13
|
import type {UserModel} from "./auth";
|
|
14
14
|
import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
|
|
15
|
+
import {APIError} from "./errors";
|
|
15
16
|
import {logger} from "./logger";
|
|
16
17
|
import {findOneOrNoneFor} from "./plugins";
|
|
17
18
|
import {updateRequestContextFromRequest} from "./requestContext";
|
|
@@ -44,12 +45,18 @@ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthIn
|
|
|
44
45
|
|
|
45
46
|
const secret = config.secret || process.env.BETTER_AUTH_SECRET;
|
|
46
47
|
if (!secret) {
|
|
47
|
-
throw new
|
|
48
|
+
throw new APIError({
|
|
49
|
+
status: 500,
|
|
50
|
+
title: "BETTER_AUTH_SECRET must be set in env or config.secret must be provided.",
|
|
51
|
+
});
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
const baseURL = config.baseURL || process.env.BETTER_AUTH_URL;
|
|
51
55
|
if (!baseURL) {
|
|
52
|
-
throw new
|
|
56
|
+
throw new APIError({
|
|
57
|
+
status: 500,
|
|
58
|
+
title: "BETTER_AUTH_URL must be set in env or config.baseURL must be provided.",
|
|
59
|
+
});
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
const basePath = config.basePath ?? "/api/auth";
|
|
@@ -253,7 +260,10 @@ export const getMongoClientFromMongoose = (): MongoClientLike => {
|
|
|
253
260
|
const connection = mongoose.connection;
|
|
254
261
|
const client = (connection as unknown as {client?: MongoClientLike}).client;
|
|
255
262
|
if (!client) {
|
|
256
|
-
throw new
|
|
263
|
+
throw new APIError({
|
|
264
|
+
status: 500,
|
|
265
|
+
title: "Mongoose is not connected. Ensure MongoDB connection is established first.",
|
|
266
|
+
});
|
|
257
267
|
}
|
|
258
268
|
return client;
|
|
259
269
|
};
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* `envConfigurationPlugin` provides a drop-in Mongoose schema integration.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import {APIError} from "./errors";
|
|
25
|
+
|
|
24
26
|
const overrides = new Map<string, string | undefined>();
|
|
25
27
|
|
|
26
28
|
let cachedEnv: Record<string, string> | null = null;
|
|
@@ -46,7 +48,7 @@ const REGISTRY: Record<string, ConfigRegistration> = Object.create(null);
|
|
|
46
48
|
*/
|
|
47
49
|
const register = (key: string, registration: ConfigRegistration = {}): void => {
|
|
48
50
|
if (REGISTRY[key]) {
|
|
49
|
-
throw new
|
|
51
|
+
throw new APIError({status: 500, title: `Config key "${key}" registered more than once`});
|
|
50
52
|
}
|
|
51
53
|
REGISTRY[key] = registration;
|
|
52
54
|
};
|
|
@@ -88,7 +90,11 @@ const getNumber = (key: string): number | undefined => {
|
|
|
88
90
|
// whereas parseFloat would silently truncate to 5000.
|
|
89
91
|
const parsed = Number(raw);
|
|
90
92
|
if (!Number.isFinite(parsed)) {
|
|
91
|
-
throw new
|
|
93
|
+
throw new APIError({
|
|
94
|
+
error: new Error(`Config key "${key}" is not a valid number: ${JSON.stringify(raw)}`),
|
|
95
|
+
status: 500,
|
|
96
|
+
title: `Config key "${key}" is not a valid number`,
|
|
97
|
+
});
|
|
92
98
|
}
|
|
93
99
|
return parsed;
|
|
94
100
|
};
|
|
@@ -115,7 +121,11 @@ const getJSON = <T = unknown>(key: string): T | undefined => {
|
|
|
115
121
|
try {
|
|
116
122
|
return JSON.parse(raw) as T;
|
|
117
123
|
} catch (error) {
|
|
118
|
-
throw new
|
|
124
|
+
throw new APIError({
|
|
125
|
+
error: new Error(`Config key "${key}" is not valid JSON: ${(error as Error).message}`),
|
|
126
|
+
status: 500,
|
|
127
|
+
title: `Config key "${key}" is not valid JSON`,
|
|
128
|
+
});
|
|
119
129
|
}
|
|
120
130
|
};
|
|
121
131
|
|
package/src/errors.ts
CHANGED
|
@@ -136,26 +136,32 @@ export class APIError extends Error {
|
|
|
136
136
|
// Create an errors field for storing error information in a JSONAPI compatible form directly on a
|
|
137
137
|
// model.
|
|
138
138
|
export const errorsPlugin = (schema: Schema): void => {
|
|
139
|
-
const errorSchema = new Schema(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
type: String,
|
|
139
|
+
const errorSchema = new Schema(
|
|
140
|
+
{
|
|
141
|
+
code: {description: "Application-specific error code", type: String},
|
|
142
|
+
detail: {description: "Human-readable explanation of the error", type: String},
|
|
143
|
+
id: {description: "Unique identifier for this error occurrence", type: String},
|
|
144
|
+
links: {
|
|
145
|
+
about: {description: "Link to documentation about this error", type: String},
|
|
146
|
+
type: {description: "Link describing the error type", type: String},
|
|
147
|
+
},
|
|
148
|
+
meta: {
|
|
149
|
+
description: "Non-standard meta information about the error",
|
|
150
|
+
type: Schema.Types.Mixed,
|
|
151
|
+
},
|
|
152
|
+
source: {
|
|
153
|
+
header: {description: "HTTP header that caused the error", type: String},
|
|
154
|
+
parameter: {description: "Query parameter that caused the error", type: String},
|
|
155
|
+
pointer: {
|
|
156
|
+
description: "JSON pointer to the request field that caused the error",
|
|
157
|
+
type: String,
|
|
158
|
+
},
|
|
154
159
|
},
|
|
160
|
+
status: {description: "HTTP status code for this error", type: Number},
|
|
161
|
+
title: {description: "Short summary of the error", required: true, type: String},
|
|
155
162
|
},
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
163
|
+
{_id: false, strict: "throw"}
|
|
164
|
+
);
|
|
159
165
|
|
|
160
166
|
schema.add({apiErrors: errorSchema});
|
|
161
167
|
};
|
package/src/example.ts
CHANGED
|
@@ -48,6 +48,9 @@ const userSchema = new Schema<User>(
|
|
|
48
48
|
// biome-ignore lint/suspicious/noExplicitAny: passport-local-mongoose's plugin type is incompatible with mongoose Schema generics
|
|
49
49
|
userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
|
|
50
50
|
userSchema.plugin(createdUpdatedPlugin);
|
|
51
|
+
userSchema.plugin(isDeletedPlugin);
|
|
52
|
+
userSchema.plugin(findOneOrNone);
|
|
53
|
+
userSchema.plugin(findExactlyOne);
|
|
51
54
|
userSchema.plugin(baseUserPlugin);
|
|
52
55
|
const UserModel = model<User>("User", userSchema);
|
|
53
56
|
|
|
@@ -770,6 +770,182 @@ describe("expressServer", () => {
|
|
|
770
770
|
|
|
771
771
|
expect(func).toHaveBeenCalled();
|
|
772
772
|
});
|
|
773
|
+
|
|
774
|
+
it("fires warning timeout callback when script is slow", async () => {
|
|
775
|
+
// terminateTimeout = 0.3s → warnTime = 150ms, closeTime = 300ms
|
|
776
|
+
// The func takes 200ms, so the warn callback fires at 150ms,
|
|
777
|
+
// but func completes at 200ms before the terminate callback at 300ms.
|
|
778
|
+
// afterEach clears the terminate timer.
|
|
779
|
+
const func = mock(
|
|
780
|
+
() => new Promise<string>((resolve) => originalSetTimeout(() => resolve("done"), 200))
|
|
781
|
+
);
|
|
782
|
+
try {
|
|
783
|
+
await wrapScript(func, {terminateTimeout: 0.3});
|
|
784
|
+
} catch {
|
|
785
|
+
// process.exit(0) throws
|
|
786
|
+
}
|
|
787
|
+
expect(process.exit).toHaveBeenCalledWith(0);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
describe("setupServer with listen (skipListen false)", () => {
|
|
792
|
+
const originalEnv = process.env;
|
|
793
|
+
|
|
794
|
+
beforeEach(() => {
|
|
795
|
+
process.env = {
|
|
796
|
+
...originalEnv,
|
|
797
|
+
PORT: "0", // Use port 0 to let OS assign a random free port
|
|
798
|
+
REFRESH_TOKEN_SECRET: "test-refresh-secret",
|
|
799
|
+
SESSION_SECRET: "test-session-secret",
|
|
800
|
+
TOKEN_EXPIRES_IN: "1h",
|
|
801
|
+
TOKEN_ISSUER: "test-issuer",
|
|
802
|
+
TOKEN_SECRET: "test-secret",
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
afterEach(() => {
|
|
807
|
+
process.env = originalEnv;
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("starts the server when skipListen is false", async () => {
|
|
811
|
+
const addRoutes = () => {};
|
|
812
|
+
// Mock app.listen on the Express prototype to avoid opening a real port
|
|
813
|
+
const express = await import("express");
|
|
814
|
+
const originalListen = express.default.application.listen;
|
|
815
|
+
// biome-ignore lint/suspicious/noExplicitAny: mocking Express internals requires type escape
|
|
816
|
+
express.default.application.listen = mock(function (this: unknown, ...args: unknown[]) {
|
|
817
|
+
const cb = args.find((a: unknown) => typeof a === "function") as (() => void) | undefined;
|
|
818
|
+
if (cb) {
|
|
819
|
+
cb();
|
|
820
|
+
}
|
|
821
|
+
return this;
|
|
822
|
+
}) as unknown as typeof originalListen;
|
|
823
|
+
try {
|
|
824
|
+
const app = setupServer({
|
|
825
|
+
addRoutes,
|
|
826
|
+
skipListen: false,
|
|
827
|
+
userModel: UserModel as any,
|
|
828
|
+
});
|
|
829
|
+
expect(app).toBeDefined();
|
|
830
|
+
} finally {
|
|
831
|
+
express.default.application.listen = originalListen;
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("handles listen error with invalid port", () => {
|
|
836
|
+
process.env.PORT = "-1";
|
|
837
|
+
const addRoutes = () => {};
|
|
838
|
+
// Using an invalid port should trigger the catch block and process.exit(1)
|
|
839
|
+
const originalExit = process.exit;
|
|
840
|
+
process.exit = (() => {}) as unknown as typeof process.exit;
|
|
841
|
+
try {
|
|
842
|
+
setupServer({
|
|
843
|
+
addRoutes,
|
|
844
|
+
skipListen: false,
|
|
845
|
+
userModel: UserModel as any,
|
|
846
|
+
});
|
|
847
|
+
} catch {
|
|
848
|
+
// May throw
|
|
849
|
+
}
|
|
850
|
+
process.exit = originalExit;
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
describe("setupServer with listen", () => {
|
|
855
|
+
const originalEnv = process.env;
|
|
856
|
+
const http = require("node:http");
|
|
857
|
+
let activeServer: any = null;
|
|
858
|
+
let originalListen: any = null;
|
|
859
|
+
|
|
860
|
+
beforeEach(() => {
|
|
861
|
+
process.env = {
|
|
862
|
+
...originalEnv,
|
|
863
|
+
PORT: "0",
|
|
864
|
+
REFRESH_TOKEN_SECRET: "test-refresh-secret",
|
|
865
|
+
SESSION_SECRET: "test-session-secret",
|
|
866
|
+
TOKEN_EXPIRES_IN: "1h",
|
|
867
|
+
TOKEN_ISSUER: "test-issuer",
|
|
868
|
+
TOKEN_SECRET: "test-secret",
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
originalListen = http.Server.prototype.listen;
|
|
872
|
+
http.Server.prototype.listen = function (...args: any[]) {
|
|
873
|
+
activeServer = this;
|
|
874
|
+
return originalListen.apply(this, args);
|
|
875
|
+
};
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
afterEach(async () => {
|
|
879
|
+
process.env = originalEnv;
|
|
880
|
+
http.Server.prototype.listen = originalListen;
|
|
881
|
+
if (activeServer) {
|
|
882
|
+
await new Promise<void>((resolve) => activeServer.close(() => resolve()));
|
|
883
|
+
activeServer = null;
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("starts listening on a port when skipListen is false", async () => {
|
|
888
|
+
const addRoutes = () => {};
|
|
889
|
+
|
|
890
|
+
const app = setupServer({
|
|
891
|
+
addRoutes,
|
|
892
|
+
skipListen: false,
|
|
893
|
+
userModel: UserModel as any,
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
expect(app).toBeDefined();
|
|
897
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe("wrapScript timeout callbacks", () => {
|
|
902
|
+
const originalEnv = process.env;
|
|
903
|
+
const originalExit = process.exit;
|
|
904
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
905
|
+
const timerIds: ReturnType<typeof setTimeout>[] = [];
|
|
906
|
+
const timerCallbacks: Array<{callback: () => void; delay: number}> = [];
|
|
907
|
+
|
|
908
|
+
beforeEach(() => {
|
|
909
|
+
process.env = {
|
|
910
|
+
...process.env,
|
|
911
|
+
REFRESH_TOKEN_SECRET: "test-refresh-secret",
|
|
912
|
+
SESSION_SECRET: "test-session-secret",
|
|
913
|
+
TOKEN_EXPIRES_IN: "1h",
|
|
914
|
+
TOKEN_ISSUER: "test-issuer",
|
|
915
|
+
TOKEN_SECRET: "test-secret",
|
|
916
|
+
};
|
|
917
|
+
process.exit = mock(() => {
|
|
918
|
+
throw new Error("__EXIT__");
|
|
919
|
+
}) as unknown as typeof process.exit;
|
|
920
|
+
|
|
921
|
+
timerCallbacks.length = 0;
|
|
922
|
+
timerIds.length = 0;
|
|
923
|
+
globalThis.setTimeout = ((cb: () => void, delay: number) => {
|
|
924
|
+
timerCallbacks.push({callback: cb, delay});
|
|
925
|
+
const id = originalSetTimeout(cb, delay);
|
|
926
|
+
timerIds.push(id);
|
|
927
|
+
return id;
|
|
928
|
+
}) as typeof setTimeout;
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
afterEach(() => {
|
|
932
|
+
for (const id of timerIds) {
|
|
933
|
+
clearTimeout(id);
|
|
934
|
+
}
|
|
935
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
936
|
+
process.exit = originalExit;
|
|
937
|
+
process.env = originalEnv;
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("registers warn and terminate timeouts with correct delays", async () => {
|
|
941
|
+
const func = async () => "ok";
|
|
942
|
+
await expect(wrapScript(func, {terminateTimeout: 100})).rejects.toThrow("__EXIT__");
|
|
943
|
+
|
|
944
|
+
const warnTimer = timerCallbacks.find((t) => t.delay === 50000);
|
|
945
|
+
const closeTimer = timerCallbacks.find((t) => t.delay === 100000);
|
|
946
|
+
expect(warnTimer).toBeDefined();
|
|
947
|
+
expect(closeTimer).toBeDefined();
|
|
948
|
+
});
|
|
773
949
|
});
|
|
774
950
|
|
|
775
951
|
describe("setupServer error handling", () => {
|
|
@@ -20,6 +20,7 @@ type WatchedChange = Extract<
|
|
|
20
20
|
>;
|
|
21
21
|
|
|
22
22
|
import type {User} from "../auth";
|
|
23
|
+
import {APIError} from "../errors";
|
|
23
24
|
import {logger} from "../logger";
|
|
24
25
|
import {checkPermissions} from "../permissions";
|
|
25
26
|
import {matchesQuery} from "./queryMatcher";
|
|
@@ -402,7 +403,10 @@ export const startChangeStreamWatcher = (
|
|
|
402
403
|
|
|
403
404
|
const nativeDb = mongoose.connection.db;
|
|
404
405
|
if (!nativeDb) {
|
|
405
|
-
throw new
|
|
406
|
+
throw new APIError({
|
|
407
|
+
status: 500,
|
|
408
|
+
title: "MongoDB connection not available for change stream",
|
|
409
|
+
});
|
|
406
410
|
}
|
|
407
411
|
|
|
408
412
|
const options: ChangeStreamOptions = {
|
|
@@ -417,7 +421,7 @@ export const startChangeStreamWatcher = (
|
|
|
417
421
|
changeWatcher = nativeDb.watch(pipeline, options);
|
|
418
422
|
|
|
419
423
|
if (!changeWatcher) {
|
|
420
|
-
throw new
|
|
424
|
+
throw new APIError({status: 500, title: "Failed to create change stream watcher"});
|
|
421
425
|
}
|
|
422
426
|
|
|
423
427
|
changeWatcher.on("change", async (rawChange: ChangeStreamDocument) => {
|