@terreno/api 0.0.11-beta.1 → 0.0.12

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,704 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import type express from "express";
3
+ import supertest from "supertest";
4
+ import type TestAgent from "supertest/lib/agent";
5
+
6
+ import {modelRouter} from "./api";
7
+ import {addAuthRoutes, setupAuth} from "./auth";
8
+ import {APIError} from "./errors";
9
+ import {Permissions} from "./permissions";
10
+ import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
11
+
12
+ describe("pre and post hooks", () => {
13
+ let server: TestAgent;
14
+ let app: express.Application;
15
+ let agent: TestAgent;
16
+
17
+ beforeEach(async () => {
18
+ await setupDb();
19
+ app = getBaseServer();
20
+ setupAuth(app, UserModel as any);
21
+ addAuthRoutes(app, UserModel as any);
22
+ agent = await authAsUser(app, "notAdmin");
23
+ });
24
+
25
+ it("pre hooks change data", async () => {
26
+ let deleteCalled = false;
27
+ app.use(
28
+ "/food",
29
+ modelRouter(FoodModel, {
30
+ allowAnonymous: true,
31
+ permissions: {
32
+ create: [Permissions.IsAny],
33
+ delete: [Permissions.IsAny],
34
+ list: [Permissions.IsAny],
35
+ read: [Permissions.IsAny],
36
+ update: [Permissions.IsAny],
37
+ },
38
+ preCreate: (data: any) => {
39
+ data.calories = 14;
40
+ return data;
41
+ },
42
+ preDelete: (data: any) => {
43
+ deleteCalled = true;
44
+ return data;
45
+ },
46
+ preUpdate: (data: any) => {
47
+ data.calories = 15;
48
+ return data;
49
+ },
50
+ })
51
+ );
52
+ server = supertest(app);
53
+
54
+ let res = await server
55
+ .post("/food")
56
+ .send({
57
+ calories: 15,
58
+ name: "Broccoli",
59
+ })
60
+ .expect(201);
61
+ const broccoli = await FoodModel.findById(res.body.data._id);
62
+ if (!broccoli) {
63
+ throw new Error("Broccoli was not created");
64
+ }
65
+ expect(broccoli.name).toBe("Broccoli");
66
+ // Overwritten by the pre create hook
67
+ expect(broccoli.calories).toBe(14);
68
+
69
+ res = await server
70
+ .patch(`/food/${broccoli._id}`)
71
+ .send({
72
+ name: "Broccoli2",
73
+ })
74
+ .expect(200);
75
+ expect(res.body.data.name).toBe("Broccoli2");
76
+ // Updated by the pre update hook
77
+ expect(res.body.data.calories).toBe(15);
78
+
79
+ await agent.delete(`/food/${broccoli._id}`).expect(204);
80
+ expect(deleteCalled).toBe(true);
81
+ });
82
+
83
+ it("pre hooks return null", async () => {
84
+ const notAdmin = await UserModel.findOne({
85
+ email: "notAdmin@example.com",
86
+ });
87
+ const spinach = await FoodModel.create({
88
+ calories: 1,
89
+ created: new Date("2021-12-03T00:00:20.000Z"),
90
+ hidden: false,
91
+ name: "Spinach",
92
+ ownerId: (notAdmin as any)._id,
93
+ source: {
94
+ name: "Brand",
95
+ },
96
+ });
97
+
98
+ app.use(
99
+ "/food",
100
+ modelRouter(FoodModel, {
101
+ allowAnonymous: true,
102
+ permissions: {
103
+ create: [Permissions.IsAny],
104
+ delete: [Permissions.IsAny],
105
+ list: [Permissions.IsAny],
106
+ read: [Permissions.IsAny],
107
+ update: [Permissions.IsAny],
108
+ },
109
+ preCreate: () => null,
110
+ preDelete: () => null,
111
+ preUpdate: () => null,
112
+ })
113
+ );
114
+ server = supertest(app);
115
+
116
+ const res = await server
117
+ .post("/food")
118
+ .send({
119
+ calories: 15,
120
+ name: "Broccoli",
121
+ })
122
+ .expect(403);
123
+ const broccoli = await FoodModel.findById(res.body._id);
124
+ expect(broccoli).toBeNull();
125
+
126
+ await server
127
+ .patch(`/food/${spinach._id}`)
128
+ .send({
129
+ name: "Broccoli",
130
+ })
131
+ .expect(403);
132
+ await server.delete(`/food/${spinach._id}`).expect(403);
133
+ });
134
+
135
+ it("post hooks succeed", async () => {
136
+ let deleteCalled = false;
137
+ app.use(
138
+ "/food",
139
+ modelRouter(FoodModel as any, {
140
+ allowAnonymous: true,
141
+ permissions: {
142
+ create: [Permissions.IsAny],
143
+ delete: [Permissions.IsAny],
144
+ list: [Permissions.IsAny],
145
+ read: [Permissions.IsAny],
146
+ update: [Permissions.IsAny],
147
+ },
148
+ postCreate: async (data: any) => {
149
+ data.calories = 14;
150
+ await data.save();
151
+ return data;
152
+ },
153
+ postDelete: (data: any) => {
154
+ deleteCalled = true;
155
+ return data;
156
+ },
157
+ postUpdate: async (data: any) => {
158
+ data.calories = 15;
159
+ await data.save();
160
+ return data;
161
+ },
162
+ })
163
+ );
164
+ server = supertest(app);
165
+
166
+ let res = await server
167
+ .post("/food")
168
+ .send({
169
+ calories: 15,
170
+ name: "Broccoli",
171
+ })
172
+ .expect(201);
173
+ let broccoli = await FoodModel.findById(res.body.data._id);
174
+ if (!broccoli) {
175
+ throw new Error("Broccoli was not created");
176
+ }
177
+ expect(broccoli.name).toBe("Broccoli");
178
+ // Overwritten by the pre create hook
179
+ expect(broccoli.calories).toBe(14);
180
+
181
+ res = await server
182
+ .patch(`/food/${broccoli._id}`)
183
+ .send({
184
+ name: "Broccoli2",
185
+ })
186
+ .expect(200);
187
+ broccoli = await FoodModel.findById(res.body.data._id);
188
+ if (!broccoli) {
189
+ throw new Error("Broccoli was not update");
190
+ }
191
+ expect(broccoli.name).toBe("Broccoli2");
192
+ // Updated by the post update hook
193
+ expect(broccoli.calories).toBe(15);
194
+
195
+ await agent.delete(`/food/${broccoli._id}`).expect(204);
196
+ expect(deleteCalled).toBe(true);
197
+ });
198
+
199
+ it("preCreate hook preserves disableExternalErrorTracking on APIError", async () => {
200
+ app.use(
201
+ "/food",
202
+ modelRouter(FoodModel, {
203
+ allowAnonymous: true,
204
+ permissions: {
205
+ create: [Permissions.IsAny],
206
+ delete: [Permissions.IsAny],
207
+ list: [Permissions.IsAny],
208
+ read: [Permissions.IsAny],
209
+ update: [Permissions.IsAny],
210
+ },
211
+ preCreate: () => {
212
+ throw new APIError({
213
+ disableExternalErrorTracking: true,
214
+ status: 400,
215
+ title: "Custom preCreate error",
216
+ });
217
+ },
218
+ })
219
+ );
220
+ server = supertest(app);
221
+
222
+ const res = await server
223
+ .post("/food")
224
+ .send({
225
+ calories: 15,
226
+ name: "Broccoli",
227
+ })
228
+ .expect(400);
229
+
230
+ expect(res.body.title).toBe("Custom preCreate error");
231
+ expect(res.body.disableExternalErrorTracking).toBe(true);
232
+ });
233
+
234
+ it("preCreate hook preserves disableExternalErrorTracking on non-APIError", async () => {
235
+ app.use(
236
+ "/food",
237
+ modelRouter(FoodModel, {
238
+ allowAnonymous: true,
239
+ permissions: {
240
+ create: [Permissions.IsAny],
241
+ delete: [Permissions.IsAny],
242
+ list: [Permissions.IsAny],
243
+ read: [Permissions.IsAny],
244
+ update: [Permissions.IsAny],
245
+ },
246
+ preCreate: () => {
247
+ const error: any = new Error("Some custom error");
248
+ error.disableExternalErrorTracking = true;
249
+ throw error;
250
+ },
251
+ })
252
+ );
253
+ server = supertest(app);
254
+
255
+ const res = await server
256
+ .post("/food")
257
+ .send({
258
+ calories: 15,
259
+ name: "Broccoli",
260
+ })
261
+ .expect(400);
262
+
263
+ expect(res.body.title).toContain("preCreate hook error");
264
+ expect(res.body.disableExternalErrorTracking).toBe(true);
265
+ });
266
+
267
+ it("preUpdate hook preserves disableExternalErrorTracking on APIError", async () => {
268
+ const notAdmin = await UserModel.findOne({
269
+ email: "notAdmin@example.com",
270
+ });
271
+ const spinach = await FoodModel.create({
272
+ calories: 1,
273
+ created: new Date("2021-12-03T00:00:20.000Z"),
274
+ hidden: false,
275
+ name: "Spinach",
276
+ ownerId: (notAdmin as any)._id,
277
+ source: {
278
+ name: "Brand",
279
+ },
280
+ });
281
+
282
+ app.use(
283
+ "/food",
284
+ modelRouter(FoodModel, {
285
+ allowAnonymous: true,
286
+ permissions: {
287
+ create: [Permissions.IsAny],
288
+ delete: [Permissions.IsAny],
289
+ list: [Permissions.IsAny],
290
+ read: [Permissions.IsAny],
291
+ update: [Permissions.IsAny],
292
+ },
293
+ preUpdate: () => {
294
+ throw new APIError({
295
+ disableExternalErrorTracking: true,
296
+ status: 400,
297
+ title: "Custom preUpdate error",
298
+ });
299
+ },
300
+ })
301
+ );
302
+ server = supertest(app);
303
+
304
+ const res = await server
305
+ .patch(`/food/${spinach._id}`)
306
+ .send({
307
+ name: "Broccoli",
308
+ })
309
+ .expect(400);
310
+
311
+ expect(res.body.title).toBe("Custom preUpdate error");
312
+ expect(res.body.disableExternalErrorTracking).toBe(true);
313
+ });
314
+
315
+ it("preUpdate hook preserves disableExternalErrorTracking on non-APIError", async () => {
316
+ const notAdmin = await UserModel.findOne({
317
+ email: "notAdmin@example.com",
318
+ });
319
+ const spinach = await FoodModel.create({
320
+ calories: 1,
321
+ created: new Date("2021-12-03T00:00:20.000Z"),
322
+ hidden: false,
323
+ name: "Spinach",
324
+ ownerId: (notAdmin as any)._id,
325
+ source: {
326
+ name: "Brand",
327
+ },
328
+ });
329
+
330
+ app.use(
331
+ "/food",
332
+ modelRouter(FoodModel, {
333
+ allowAnonymous: true,
334
+ permissions: {
335
+ create: [Permissions.IsAny],
336
+ delete: [Permissions.IsAny],
337
+ list: [Permissions.IsAny],
338
+ read: [Permissions.IsAny],
339
+ update: [Permissions.IsAny],
340
+ },
341
+ preUpdate: () => {
342
+ const error: any = new Error("Some custom error");
343
+ error.disableExternalErrorTracking = true;
344
+ throw error;
345
+ },
346
+ })
347
+ );
348
+ server = supertest(app);
349
+
350
+ const res = await server
351
+ .patch(`/food/${spinach._id}`)
352
+ .send({
353
+ name: "Broccoli",
354
+ })
355
+ .expect(400);
356
+
357
+ expect(res.body.title).toContain("preUpdate hook error");
358
+ expect(res.body.disableExternalErrorTracking).toBe(true);
359
+ });
360
+
361
+ it("preDelete hook preserves disableExternalErrorTracking on non-APIError", async () => {
362
+ const notAdmin = await UserModel.findOne({
363
+ email: "notAdmin@example.com",
364
+ });
365
+ const spinach = await FoodModel.create({
366
+ calories: 1,
367
+ created: new Date("2021-12-03T00:00:20.000Z"),
368
+ hidden: false,
369
+ name: "Spinach",
370
+ ownerId: (notAdmin as any)._id,
371
+ source: {
372
+ name: "Brand",
373
+ },
374
+ });
375
+
376
+ app.use(
377
+ "/food",
378
+ modelRouter(FoodModel, {
379
+ allowAnonymous: true,
380
+ permissions: {
381
+ create: [Permissions.IsAny],
382
+ delete: [Permissions.IsAny],
383
+ list: [Permissions.IsAny],
384
+ read: [Permissions.IsAny],
385
+ update: [Permissions.IsAny],
386
+ },
387
+ preDelete: () => {
388
+ const error: any = new Error("Some custom error");
389
+ error.disableExternalErrorTracking = true;
390
+ throw error;
391
+ },
392
+ })
393
+ );
394
+ server = supertest(app);
395
+
396
+ const res = await agent.delete(`/food/${spinach._id}`).expect(403);
397
+
398
+ expect(res.body.title).toContain("preDelete hook error");
399
+ expect(res.body.disableExternalErrorTracking).toBe(true);
400
+ });
401
+ });
402
+
403
+ describe("hook error handling", () => {
404
+ let server: TestAgent;
405
+ let app: express.Application;
406
+ let admin: any;
407
+ let agent: TestAgent;
408
+ let spinach: Food;
409
+
410
+ beforeEach(async () => {
411
+ [admin] = await setupDb();
412
+
413
+ spinach = await FoodModel.create({
414
+ calories: 1,
415
+ created: new Date("2021-12-03T00:00:20.000Z"),
416
+ hidden: false,
417
+ name: "Spinach",
418
+ ownerId: admin._id,
419
+ source: {
420
+ name: "Brand",
421
+ },
422
+ });
423
+
424
+ app = getBaseServer();
425
+ setupAuth(app, UserModel as any);
426
+ addAuthRoutes(app, UserModel as any);
427
+ });
428
+
429
+ it("preCreate returning undefined throws error", async () => {
430
+ app.use(
431
+ "/food",
432
+ modelRouter(FoodModel, {
433
+ allowAnonymous: true,
434
+ permissions: {
435
+ create: [Permissions.IsAny],
436
+ delete: [Permissions.IsAny],
437
+ list: [Permissions.IsAny],
438
+ read: [Permissions.IsAny],
439
+ update: [Permissions.IsAny],
440
+ },
441
+ preCreate: () => undefined as any,
442
+ })
443
+ );
444
+ server = supertest(app);
445
+
446
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
447
+ expect(res.body.title).toBe("Create not allowed");
448
+ expect(res.body.detail).toBe("A body must be returned from preCreate");
449
+ });
450
+
451
+ it("preUpdate returning undefined throws error", async () => {
452
+ app.use(
453
+ "/food",
454
+ modelRouter(FoodModel, {
455
+ allowAnonymous: true,
456
+ permissions: {
457
+ create: [Permissions.IsAny],
458
+ delete: [Permissions.IsAny],
459
+ list: [Permissions.IsAny],
460
+ read: [Permissions.IsAny],
461
+ update: [Permissions.IsAny],
462
+ },
463
+ preUpdate: () => undefined as any,
464
+ })
465
+ );
466
+ server = supertest(app);
467
+
468
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
469
+ expect(res.body.title).toBe("Update not allowed");
470
+ expect(res.body.detail).toBe("A body must be returned from preUpdate");
471
+ });
472
+
473
+ it("preDelete returning undefined throws error", async () => {
474
+ app.use(
475
+ "/food",
476
+ modelRouter(FoodModel, {
477
+ allowAnonymous: true,
478
+ permissions: {
479
+ create: [Permissions.IsAny],
480
+ delete: [Permissions.IsAny],
481
+ list: [Permissions.IsAny],
482
+ read: [Permissions.IsAny],
483
+ update: [Permissions.IsAny],
484
+ },
485
+ preDelete: () => undefined as any,
486
+ })
487
+ );
488
+ server = supertest(app);
489
+ agent = await authAsUser(app, "notAdmin");
490
+
491
+ const res = await agent.delete(`/food/${spinach._id}`).expect(403);
492
+ expect(res.body.title).toBe("Delete not allowed");
493
+ expect(res.body.detail).toBe("A body must be returned from preDelete");
494
+ });
495
+
496
+ it("postCreate hook error is handled", async () => {
497
+ app.use(
498
+ "/food",
499
+ modelRouter(FoodModel, {
500
+ allowAnonymous: true,
501
+ permissions: {
502
+ create: [Permissions.IsAny],
503
+ delete: [Permissions.IsAny],
504
+ list: [Permissions.IsAny],
505
+ read: [Permissions.IsAny],
506
+ update: [Permissions.IsAny],
507
+ },
508
+ postCreate: () => {
509
+ throw new Error("postCreate failed");
510
+ },
511
+ })
512
+ );
513
+ server = supertest(app);
514
+
515
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
516
+ expect(res.body.title).toContain("postCreate hook error");
517
+ });
518
+
519
+ it("postUpdate hook error is handled", async () => {
520
+ app.use(
521
+ "/food",
522
+ modelRouter(FoodModel, {
523
+ allowAnonymous: true,
524
+ permissions: {
525
+ create: [Permissions.IsAny],
526
+ delete: [Permissions.IsAny],
527
+ list: [Permissions.IsAny],
528
+ read: [Permissions.IsAny],
529
+ update: [Permissions.IsAny],
530
+ },
531
+ postUpdate: () => {
532
+ throw new Error("postUpdate failed");
533
+ },
534
+ })
535
+ );
536
+ server = supertest(app);
537
+
538
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
539
+ expect(res.body.title).toContain("postUpdate hook error");
540
+ });
541
+
542
+ it("postDelete hook error is handled", async () => {
543
+ app.use(
544
+ "/food",
545
+ modelRouter(FoodModel, {
546
+ allowAnonymous: true,
547
+ permissions: {
548
+ create: [Permissions.IsAny],
549
+ delete: [Permissions.IsAny],
550
+ list: [Permissions.IsAny],
551
+ read: [Permissions.IsAny],
552
+ update: [Permissions.IsAny],
553
+ },
554
+ postDelete: () => {
555
+ throw new Error("postDelete failed");
556
+ },
557
+ })
558
+ );
559
+ server = supertest(app);
560
+ agent = await authAsUser(app, "notAdmin");
561
+
562
+ const res = await agent.delete(`/food/${spinach._id}`).expect(400);
563
+ expect(res.body.title).toContain("postDelete hook error");
564
+ });
565
+
566
+ it("preUpdate returning null throws error", async () => {
567
+ app.use(
568
+ "/food",
569
+ modelRouter(FoodModel, {
570
+ allowAnonymous: true,
571
+ permissions: {
572
+ create: [Permissions.IsAny],
573
+ delete: [Permissions.IsAny],
574
+ list: [Permissions.IsAny],
575
+ read: [Permissions.IsAny],
576
+ update: [Permissions.IsAny],
577
+ },
578
+ preUpdate: () => null,
579
+ })
580
+ );
581
+ server = supertest(app);
582
+
583
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
584
+ expect(res.body.title).toBe("Update not allowed");
585
+ });
586
+
587
+ it("preDelete returning null throws error", async () => {
588
+ app.use(
589
+ "/food",
590
+ modelRouter(FoodModel, {
591
+ allowAnonymous: true,
592
+ permissions: {
593
+ create: [Permissions.IsAny],
594
+ delete: [Permissions.IsAny],
595
+ list: [Permissions.IsAny],
596
+ read: [Permissions.IsAny],
597
+ update: [Permissions.IsAny],
598
+ },
599
+ preDelete: () => null,
600
+ })
601
+ );
602
+ server = supertest(app);
603
+ agent = await authAsUser(app, "notAdmin");
604
+
605
+ const res = await agent.delete(`/food/${spinach._id}`).expect(403);
606
+ expect(res.body.title).toBe("Delete not allowed");
607
+ });
608
+
609
+ it("preCreate returning null throws error", async () => {
610
+ app.use(
611
+ "/food",
612
+ modelRouter(FoodModel, {
613
+ allowAnonymous: true,
614
+ permissions: {
615
+ create: [Permissions.IsAny],
616
+ delete: [Permissions.IsAny],
617
+ list: [Permissions.IsAny],
618
+ read: [Permissions.IsAny],
619
+ update: [Permissions.IsAny],
620
+ },
621
+ preCreate: () => null,
622
+ })
623
+ );
624
+ server = supertest(app);
625
+
626
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
627
+ expect(res.body.title).toBe("Create not allowed");
628
+ });
629
+
630
+ it("preCreate error is handled", async () => {
631
+ app.use(
632
+ "/food",
633
+ modelRouter(FoodModel, {
634
+ allowAnonymous: true,
635
+ permissions: {
636
+ create: [Permissions.IsAny],
637
+ delete: [Permissions.IsAny],
638
+ list: [Permissions.IsAny],
639
+ read: [Permissions.IsAny],
640
+ update: [Permissions.IsAny],
641
+ },
642
+ preCreate: () => {
643
+ throw new Error("preCreate failed");
644
+ },
645
+ })
646
+ );
647
+ server = supertest(app);
648
+
649
+ const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
650
+ expect(res.body.title).toContain("preCreate hook error");
651
+ });
652
+
653
+ it("preUpdate error is handled", async () => {
654
+ app.use(
655
+ "/food",
656
+ modelRouter(FoodModel, {
657
+ allowAnonymous: true,
658
+ permissions: {
659
+ create: [Permissions.IsAny],
660
+ delete: [Permissions.IsAny],
661
+ list: [Permissions.IsAny],
662
+ read: [Permissions.IsAny],
663
+ update: [Permissions.IsAny],
664
+ },
665
+ preUpdate: () => {
666
+ throw new Error("preUpdate failed");
667
+ },
668
+ })
669
+ );
670
+ server = supertest(app);
671
+
672
+ const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
673
+ expect(res.body.title).toContain("preUpdate hook error");
674
+ });
675
+
676
+ it("preDelete hook throwing APIError is re-thrown", async () => {
677
+ app.use(
678
+ "/food",
679
+ modelRouter(FoodModel, {
680
+ allowAnonymous: true,
681
+ permissions: {
682
+ create: [Permissions.IsAny],
683
+ delete: [Permissions.IsAny],
684
+ list: [Permissions.IsAny],
685
+ read: [Permissions.IsAny],
686
+ update: [Permissions.IsAny],
687
+ },
688
+ preDelete: () => {
689
+ throw new APIError({
690
+ disableExternalErrorTracking: true,
691
+ status: 400,
692
+ title: "Custom preDelete APIError",
693
+ });
694
+ },
695
+ })
696
+ );
697
+ server = supertest(app);
698
+ agent = await authAsUser(app, "notAdmin");
699
+
700
+ const res = await agent.delete(`/food/${spinach._id}`).expect(400);
701
+ expect(res.body.title).toBe("Custom preDelete APIError");
702
+ expect(res.body.disableExternalErrorTracking).toBe(true);
703
+ });
704
+ });