@terreno/api 0.0.11 → 0.0.13

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.
@@ -0,0 +1,579 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import express from "express";
3
+ import supertest from "supertest";
4
+
5
+ import {
6
+ createRouter,
7
+ createRouterWithAuth,
8
+ cronjob,
9
+ logRequests,
10
+ setupEnvironment,
11
+ setupServer,
12
+ } from "./expressServer";
13
+ import {UserModel} from "./tests";
14
+
15
+ describe("expressServer", () => {
16
+ describe("setupEnvironment", () => {
17
+ const originalEnv = process.env;
18
+
19
+ beforeEach(() => {
20
+ // Reset env to a clean state with required values
21
+ process.env = {
22
+ ...originalEnv,
23
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
24
+ SESSION_SECRET: "test-session-secret",
25
+ TOKEN_ISSUER: "test-issuer",
26
+ TOKEN_SECRET: "test-secret",
27
+ };
28
+ });
29
+
30
+ afterEach(() => {
31
+ process.env = originalEnv;
32
+ });
33
+
34
+ it("throws error when TOKEN_ISSUER is not set", () => {
35
+ process.env.TOKEN_ISSUER = "";
36
+ expect(() => setupEnvironment()).toThrow("TOKEN_ISSUER must be set in env.");
37
+ });
38
+
39
+ it("throws error when TOKEN_SECRET is not set", () => {
40
+ process.env.TOKEN_SECRET = "";
41
+ expect(() => setupEnvironment()).toThrow("TOKEN_SECRET must be set.");
42
+ });
43
+
44
+ it("throws error when REFRESH_TOKEN_SECRET is not set", () => {
45
+ process.env.REFRESH_TOKEN_SECRET = "";
46
+ expect(() => setupEnvironment()).toThrow("REFRESH_TOKEN_SECRET must be set.");
47
+ });
48
+
49
+ it("throws error when SESSION_SECRET is not set", () => {
50
+ process.env.SESSION_SECRET = "";
51
+ expect(() => setupEnvironment()).toThrow("SESSION_SECRET must be set.");
52
+ });
53
+
54
+ it("does not throw when all required env vars are set", () => {
55
+ expect(() => setupEnvironment()).not.toThrow();
56
+ });
57
+ });
58
+
59
+ describe("logRequests", () => {
60
+ it("logs request with admin user type", () => {
61
+ const req = {
62
+ body: {},
63
+ method: "GET",
64
+ url: "/test",
65
+ user: {admin: true, id: "admin-123"},
66
+ };
67
+ const res = {
68
+ locals: {},
69
+ on: () => {},
70
+ };
71
+ let nextCalled = false;
72
+ const next = () => {
73
+ nextCalled = true;
74
+ };
75
+
76
+ logRequests(req, res, next);
77
+ expect(nextCalled).toBe(true);
78
+ });
79
+
80
+ it("logs request with test user type", () => {
81
+ const req = {
82
+ body: {},
83
+ method: "GET",
84
+ url: "/test",
85
+ user: {id: "test-123", testUser: true},
86
+ };
87
+ const res = {
88
+ locals: {},
89
+ on: () => {},
90
+ };
91
+ let nextCalled = false;
92
+ const next = () => {
93
+ nextCalled = true;
94
+ };
95
+
96
+ logRequests(req, res, next);
97
+ expect(nextCalled).toBe(true);
98
+ });
99
+
100
+ it("logs request with custom user type", () => {
101
+ const req = {
102
+ body: {},
103
+ method: "GET",
104
+ url: "/test",
105
+ user: {id: "user-123", type: "CustomType"},
106
+ };
107
+ const res = {
108
+ locals: {},
109
+ on: () => {},
110
+ };
111
+ let nextCalled = false;
112
+ const next = () => {
113
+ nextCalled = true;
114
+ };
115
+
116
+ logRequests(req, res, next);
117
+ expect(nextCalled).toBe(true);
118
+ });
119
+
120
+ it("masks password in body", () => {
121
+ const req = {
122
+ body: {password: "secret123", username: "testuser"},
123
+ method: "POST",
124
+ url: "/login",
125
+ };
126
+ const res = {
127
+ locals: {},
128
+ on: () => {},
129
+ };
130
+ let nextCalled = false;
131
+ const next = () => {
132
+ nextCalled = true;
133
+ };
134
+
135
+ logRequests(req, res, next);
136
+ expect(nextCalled).toBe(true);
137
+ // Original body should not be modified
138
+ expect(req.body.password).toBe("secret123");
139
+ });
140
+
141
+ it("triggers onFinished callback with route info", async () => {
142
+ const app = express();
143
+ app.use(logRequests);
144
+ app.get("/test", (req: any, res) => {
145
+ req.route = {path: "/test"};
146
+ req.routeMount = "/api";
147
+ res.json({ok: true});
148
+ });
149
+
150
+ await supertest(app).get("/test").expect(200);
151
+ });
152
+
153
+ it("triggers onFinished callback without route (for 404)", async () => {
154
+ const app = express();
155
+ app.use(logRequests);
156
+ // No routes defined, so it will 404
157
+
158
+ await supertest(app).get("/nonexistent").expect(404);
159
+ });
160
+
161
+ it("logs slow GET requests when enabled", async () => {
162
+ const app = express();
163
+ // Store logging options
164
+ app.use((_req, res, next) => {
165
+ res.locals.loggingOptions = {
166
+ logSlowRequests: true,
167
+ logSlowRequestsReadMs: 1, // Very low threshold to trigger slow request warning
168
+ };
169
+ next();
170
+ });
171
+ app.use(logRequests);
172
+ app.get("/slow", async (_req, res) => {
173
+ // Add small delay to exceed threshold
174
+ await new Promise((resolve) => setTimeout(resolve, 10));
175
+ res.json({ok: true});
176
+ });
177
+
178
+ await supertest(app).get("/slow").expect(200);
179
+ });
180
+
181
+ it("logs slow write requests when enabled", async () => {
182
+ const app = express();
183
+ app.use(express.json());
184
+ app.use((_req, res, next) => {
185
+ res.locals.loggingOptions = {
186
+ logSlowRequests: true,
187
+ logSlowRequestsWriteMs: 1, // Very low threshold
188
+ };
189
+ next();
190
+ });
191
+ app.use(logRequests);
192
+ app.post("/slow", async (_req, res) => {
193
+ await new Promise((resolve) => setTimeout(resolve, 10));
194
+ res.json({ok: true});
195
+ });
196
+
197
+ await supertest(app).post("/slow").send({data: "test"}).expect(200);
198
+ });
199
+
200
+ it("handles request with route path only (no routeMount)", async () => {
201
+ const app = express();
202
+ app.use(logRequests);
203
+ app.get("/test", (req: any, res) => {
204
+ req.route = {path: "/test"};
205
+ // No routeMount set
206
+ res.json({ok: true});
207
+ });
208
+
209
+ await supertest(app).get("/test").expect(200);
210
+ });
211
+ });
212
+
213
+ describe("createRouter", () => {
214
+ it("creates router with root path and adds routes", () => {
215
+ let routesCalled = false;
216
+ const addRoutes = (router: any) => {
217
+ routesCalled = true;
218
+ router.get("/test", (_req: any, res: any) => res.send("ok"));
219
+ };
220
+
221
+ const result = createRouter("/api", addRoutes);
222
+
223
+ expect(result[0]).toBe("/api");
224
+ expect(routesCalled).toBe(true);
225
+ expect(result.length).toBe(2); // [path, router]
226
+ });
227
+
228
+ it("creates router with middleware", () => {
229
+ const middleware1 = (_req: any, _res: any, next: any) => next();
230
+ const middleware2 = (_req: any, _res: any, next: any) => next();
231
+ const addRoutes = () => {};
232
+
233
+ const result = createRouter("/api", addRoutes, [middleware1, middleware2]);
234
+
235
+ expect(result[0]).toBe("/api");
236
+ expect(result.length).toBe(4); // [path, middleware1, middleware2, router]
237
+ });
238
+
239
+ it("routePathMiddleware sets routeMount on request", () => {
240
+ const addRoutes = (router: any) => {
241
+ router.get("/test", (req: any, res: any) => {
242
+ res.json({routeMount: req.routeMount});
243
+ });
244
+ };
245
+
246
+ const result = createRouter("/api", addRoutes);
247
+ const app = express();
248
+ app.use(...(result as [string, ...any[]]));
249
+
250
+ // The routePathMiddleware is internal, but we can verify the router works
251
+ expect(result[0]).toBe("/api");
252
+ });
253
+ });
254
+
255
+ describe("createRouterWithAuth", () => {
256
+ it("creates router with passport authentication middleware", () => {
257
+ let routesCalled = false;
258
+ const addRoutes = (router: any) => {
259
+ routesCalled = true;
260
+ router.get("/protected", (_req: any, res: any) => res.send("ok"));
261
+ };
262
+
263
+ const result = createRouterWithAuth("/secure", addRoutes);
264
+
265
+ expect(result[0]).toBe("/secure");
266
+ expect(routesCalled).toBe(true);
267
+ // Should have path + passport middleware + router = 3 elements minimum
268
+ expect(result.length).toBeGreaterThanOrEqual(2);
269
+ });
270
+
271
+ it("includes additional middleware", () => {
272
+ const customMiddleware = (_req: any, _res: any, next: any) => next();
273
+ const addRoutes = () => {};
274
+
275
+ const result = createRouterWithAuth("/secure", addRoutes, [customMiddleware]);
276
+
277
+ expect(result[0]).toBe("/secure");
278
+ // path + passport + customMiddleware + router
279
+ expect(result.length).toBeGreaterThanOrEqual(3);
280
+ });
281
+ });
282
+
283
+ describe("cronjob", () => {
284
+ it("accepts custom cron schedule for hourly", () => {
285
+ const callback = () => {};
286
+
287
+ // Every hour at minute 0
288
+ expect(() => cronjob("test-hourly", "0 * * * *", callback)).not.toThrow();
289
+ });
290
+
291
+ it("accepts custom cron schedule for minutely", () => {
292
+ const callback = () => {};
293
+
294
+ // Every minute
295
+ expect(() => cronjob("test-minutely", "* * * * *", callback)).not.toThrow();
296
+ });
297
+
298
+ it("accepts custom cron schedule", () => {
299
+ const callback = () => {};
300
+
301
+ // Every day at midnight
302
+ expect(() => cronjob("test-custom", "0 0 * * *", callback)).not.toThrow();
303
+ });
304
+
305
+ it("throws error for invalid cron schedule", () => {
306
+ const callback = () => {};
307
+
308
+ expect(() => cronjob("test-invalid", "invalid-cron", callback)).toThrow(
309
+ "Failed to create cronjob"
310
+ );
311
+ });
312
+
313
+ // Note: The "hourly" and "minutely" aliases have a bug - they convert the
314
+ // schedule to a cron expression but then use the original schedule string.
315
+ // This test documents that current (buggy) behavior.
316
+ it("hourly alias fails due to bug in implementation", () => {
317
+ const callback = () => {};
318
+ expect(() => cronjob("test-hourly-alias", "hourly", callback)).toThrow(
319
+ "Failed to create cronjob"
320
+ );
321
+ });
322
+
323
+ it("minutely alias fails due to bug in implementation", () => {
324
+ const callback = () => {};
325
+ expect(() => cronjob("test-minutely-alias", "minutely", callback)).toThrow(
326
+ "Failed to create cronjob"
327
+ );
328
+ });
329
+ });
330
+
331
+ describe("createRouter routePathMiddleware", () => {
332
+ it("initializes routeMount array when not present", async () => {
333
+ const addRoutes = (router: any) => {
334
+ router.get("/test", (req: any, res: any) => {
335
+ res.json({routeMount: req.routeMount});
336
+ });
337
+ };
338
+
339
+ const result = createRouter("/api", addRoutes);
340
+ const app = express();
341
+ app.use(...(result as [string, ...any[]]));
342
+
343
+ const response = await supertest(app).get("/api/test").expect(200);
344
+ expect(response.body.routeMount).toEqual(["/api"]);
345
+ });
346
+
347
+ it("appends to existing routeMount array", async () => {
348
+ const addRoutes = (router: any) => {
349
+ router.get("/test", (req: any, res: any) => {
350
+ res.json({routeMount: req.routeMount});
351
+ });
352
+ };
353
+
354
+ // Create nested routers
355
+ const innerResult = createRouter("/inner", addRoutes);
356
+ const outerAddRoutes = (router: any) => {
357
+ router.use(...(innerResult as [string, ...any[]]));
358
+ };
359
+ const outerResult = createRouter("/outer", outerAddRoutes);
360
+
361
+ const app = express();
362
+ app.use(...(outerResult as [string, ...any[]]));
363
+
364
+ const response = await supertest(app).get("/outer/inner/test").expect(200);
365
+ expect(response.body.routeMount).toEqual(["/outer", "/inner"]);
366
+ });
367
+ });
368
+
369
+ describe("setupServer", () => {
370
+ const originalEnv = process.env;
371
+
372
+ beforeEach(() => {
373
+ process.env = {
374
+ ...originalEnv,
375
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
376
+ SESSION_SECRET: "test-session-secret",
377
+ TOKEN_EXPIRES_IN: "1h",
378
+ TOKEN_ISSUER: "test-issuer",
379
+ TOKEN_SECRET: "test-secret",
380
+ };
381
+ });
382
+
383
+ afterEach(() => {
384
+ process.env = originalEnv;
385
+ });
386
+
387
+ it("creates server with skipListen option", () => {
388
+ const addRoutes = () => {};
389
+
390
+ const app = setupServer({
391
+ addRoutes,
392
+ skipListen: true,
393
+ userModel: UserModel as any,
394
+ });
395
+
396
+ expect(app).toBeDefined();
397
+ });
398
+
399
+ it("creates server with addMiddleware option", () => {
400
+ let middlewareCalled = false;
401
+ const addMiddleware = (app: any) => {
402
+ middlewareCalled = true;
403
+ app.use((_req: any, _res: any, next: any) => next());
404
+ };
405
+ const addRoutes = () => {};
406
+
407
+ const app = setupServer({
408
+ addMiddleware,
409
+ addRoutes,
410
+ skipListen: true,
411
+ userModel: UserModel as any,
412
+ });
413
+
414
+ expect(app).toBeDefined();
415
+ expect(middlewareCalled).toBe(true);
416
+ });
417
+
418
+ it("creates server with custom corsOrigin", () => {
419
+ const addRoutes = () => {};
420
+
421
+ const app = setupServer({
422
+ addRoutes,
423
+ corsOrigin: "https://example.com",
424
+ skipListen: true,
425
+ userModel: UserModel as any,
426
+ });
427
+
428
+ expect(app).toBeDefined();
429
+ });
430
+
431
+ it("creates server with authOptions", () => {
432
+ const addRoutes = () => {};
433
+
434
+ const app = setupServer({
435
+ addRoutes,
436
+ authOptions: {
437
+ generateJWTPayload: (user) => ({customField: "test", id: user._id}),
438
+ generateTokenExpiration: () => "2h",
439
+ },
440
+ skipListen: true,
441
+ userModel: UserModel as any,
442
+ });
443
+
444
+ expect(app).toBeDefined();
445
+ });
446
+ });
447
+
448
+ describe("logRequests edge cases", () => {
449
+ it("warns for request without route but with success status code", async () => {
450
+ const app = express();
451
+ app.use(logRequests);
452
+ // Middleware that sets statusCode < 400 but doesn't define route
453
+ app.use((_req: any, res) => {
454
+ res.status(200).json({ok: true});
455
+ });
456
+
457
+ await supertest(app).get("/no-route").expect(200);
458
+ });
459
+
460
+ it("handles request with routeMount as string (legacy)", async () => {
461
+ const app = express();
462
+ app.use(logRequests);
463
+ app.get("/test", (req: any, res) => {
464
+ req.route = {path: "/test"};
465
+ req.routeMount = "/api"; // String instead of array
466
+ res.json({ok: true});
467
+ });
468
+
469
+ await supertest(app).get("/test").expect(200);
470
+ });
471
+ });
472
+
473
+ describe("setupServer with full integration", () => {
474
+ const originalEnv = process.env;
475
+
476
+ beforeEach(() => {
477
+ process.env = {
478
+ ...originalEnv,
479
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
480
+ SESSION_SECRET: "test-session-secret",
481
+ TOKEN_EXPIRES_IN: "1h",
482
+ TOKEN_ISSUER: "test-issuer",
483
+ TOKEN_SECRET: "test-secret",
484
+ };
485
+ });
486
+
487
+ afterEach(() => {
488
+ process.env = originalEnv;
489
+ });
490
+
491
+ it("sets Sentry transaction ID tag from header", async () => {
492
+ const addRoutes = (app: any) => {
493
+ app.get("/test", (_req: any, res: any) => {
494
+ res.json({ok: true});
495
+ });
496
+ };
497
+
498
+ const app = setupServer({
499
+ addRoutes,
500
+ skipListen: true,
501
+ userModel: UserModel as any,
502
+ });
503
+
504
+ await supertest(app).get("/test").set("X-Transaction-ID", "txn-123").expect(200);
505
+ });
506
+
507
+ it("sets Sentry session ID tag from header", async () => {
508
+ const addRoutes = (app: any) => {
509
+ app.get("/test", (_req: any, res: any) => {
510
+ res.json({ok: true});
511
+ });
512
+ };
513
+
514
+ const app = setupServer({
515
+ addRoutes,
516
+ skipListen: true,
517
+ userModel: UserModel as any,
518
+ });
519
+
520
+ await supertest(app).get("/test").set("X-Session-ID", "session-456").expect(200);
521
+ });
522
+
523
+ it("sets both transaction and session ID tags", async () => {
524
+ const addRoutes = (app: any) => {
525
+ app.get("/test", (_req: any, res: any) => {
526
+ res.json({ok: true});
527
+ });
528
+ };
529
+
530
+ const app = setupServer({
531
+ addRoutes,
532
+ skipListen: true,
533
+ userModel: UserModel as any,
534
+ });
535
+
536
+ await supertest(app)
537
+ .get("/test")
538
+ .set("X-Transaction-ID", "txn-123")
539
+ .set("X-Session-ID", "session-456")
540
+ .expect(200);
541
+ });
542
+
543
+ it("handles fallthrough error handler", async () => {
544
+ const addRoutes = (app: any) => {
545
+ app.get("/error", (_req: any, _res: any) => {
546
+ throw new Error("Unexpected error");
547
+ });
548
+ };
549
+
550
+ const app = setupServer({
551
+ addRoutes,
552
+ skipListen: true,
553
+ userModel: UserModel as any,
554
+ });
555
+
556
+ await supertest(app).get("/error").expect(500);
557
+ });
558
+
559
+ it("handles loggingOptions passed to setupServer", async () => {
560
+ const addRoutes = (app: any) => {
561
+ app.get("/test", (_req: any, res: any) => {
562
+ res.json({ok: true});
563
+ });
564
+ };
565
+
566
+ const app = setupServer({
567
+ addRoutes,
568
+ loggingOptions: {
569
+ logSlowRequests: true,
570
+ logSlowRequestsReadMs: 100,
571
+ },
572
+ skipListen: true,
573
+ userModel: UserModel as any,
574
+ });
575
+
576
+ await supertest(app).get("/test").expect(200);
577
+ });
578
+ });
579
+ });
@@ -4,33 +4,44 @@ import axios from "axios";
4
4
  import {APIError} from "../errors";
