@terreno/api 0.0.17 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/CLAUDE.local.md +204 -0
  2. package/.cursor/rules/00-root.mdc +338 -0
  3. package/.github/copilot-instructions.md +333 -0
  4. package/AGENTS.md +333 -0
  5. package/README.md +76 -7
  6. package/biome.jsonc +1 -1
  7. package/dist/api.d.ts +68 -1
  8. package/dist/api.js +140 -5
  9. package/dist/api.query.test.js +1 -1
  10. package/dist/api.test.js +222 -484
  11. package/dist/auth.js +3 -1
  12. package/dist/errors.js +15 -12
  13. package/dist/example.js +7 -7
  14. package/dist/expressServer.d.ts +8 -2
  15. package/dist/expressServer.js +8 -1
  16. package/dist/githubAuth.d.ts +64 -0
  17. package/dist/githubAuth.js +293 -0
  18. package/dist/githubAuth.test.d.ts +1 -0
  19. package/dist/githubAuth.test.js +351 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/logger.js +1 -1
  23. package/dist/middleware.js +1 -1
  24. package/dist/notifiers/googleChatNotifier.js +1 -1
  25. package/dist/notifiers/googleChatNotifier.test.js +1 -1
  26. package/dist/notifiers/slackNotifier.js +1 -1
  27. package/dist/notifiers/slackNotifier.test.js +1 -1
  28. package/dist/notifiers/zoomNotifier.js +1 -1
  29. package/dist/notifiers/zoomNotifier.test.js +1 -1
  30. package/dist/openApi.test.js +8 -5
  31. package/dist/openApiBuilder.d.ts +69 -1
  32. package/dist/openApiBuilder.js +109 -5
  33. package/dist/openApiValidator.d.ts +296 -0
  34. package/dist/openApiValidator.js +698 -0
  35. package/dist/openApiValidator.test.d.ts +1 -0
  36. package/dist/openApiValidator.test.js +346 -0
  37. package/dist/permissions.js +1 -1
  38. package/dist/plugins.test.js +3 -3
  39. package/dist/terrenoPlugin.d.ts +4 -0
  40. package/dist/terrenoPlugin.js +2 -0
  41. package/dist/tests/bunSetup.js +2 -2
  42. package/dist/tests.js +34 -24
  43. package/package.json +7 -2
  44. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  45. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  46. package/src/api.query.test.ts +1 -1
  47. package/src/api.test.ts +161 -374
  48. package/src/api.ts +210 -4
  49. package/src/auth.ts +3 -1
  50. package/src/errors.ts +15 -12
  51. package/src/example.ts +7 -7
  52. package/src/expressServer.ts +18 -2
  53. package/src/githubAuth.test.ts +223 -0
  54. package/src/githubAuth.ts +335 -0
  55. package/src/index.ts +3 -0
  56. package/src/logger.ts +1 -1
  57. package/src/middleware.ts +1 -1
  58. package/src/notifiers/googleChatNotifier.test.ts +1 -1
  59. package/src/notifiers/googleChatNotifier.ts +1 -1
  60. package/src/notifiers/slackNotifier.test.ts +1 -1
  61. package/src/notifiers/slackNotifier.ts +1 -1
  62. package/src/notifiers/zoomNotifier.test.ts +1 -1
  63. package/src/notifiers/zoomNotifier.ts +1 -1
  64. package/src/openApi.test.ts +8 -5
  65. package/src/openApiBuilder.ts +188 -15
  66. package/src/openApiValidator.test.ts +241 -0
  67. package/src/openApiValidator.ts +860 -0
  68. package/src/permissions.ts +1 -1
  69. package/src/plugins.test.ts +3 -3
  70. package/src/terrenoPlugin.ts +5 -0
  71. package/src/tests/bunSetup.ts +2 -2
  72. package/src/tests.ts +34 -24
  73. package/CLAUDE.md +0 -107
  74. package/dist/response.d.ts +0 -0
  75. package/dist/response.js +0 -1
  76. package/index.ts +0 -1
  77. package/src/response.ts +0 -0
