@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.
- package/.claude/CLAUDE.local.md +204 -0
- package/.cursor/rules/00-root.mdc +338 -0
- package/.github/copilot-instructions.md +333 -0
- package/AGENTS.md +333 -0
- package/README.md +76 -7
- package/biome.jsonc +1 -1
- package/dist/api.d.ts +68 -1
- package/dist/api.js +140 -5
- package/dist/api.query.test.js +1 -1
- package/dist/api.test.js +222 -484
- package/dist/auth.js +3 -1
- package/dist/errors.js +15 -12
- package/dist/example.js +7 -7
- package/dist/expressServer.d.ts +8 -2
- package/dist/expressServer.js +8 -1
- package/dist/githubAuth.d.ts +64 -0
- package/dist/githubAuth.js +293 -0
- package/dist/githubAuth.test.d.ts +1 -0
- package/dist/githubAuth.test.js +351 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/logger.js +1 -1
- package/dist/middleware.js +1 -1
- package/dist/notifiers/googleChatNotifier.js +1 -1
- package/dist/notifiers/googleChatNotifier.test.js +1 -1
- package/dist/notifiers/slackNotifier.js +1 -1
- package/dist/notifiers/slackNotifier.test.js +1 -1
- package/dist/notifiers/zoomNotifier.js +1 -1
- package/dist/notifiers/zoomNotifier.test.js +1 -1
- package/dist/openApi.test.js +8 -5
- package/dist/openApiBuilder.d.ts +69 -1
- package/dist/openApiBuilder.js +109 -5
- package/dist/openApiValidator.d.ts +296 -0
- package/dist/openApiValidator.js +698 -0
- package/dist/openApiValidator.test.d.ts +1 -0
- package/dist/openApiValidator.test.js +346 -0
- package/dist/permissions.js +1 -1
- package/dist/plugins.test.js +3 -3
- package/dist/terrenoPlugin.d.ts +4 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests/bunSetup.js +2 -2
- package/dist/tests.js +34 -24
- package/package.json +7 -2
- package/src/__snapshots__/openApi.test.ts.snap +399 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
- package/src/api.query.test.ts +1 -1
- package/src/api.test.ts +161 -374
- package/src/api.ts +210 -4
- package/src/auth.ts +3 -1
- package/src/errors.ts +15 -12
- package/src/example.ts +7 -7
- package/src/expressServer.ts +18 -2
- package/src/githubAuth.test.ts +223 -0
- package/src/githubAuth.ts +335 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +1 -1
- package/src/middleware.ts +1 -1
- package/src/notifiers/googleChatNotifier.test.ts +1 -1
- package/src/notifiers/googleChatNotifier.ts +1 -1
- package/src/notifiers/slackNotifier.test.ts +1 -1
- package/src/notifiers/slackNotifier.ts +1 -1
- package/src/notifiers/zoomNotifier.test.ts +1 -1
- package/src/notifiers/zoomNotifier.ts +1 -1
- package/src/openApi.test.ts +8 -5
- package/src/openApiBuilder.ts +188 -15
- package/src/openApiValidator.test.ts +241 -0
- package/src/openApiValidator.ts +860 -0
- package/src/permissions.ts +1 -1
- package/src/plugins.test.ts +3 -3
- package/src/terrenoPlugin.ts +5 -0
- package/src/tests/bunSetup.ts +2 -2
- package/src/tests.ts +34 -24
- package/CLAUDE.md +0 -107
- package/dist/response.d.ts +0 -0
- package/dist/response.js +0 -1
- package/index.ts +0 -1
- 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: {
|
|
832
|
-
|
|
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("
|
|
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("
|
|
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
|
-
"/
|
|
1206
|
-
modelRouter(
|
|
1097
|
+
"/food",
|
|
1098
|
+
modelRouter(FoodModel, {
|
|
1207
1099
|
allowAnonymous: true,
|
|
1208
1100
|
permissions: {
|
|
1209
|
-
create: [Permissions.
|
|
1210
|
-
delete: [Permissions.
|
|
1211
|
-
list: [Permissions.
|
|
1212
|
-
read: [Permissions.
|
|
1213
|
-
update: [Permissions.
|
|
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, "
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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("
|
|
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.
|
|
1257
|
-
delete: [Permissions.
|
|
1258
|
-
list: [Permissions.
|
|
1259
|
-
read: [Permissions.
|
|
1260
|
-
update: [Permissions.
|
|
1124
|
+
create: [Permissions.IsAdmin],
|
|
1125
|
+
delete: [Permissions.IsAdmin],
|
|
1126
|
+
list: [Permissions.IsAdmin],
|
|
1127
|
+
read: [Permissions.IsAdmin],
|
|
1128
|
+
update: [Permissions.IsAdmin],
|
|
1261
1129
|
},
|
|
1262
|
-
|
|
1130
|
+
preUpdate: () => null,
|
|
1263
1131
|
})
|
|
1264
1132
|
);
|
|
1265
1133
|
server = supertest(app);
|
|
1134
|
+
agent = await authAsUser(app, "admin");
|
|
1266
1135
|
|
|
1267
|
-
const res = await
|
|
1268
|
-
.
|
|
1269
|
-
.send({
|
|
1270
|
-
.expect(
|
|
1271
|
-
expect(res.body.
|
|
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("
|
|
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.
|
|
1308
|
-
delete: [Permissions.
|
|
1309
|
-
list: [Permissions.
|
|
1310
|
-
read: [Permissions.
|
|
1311
|
-
update: [Permissions.
|
|
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
|
-
|
|
1318
|
-
|
|
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("
|
|
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.
|
|
1343
|
-
delete: [Permissions.
|
|
1344
|
-
list: [Permissions.
|
|
1345
|
-
read: [Permissions.
|
|
1346
|
-
update: [Permissions.
|
|
1173
|
+
create: [Permissions.IsAdmin],
|
|
1174
|
+
delete: [Permissions.IsAdmin],
|
|
1175
|
+
list: [Permissions.IsAdmin],
|
|
1176
|
+
read: [Permissions.IsAdmin],
|
|
1177
|
+
update: [Permissions.IsAdmin],
|
|
1347
1178
|
},
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
|
1356
|
-
expect(res.body.title).
|
|
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("
|
|
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
|
-
"/
|
|
1400
|
-
modelRouter(
|
|
1193
|
+
"/food",
|
|
1194
|
+
modelRouter(FoodModel, {
|
|
1401
1195
|
allowAnonymous: true,
|
|
1402
1196
|
permissions: {
|
|
1403
|
-
create: [Permissions.
|
|
1404
|
-
delete: [Permissions.
|
|
1197
|
+
create: [Permissions.IsAdmin],
|
|
1198
|
+
delete: [Permissions.IsAdmin],
|
|
1405
1199
|
list: [Permissions.IsAny],
|
|
1406
1200
|
read: [Permissions.IsAny],
|
|
1407
|
-
update: [Permissions.
|
|
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
|
-
|
|
1415
|
-
|
|
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
|
|
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
|
|
1472
|
-
|
|
1473
|
-
expect(res.body.
|
|
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
|
|
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.
|
|
1483
|
-
delete: [Permissions.
|
|
1484
|
-
list: [Permissions.
|
|
1485
|
-
read: [Permissions.
|
|
1486
|
-
update: [Permissions.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
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
|
|
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
|