5
5
  import {logger} from "../logger";
6
6
  // Convenience method to send data to a Slack webhook.
7
+ // If `url` is provided, it will be used directly instead of looking up from environment.
8
+ // DEPRECATED: Looking up webhook URLs from the SLACK_WEBHOOKS environment variable by channel name
9
+ // is deprecated and will be removed in a future version. Please pass the `url` parameter directly.
7
10
  export async function sendToSlack(
8
11
  text: string,
9
12
  {
10
13
  slackChannel,
11
14
  shouldThrow = false,
12
15
  env,
13
- }: {slackChannel?: string; shouldThrow?: boolean; env?: string} = {}
16
+ url,
17
+ }: {slackChannel?: string; shouldThrow?: boolean; env?: string; url?: string} = {}
14
18
  ) {
15
- // since Slack now requires a webhook for each channel, we need to store them in the environment
16
- // as an object, so we can look them up by channel name.
17
- const slackWebhooksString = process.env.SLACK_WEBHOOKS;
18
- if (!slackWebhooksString) {
19
- logger.debug("You must set SLACK_WEBHOOKS in the environment to use sendToSlack.");
20
- return;
21
- }
22
- const slackWebhooks = JSON.parse(slackWebhooksString ?? "{}");
23
-
24
- const channel = slackChannel ?? "default";
25
-
26
- const slackWebhookUrl = slackWebhooks[channel] ?? slackWebhooks.default;
19
+ let slackWebhookUrl = url;
27
20
 
28
21
  if (!slackWebhookUrl) {
29
- Sentry.captureException(
30
- new Error(`No webhook url set in env for ${channel}. Slack message not sent`)
22
+ logger.debug(
23
+ "DEPRECATED: Looking up webhook URLs from SLACK_WEBHOOKS environment variable is deprecated and will be removed in a future version. Please pass the url parameter directly."
31
24
  );
32
- logger.debug(`No webhook url set in env for ${channel}.`);
33
- return;
25
+ // since Slack now requires a webhook for each channel, we need to store them in the environment
26
+ // as an object, so we can look them up by channel name.
27
+ const slackWebhooksString = process.env.SLACK_WEBHOOKS;
28
+ if (!slackWebhooksString) {
29
+ logger.debug("You must set SLACK_WEBHOOKS in the environment to use sendToSlack.");
30
+ return;
31
+ }
32
+ const slackWebhooks = JSON.parse(slackWebhooksString ?? "{}");
33
+
34
+ const channel = slackChannel ?? "default";
35
+
36
+ slackWebhookUrl = slackWebhooks[channel] ?? slackWebhooks.default;
37
+
38
+ if (!slackWebhookUrl) {
39
+ Sentry.captureException(
40
+ new Error(`No webhook url set in env for ${channel}. Slack message not sent`)
41
+ );
42
+ logger.debug(`No webhook url set in env for ${channel}.`);
43
+ return;
44
+ }
34
45
  }
35
46
 
36
47
  let formattedText = text;