package/src/api.test.ts CHANGED
@@ -536,6 +536,7 @@ describe("@terreno/api", () => {
536
536
  describe("transformer errors", () => {
537
537
  let admin: any;
538
538
  let spinach: Food;
539
+ let agent: TestAgent;
539
540
 
540
541
  beforeEach(async () => {
541
542
  [admin] = await setupDb();
@@ -621,6 +622,35 @@ describe("@terreno/api", () => {
621
622
  const res = await server.post("/required").send({about: "test"}).expect(400);
622
623
  expect(res.body.title).toContain("Required");
623
624
  });
625
+
626
+ it("preDelete hook throwing APIError is re-thrown", async () => {
627
+ app.use(
628
+ "/food",
629
+ modelRouter(FoodModel, {
630
+ allowAnonymous: true,
631
+ permissions: {
632
+ create: [Permissions.IsAny],
633
+ delete: [Permissions.IsAny],
634
+ list: [Permissions.IsAny],
635
+ read: [Permissions.IsAny],
636
+ update: [Permissions.IsAny],
637
+ },
638
+ preDelete: () => {
639
+ throw new APIError({
640
+ disableExternalErrorTracking: true,
641
+ status: 400,
642
+ title: "Custom preDelete APIError",
643
+ });
644
+ },
645
+ })
646
+ );
647
+ server = supertest(app);
648
+ agent = await authAsUser(app, "notAdmin");
649
+
650
+ const res = await agent.delete(`/food/${spinach._id}`).expect(400);
651
+ expect(res.body.title).toBe("Custom preDelete APIError");
652
+ expect(res.body.disableExternalErrorTracking).toBe(true);
653
+ });
624
654
  });
625
655
 
626
656
  describe("addPopulateToQuery", () => {
@@ -828,8 +858,12 @@ describe("@terreno/api", () => {
828
858
  const mongoose = await import("mongoose");
829
859
 
830
860
  const softDeleteSchema = new mongoose.Schema({
831
- deleted: {default: false, type: Boolean},
832
- name: String,
861
+ deleted: {
862
+ default: false,
863
+ description: "Whether this item has been soft deleted",
864
+ type: Boolean,
865
+ },
866
+ name: {description: "The name of the item", type: String},
833
867
  });
834
868
 
835
869
  let SoftDeleteModel;
@@ -902,132 +936,6 @@ describe("@terreno/api", () => {
902
936
  });
903
937
  });
904
938
 
905
- describe("transformer errors", () => {
906
- let admin: any;
907
- let spinach: Food;
908
- let agent: TestAgent;
909
-
910
- beforeEach(async () => {
911
- [admin] = await setupDb();
912
-
913
- spinach = await FoodModel.create({
914
- calories: 1,
915
- created: new Date("2021-12-03T00:00:20.000Z"),
916
- hidden: false,
917
- name: "Spinach",
918
- ownerId: admin._id,
919
- source: {
920
- name: "Brand",
921
- },
922
- });
923
-
924
- app = getBaseServer();
925
- setupAuth(app, UserModel as any);
926
- addAuthRoutes(app, UserModel as any);
927
- });
928
-
929
- it("transform error in create is handled", async () => {
930
- app.use(
931
- "/food",
932
- modelRouter(FoodModel, {
933
- allowAnonymous: true,
934
- permissions: {
935
- create: [Permissions.IsAny],
936
- delete: [Permissions.IsAny],
937
- list: [Permissions.IsAny],
938
- read: [Permissions.IsAny],
939
- update: [Permissions.IsAny],
940
- },
941
- transformer: AdminOwnerTransformer({
942
- // Only allow 'name' to be written, so 'calories' will throw
943
- anonWriteFields: ["name"],
944
- }),
945
- })
946
- );
947
- server = supertest(app);
948
-
949
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
950
- expect(res.body.title).toContain("cannot write fields");
951
- });
952
-
953
- it("transform error in patch is handled", async () => {
954
- app.use(
955
- "/food",
956
- modelRouter(FoodModel, {
957
- allowAnonymous: true,
958
- permissions: {
959
- create: [Permissions.IsAny],
960
- delete: [Permissions.IsAny],
961
- list: [Permissions.IsAny],
962
- read: [Permissions.IsAny],
963
- update: [Permissions.IsAny],
964
- },
965
- transformer: AdminOwnerTransformer({
966
- // Only allow 'name' to be written, so 'calories' will throw
967
- anonWriteFields: ["name"],
968
- }),
969
- })
970
- );
971
- server = supertest(app);
972
-
973
- const res = await server.patch(`/food/${spinach._id}`).send({calories: 100}).expect(403);
974
- expect(res.body.title).toContain("cannot write fields");
975
- });
976
-
977
- it("model.create validation error is handled", async () => {
978
- // Use a model that has required fields
979
- const {RequiredModel} = await import("./tests");
980
-
981
- app.use(
982
- "/required",
983
- modelRouter(RequiredModel, {
984
- allowAnonymous: true,
985
- permissions: {
986
- create: [Permissions.IsAny],
987
- delete: [Permissions.IsAny],
988
- list: [Permissions.IsAny],
989
- read: [Permissions.IsAny],
990
- update: [Permissions.IsAny],
991
- },
992
- })
993
- );
994
- server = supertest(app);
995
-
996
- // Send without required 'name' field
997
- const res = await server.post("/required").send({about: "test"}).expect(400);
998
- expect(res.body.title).toContain("Required");
999
- });
1000
-
1001
- it("preDelete hook throwing APIError is re-thrown", async () => {
1002
- app.use(
1003
- "/food",
1004
- modelRouter(FoodModel, {
1005
- allowAnonymous: true,
1006
- permissions: {
1007
- create: [Permissions.IsAny],
1008
- delete: [Permissions.IsAny],
1009
- list: [Permissions.IsAny],
1010
- read: [Permissions.IsAny],
1011
- update: [Permissions.IsAny],
1012
- },
1013
- preDelete: () => {
1014
- throw new APIError({
1015
- disableExternalErrorTracking: true,
1016
- status: 400,
1017
- title: "Custom preDelete APIError",
1018
- });
1019
- },
1020
- })
1021
- );
1022
- server = supertest(app);
1023
- agent = await authAsUser(app, "notAdmin");
1024
-
1025
- const res = await agent.delete(`/food/${spinach._id}`).expect(400);
1026
- expect(res.body.title).toBe("Custom preDelete APIError");
1027
- expect(res.body.disableExternalErrorTracking).toBe(true);
1028
- });
1029
- });
1030
-
1031
939
  describe("special query params", () => {
1032
940
  let admin: any;
1033
941
 
@@ -1158,299 +1066,150 @@ describe("@terreno/api", () => {
1158
1066
  });
1159
1067
  });
1160
1068
 
1161
- describe("addPopulateToQuery", () => {
1162
- it("returns query unchanged with no populate paths", async () => {
1163
- await setupDb();
1164
- const query = FoodModel.find({});
1165
- const result = addPopulateToQuery(query, undefined);
1166
- expect(result).toBe(query);
1167
- });
1168
-
1169
- it("returns query unchanged with empty populate paths", async () => {
1170
- await setupDb();
1171
- const query = FoodModel.find({});
1172
- const result = addPopulateToQuery(query, []);
1173
- expect(result).toBe(query);
1174
- });
1175
-
1176
- it("applies multiple populate paths", async () => {
1177
- await setupDb();
1178
- const query = FoodModel.find({});
1179
- const result = addPopulateToQuery(query, [
1180
- {fields: ["email"], path: "ownerId"},
1181
- {fields: ["name"], path: "eatenBy"},
1182
- ]);
1183
- // The result should be a query with populate applied
1184
- expect(result).toBeDefined();
1185
- });
1186
- });
1187
-
1188
- describe("soft delete with isDeleted plugin", () => {
1069
+ describe("array operation with undefined preUpdate return", () => {
1189
1070
  let admin: any;
1071
+ let apple: Food;
1190
1072
  let agent: TestAgent;
1191
1073
 
1192
1074
  beforeEach(async () => {
1193
1075
  [admin] = await setupDb();
1194
1076
 
1077
+ apple = await FoodModel.create({
1078
+ calories: 100,
1079
+ categories: [
1080
+ {name: "Fruit", show: true},
1081
+ {name: "Popular", show: false},
1082
+ ],
1083
+ created: new Date("2021-12-03T00:00:30.000Z"),
1084
+ hidden: false,
1085
+ name: "Apple",
1086
+ ownerId: admin._id,
1087
+ tags: ["healthy", "cheap"],
1088
+ });
1089
+
1195
1090
  app = getBaseServer();
1196
1091
  setupAuth(app, UserModel as any);
1197
1092
  addAuthRoutes(app, UserModel as any);
1198
1093
  });
1199
1094
 
1200
- it("soft deletes user with deleted field", async () => {
1201
- // UserModel has the isDisabledPlugin which adds a 'disabled' field,
1202
- // but we need to test the 'deleted' field check.
1203
- // Let's use a model that has the deleted field.
1095
+ it("array operation preUpdate returning undefined for array POST throws error", async () => {
1204
1096
  app.use(
1205
- "/users",
1206
- modelRouter(UserModel, {
1097
+ "/food",
1098
+ modelRouter(FoodModel, {
1207
1099
  allowAnonymous: true,
1208
1100
  permissions: {
1209
- create: [Permissions.IsAny],
1210
- delete: [Permissions.IsAny],
1211
- list: [Permissions.IsAny],
1212
- read: [Permissions.IsAny],
1213
- update: [Permissions.IsAny],
1101
+ create: [Permissions.IsAdmin],
1102
+ delete: [Permissions.IsAdmin],
1103
+ list: [Permissions.IsAdmin],
1104
+ read: [Permissions.IsAdmin],
1105
+ update: [Permissions.IsAdmin],
1214
1106
  },
1107
+ preUpdate: () => undefined as any,
1215
1108
  })
1216
1109
  );
1217
1110
  server = supertest(app);
1218
- agent = await authAsUser(app, "notAdmin");
1219
-
1220
- // Delete a user - this should use deleteOne since User doesn't have deleted field
1221
- const res = await agent.delete(`/users/${admin._id}`).expect(204);
1222
- expect(res.body).toEqual({});
1223
-
1224
- // Verify user was deleted
1225
- const deletedUser = await UserModel.findById(admin._id);
1226
- expect(deletedUser).toBeNull();
1227
- });
1228
- });
1229
-
1230
- describe("populate in create", () => {
1231
- let admin: any;
1232
-
1233
- beforeEach(async () => {
1234
- [admin] = await setupDb();
1235
-
1236
- await FoodModel.create({
1237
- calories: 1,
1238
- created: new Date("2021-12-03T00:00:20.000Z"),
1239
- hidden: false,
1240
- name: "Spinach",
1241
- ownerId: admin._id,
1242
- });
1111
+ agent = await authAsUser(app, "admin");
1243
1112
 
1244
- app = getBaseServer();
1245
- setupAuth(app, UserModel as any);
1246
- addAuthRoutes(app, UserModel as any);
1113
+ const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
1114
+ expect(res.body.title).toBe("Update not allowed");
1115
+ expect(res.body.detail).toBe("A body must be returned from preUpdate");
1247
1116
  });
1248
1117
 
1249
- it("handles populate with valid path in create", async () => {
1250
- // Test that valid populate works in create flow
1118
+ it("array operation preUpdate returning null for array PATCH throws error", async () => {
1251
1119
  app.use(
1252
1120
  "/food",
1253
1121
  modelRouter(FoodModel, {
1254
1122
  allowAnonymous: true,
1255
1123
  permissions: {
1256
- create: [Permissions.IsAny],
1257
- delete: [Permissions.IsAny],
1258
- list: [Permissions.IsAny],
1259
- read: [Permissions.IsAny],
1260
- update: [Permissions.IsAny],
1124
+ create: [Permissions.IsAdmin],
1125
+ delete: [Permissions.IsAdmin],
1126
+ list: [Permissions.IsAdmin],
1127
+ read: [Permissions.IsAdmin],
1128
+ update: [Permissions.IsAdmin],
1261
1129
  },
1262
- populatePaths: [{fields: ["email"], path: "ownerId"}],
1130
+ preUpdate: () => null,
1263
1131
  })
1264
1132
  );
1265
1133
  server = supertest(app);
1134
+ agent = await authAsUser(app, "admin");
1266
1135
 
1267
- const res = await server
1268
- .post("/food")
1269
- .send({calories: 15, name: "Broccoli", ownerId: admin._id})
1270
- .expect(201);
1271
- expect(res.body.data.name).toBe("Broccoli");
1272
- // Verify populate worked - ownerId should be an object with email
1273
- expect(res.body.data.ownerId.email).toBe(admin.email);
1274
- });
1275
- });
1276
-
1277
- describe("save error handling", () => {
1278
- let admin: any;
1279
- let spinach: Food;
1280
-
1281
- beforeEach(async () => {
1282
- [admin] = await setupDb();
1283
-
1284
- spinach = await FoodModel.create({
1285
- calories: 1,
1286
- created: new Date("2021-12-03T00:00:20.000Z"),
1287
- hidden: false,
1288
- name: "Spinach",
1289
- ownerId: admin._id,
1290
- source: {
1291
- name: "Brand",
1292
- },
1293
- });
1294
-
1295
- app = getBaseServer();
1296
- setupAuth(app, UserModel as any);
1297
- addAuthRoutes(app, UserModel as any);
1136
+ const res = await agent
1137
+ .patch(`/food/${apple._id}/tags/healthy`)
1138
+ .send({tags: "unhealthy"})
1139
+ .expect(403);
1140
+ expect(res.body.title).toBe("Update not allowed");
1298
1141
  });
1299
1142
 
1300
- it("handles patch save error with validation failure", async () => {
1301
- // The FoodModel has strict: "throw" which will cause validation errors for unknown fields
1143
+ it("array operation preUpdate error for array DELETE is handled", async () => {
1302
1144
  app.use(
1303
1145
  "/food",
1304
1146
  modelRouter(FoodModel, {
1305
1147
  allowAnonymous: true,
1306
1148
  permissions: {
1307
- create: [Permissions.IsAny],
1308
- delete: [Permissions.IsAny],
1309
- list: [Permissions.IsAny],
1310
- read: [Permissions.IsAny],
1311
- update: [Permissions.IsAny],
1149
+ create: [Permissions.IsAdmin],
1150
+ delete: [Permissions.IsAdmin],
1151
+ list: [Permissions.IsAdmin],
1152
+ read: [Permissions.IsAdmin],
1153
+ update: [Permissions.IsAdmin],
1154
+ },
1155
+ preUpdate: () => {
1156
+ throw new Error("preUpdate error during delete");
1312
1157
  },
1313
1158
  })
1314
1159
  );
1315
1160
  server = supertest(app);
1161
+ agent = await authAsUser(app, "admin");
1316
1162
 
1317
- // Try to patch with an invalid field (will be caught by strict: "throw")
1318
- const res = await server
1319
- .patch(`/food/${spinach._id}`)
1320
- .send({invalidField: "value"})
1321
- .expect(400);
1322
- expect(res.body.title).toContain("preUpdate hook save error");
1323
- });
1324
- });
1325
-
1326
- describe("body undefined after transform without preCreate", () => {
1327
- beforeEach(async () => {
1328
- await setupDb();
1329
-
1330
- app = getBaseServer();
1331
- setupAuth(app, UserModel as any);
1332
- addAuthRoutes(app, UserModel as any);
1163
+ const res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(400);
1164
+ expect(res.body.title).toContain("preUpdate hook error");
1333
1165
  });
1334
1166
 
1335
- it("handles undefined body after transform when no preCreate", async () => {
1336
- // Create a transformer that returns undefined
1167
+ it("array operation postUpdate error is handled", async () => {
1337
1168
  app.use(
1338
1169
  "/food",
1339
1170
  modelRouter(FoodModel, {
1340
1171
  allowAnonymous: true,
1341
1172
  permissions: {
1342
- create: [Permissions.IsAny],
1343
- delete: [Permissions.IsAny],
1344
- list: [Permissions.IsAny],
1345
- read: [Permissions.IsAny],
1346
- update: [Permissions.IsAny],
1173
+ create: [Permissions.IsAdmin],
1174
+ delete: [Permissions.IsAdmin],
1175
+ list: [Permissions.IsAdmin],
1176
+ read: [Permissions.IsAdmin],
1177
+ update: [Permissions.IsAdmin],
1347
1178
  },
1348
- transformer: {
1349
- transform: () => undefined,
1179
+ postUpdate: () => {
1180
+ throw new Error("postUpdate array failed");
1350
1181
  },
1351
1182
  })
1352
1183
  );
1353
1184
  server = supertest(app);
1185
+ agent = await authAsUser(app, "admin");
1354
1186
 
1355
- const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
1356
- expect(res.body.title).toBe("Invalid request body");
1357
- expect(res.body.detail).toBe("Body is undefined");
1358
- });
1359
- });
1360
-
1361
- describe("soft delete with deleted field", () => {
1362
- let agent: TestAgent;
1363
-
1364
- beforeEach(async () => {
1365
- await setupDb();
1366
-
1367
- app = getBaseServer();
1368
- setupAuth(app, UserModel as any);
1369
- addAuthRoutes(app, UserModel as any);
1187
+ const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(400);
1188
+ expect(res.body.title).toContain("PATCH Post Update error");
1370
1189
  });
1371
1190
 
1372
- it("soft deletes document with deleted field using isDeletedPlugin", async () => {
1373
- // Create a test schema with the isDeletedPlugin
1374
- const mongoose = await import("mongoose");
1375
-
1376
- // Create a temporary model with the deleted field
1377
- const softDeleteSchema = new mongoose.Schema({
1378
- deleted: {default: false, type: Boolean},
1379
- name: String,
1380
- });
1381
- // Manually add the deleted field (simulating what isDeletedPlugin does)
1382
- // The schema already has the deleted field, so it should use soft delete
1383
-
1384
- // Check if the model already exists to avoid OverwriteModelError
1385
- let SoftDeleteModel;
1386
- try {
1387
- SoftDeleteModel = mongoose.model("SoftDeleteTest");
1388
- } catch {
1389
- SoftDeleteModel = mongoose.model("SoftDeleteTest", softDeleteSchema);
1390
- }
1391
-
1392
- // Clean up any existing documents
1393
- await SoftDeleteModel.deleteMany({});
1394
-
1395
- // Create a test document
1396
- const testDoc = await SoftDeleteModel.create({name: "TestItem"});
1397
-
1191
+ it("array operation denied without update permission", async () => {
1398
1192
  app.use(
1399
- "/softdelete",
1400
- modelRouter(SoftDeleteModel, {
1193
+ "/food",
1194
+ modelRouter(FoodModel, {
1401
1195
  allowAnonymous: true,
1402
1196
  permissions: {
1403
- create: [Permissions.IsAny],
1404
- delete: [Permissions.IsAny],
1197
+ create: [Permissions.IsAdmin],
1198
+ delete: [Permissions.IsAdmin],
1405
1199
  list: [Permissions.IsAny],
1406
1200
  read: [Permissions.IsAny],
1407
- update: [Permissions.IsAny],
1201
+ update: [Permissions.IsAdmin],
1408
1202
  },
1409
1203
  })
1410
1204
  );
1411
1205
  server = supertest(app);
1412
1206
  agent = await authAsUser(app, "notAdmin");
1413
1207
 
1414
- // Delete should soft delete (set deleted: true) instead of hard delete
1415
- await agent.delete(`/softdelete/${testDoc._id}`).expect(204);
1416
-
1417
- // Verify document was soft deleted (not hard deleted)
1418
- const softDeleted = await SoftDeleteModel.findById(testDoc._id);
1419
- expect(softDeleted).not.toBeNull();
1420
- expect(softDeleted?.deleted).toBe(true);
1421
-
1422
- // Clean up
1423
- await SoftDeleteModel.deleteMany({});
1424
- });
1425
- });
1426
-
1427
- describe("array operation with undefined preUpdate return", () => {
1428
- let admin: any;
1429
- let apple: Food;
1430
- let agent: TestAgent;
1431
-
1432
- beforeEach(async () => {
1433
- [admin] = await setupDb();
1434
-
1435
- apple = await FoodModel.create({
1436
- calories: 100,
1437
- categories: [
1438
- {name: "Fruit", show: true},
1439
- {name: "Popular", show: false},
1440
- ],
1441
- created: new Date("2021-12-03T00:00:30.000Z"),
1442
- hidden: false,
1443
- name: "Apple",
1444
- ownerId: admin._id,
1445
- tags: ["healthy", "cheap"],
1446
- });
1447
-
1448
- app = getBaseServer();
1449
- setupAuth(app, UserModel as any);
1450
- addAuthRoutes(app, UserModel as any);
1208
+ const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(405);
1209
+ expect(res.body.title).toContain("Access to PATCH");
1451
1210
  });
1452
1211
 
1453
- it("array operation preUpdate returning undefined for array POST throws error", async () => {
1212
+ it("array operation on non-existent document returns 404", async () => {
1454
1213
  app.use(
1455
1214
  "/food",
1456
1215
  modelRouter(FoodModel, {
@@ -1462,43 +1221,40 @@ describe("@terreno/api", () => {
1462
1221
  read: [Permissions.IsAdmin],
1463
1222
  update: [Permissions.IsAdmin],
1464
1223
  },
1465
- preUpdate: () => undefined as any,
1466
1224
  })
1467
1225
  );
1468
1226
  server = supertest(app);
1469
1227
  agent = await authAsUser(app, "admin");
1470
1228
 
1471
- const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
1472
- expect(res.body.title).toBe("Update not allowed");
1473
- expect(res.body.detail).toBe("A body must be returned from preUpdate");
1229
+ const fakeId = "000000000000000000000000";
1230
+ const res = await agent.post(`/food/${fakeId}/tags`).send({tags: "organic"}).expect(404);
1231
+ expect(res.body.title).toContain("Could not find document to PATCH");
1474
1232
  });
1475
1233
 
1476
- it("array operation preUpdate returning null for array PATCH throws error", async () => {
1234
+ it("array operation denied when user cannot update specific doc", async () => {
1235
+ // Create food owned by admin, then try to update as notAdmin
1477
1236
  app.use(
1478
1237
  "/food",
1479
1238
  modelRouter(FoodModel, {
1480
1239
  allowAnonymous: true,
1481
1240
  permissions: {
1482
- create: [Permissions.IsAdmin],
1483
- delete: [Permissions.IsAdmin],
1484
- list: [Permissions.IsAdmin],
1485
- read: [Permissions.IsAdmin],
1486
- update: [Permissions.IsAdmin],
1241
+ create: [Permissions.IsAuthenticated],
1242
+ delete: [Permissions.IsAuthenticated],
1243
+ list: [Permissions.IsAuthenticated],
1244
+ read: [Permissions.IsAuthenticated],
1245
+ update: [Permissions.IsOwner],
1487
1246
  },
1488
- preUpdate: () => null,
1489
1247
  })
1490
1248
  );
1491
1249
  server = supertest(app);
1492
- agent = await authAsUser(app, "admin");
1250
+ // Login as notAdmin and try to update admin's food (apple)
1251
+ agent = await authAsUser(app, "notAdmin");
1493
1252
 
1494
- const res = await agent
1495
- .patch(`/food/${apple._id}/tags/healthy`)
1496
- .send({tags: "unhealthy"})
1497
- .expect(403);
1498
- expect(res.body.title).toBe("Update not allowed");
1253
+ const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
1254
+ expect(res.body.title).toContain("Patch not allowed");
1499
1255
  });
1500
1256
 
1501
- it("array operation preUpdate error for array DELETE is handled", async () => {
1257
+ it("array operation transform error is handled", async () => {
1502
1258
  app.use(
1503
1259
  "/food",
1504
1260
  modelRouter(FoodModel, {
@@ -1510,23 +1266,24 @@ describe("@terreno/api", () => {
1510
1266
  read: [Permissions.IsAdmin],
1511
1267
  update: [Permissions.IsAdmin],
1512
1268
  },
1513
- preUpdate: () => {
1514
- throw new Error("preUpdate error during delete");
1515
- },
1269
+ transformer: AdminOwnerTransformer({
1270
+ adminWriteFields: ["name"],
1271
+ }),
1516
1272
  })
1517
1273
  );
1518
1274
  server = supertest(app);
1519
1275
  agent = await authAsUser(app, "admin");
1520
1276
 
1521
- const res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(400);
1522
- expect(res.body.title).toContain("preUpdate hook error");
1277
+ // Try to update tags field, which is not in the allowed write fields
1278
+ const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
1279
+ expect(res.body.title).toContain("cannot write fields");
1523
1280
  });
1524
1281
  });
1525
1282
 
1526
1283
  describe("transformer errors", () => {
1527
1284
  let admin: any;
1528
1285
  let spinach: Food;
1529
- let _agent: TestAgent;
1286
+ let agent: TestAgent;
1530
1287
 
1531
1288
  beforeEach(async () => {
1532
1289
  [admin] = await setupDb();
@@ -1618,6 +1375,35 @@ describe("@terreno/api", () => {
1618
1375
  const res = await server.post("/required").send({about: "test"}).expect(400);
1619
1376
  expect(res.body.title).toContain("Required");
1620
1377
  });
1378
+
1379
+ it("preDelete hook throwing APIError is re-thrown", async () => {
1380
+ app.use(
1381
+ "/food",
1382
+ modelRouter(FoodModel, {
1383
+ allowAnonymous: true,
1384
+ permissions: {
1385
+ create: [Permissions.IsAny],
1386
+ delete: [Permissions.IsAny],
1387
+ list: [Permissions.IsAny],
1388
+ read: [Permissions.IsAny],
1389
+ update: [Permissions.IsAny],
1390
+ },
1391
+ preDelete: () => {
1392
+ throw new APIError({
1393
+ disableExternalErrorTracking: true,
1394
+ status: 400,
1395
+ title: "Custom preDelete APIError",
1396
+ });
1397
+ },
1398
+ })
1399
+ );
1400
+ server = supertest(app);
1401
+ agent = await authAsUser(app, "notAdmin");
1402
+
1403
+ const res = await agent.delete(`/food/${spinach._id}`).expect(400);
1404
+ expect(res.body.title).toBe("Custom preDelete APIError");
1405
+ expect(res.body.disableExternalErrorTracking).toBe(true);
1406
+ });
1621
1407
  });
1622
1408
 
1623
1409
  describe("special query params", () => {
@@ -1963,6 +1749,7 @@ describe("@terreno/api", () => {
1963
1749
  });
1964
1750
 
1965
1751
  it("soft deletes document with deleted field using isDeletedPlugin", async () => {
1752
+ // Create a test schema with the isDeletedPlugin
1966
1753
  const mongoose = await import("mongoose");
1967
1754
 
1968
1755
  // Create a temporary model with the deleted field