@terreno/api 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/__tests__/versionCheckPlugin.test.js +83 -0
- package/dist/actions.d.ts +55 -0
- package/dist/actions.js +472 -0
- package/dist/actions.openApi.test.d.ts +1 -0
- package/dist/actions.openApi.test.js +252 -0
- package/dist/actions.test.d.ts +1 -0
- package/dist/actions.test.js +946 -0
- package/dist/api.d.ts +5 -0
- package/dist/api.js +4 -1
- package/dist/consentApp.js +118 -102
- package/dist/docLoader.d.ts +7 -0
- package/dist/docLoader.js +154 -0
- package/dist/docLoader.test.d.ts +1 -0
- package/dist/docLoader.test.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/permissions.d.ts +2 -2
- package/dist/permissions.js +11 -107
- package/dist/zodOpenApi.d.ts +2 -0
- package/dist/zodOpenApi.js +7 -0
- package/package.json +6 -3
- package/src/__tests__/versionCheckPlugin.test.ts +36 -0
- package/src/actions.openApi.test.ts +176 -0
- package/src/actions.test.ts +636 -0
- package/src/actions.ts +441 -0
- package/src/api.ts +14 -1
- package/src/consentApp.ts +80 -81
- package/src/docLoader.test.ts +58 -0
- package/src/docLoader.ts +77 -0
- package/src/index.ts +2 -0
- package/src/permissions.ts +4 -62
- package/src/zodOpenApi.ts +6 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
|
|
2
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
3
|
+
import type express from "express";
|
|
4
|
+
import {type Model, model, Schema} from "mongoose";
|
|
5
|
+
import supertest from "supertest";
|
|
6
|
+
import type TestAgent from "supertest/lib/agent";
|
|
7
|
+
import {z} from "zod";
|
|
8
|
+
import {ACTION_NAME_PATTERN, defineCollectionAction, defineInstanceAction} from "./actions";
|
|
9
|
+
import {modelRouter} from "./api";
|
|
10
|
+
import {addAuthRoutes, setupAuth} from "./auth";
|
|
11
|
+
import {apiUnauthorizedMiddleware} from "./errors";
|
|
12
|
+
import {Permissions} from "./permissions";
|
|
13
|
+
import {type IsDeleted, isDeletedPlugin} from "./plugins";
|
|
14
|
+
import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
|
|
15
|
+
|
|
16
|
+
interface Stuff extends IsDeleted {
|
|
17
|
+
_id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
ownerId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stuffSchema = new Schema<Stuff>({
|
|
23
|
+
name: {type: String},
|
|
24
|
+
ownerId: {type: String},
|
|
25
|
+
});
|
|
26
|
+
stuffSchema.plugin(isDeletedPlugin);
|
|
27
|
+
const StuffModel = model<Stuff>("ActionStuff", stuffSchema);
|
|
28
|
+
|
|
29
|
+
const allPermissions = {
|
|
30
|
+
create: [Permissions.IsAny],
|
|
31
|
+
delete: [Permissions.IsAny],
|
|
32
|
+
list: [Permissions.IsAny],
|
|
33
|
+
read: [Permissions.IsAny],
|
|
34
|
+
update: [Permissions.IsAny],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("modelRouter actions", () => {
|
|
38
|
+
describe("registration validation", () => {
|
|
39
|
+
it("throws when permissions are missing", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
modelRouter(FoodModel, {
|
|
42
|
+
collectionActions: {
|
|
43
|
+
broken: {
|
|
44
|
+
handler: async () => ({ok: true}),
|
|
45
|
+
method: "POST",
|
|
46
|
+
} as any,
|
|
47
|
+
},
|
|
48
|
+
permissions: allPermissions,
|
|
49
|
+
})
|
|
50
|
+
).toThrow(/missing required "permissions"/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects single-character action names by design", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
modelRouter(FoodModel, {
|
|
56
|
+
collectionActions: {
|
|
57
|
+
a: {
|
|
58
|
+
handler: async () => ({}),
|
|
59
|
+
method: "GET",
|
|
60
|
+
permissions: [Permissions.IsAny],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
permissions: allPermissions,
|
|
64
|
+
})
|
|
65
|
+
).toThrow(ACTION_NAME_PATTERN.toString());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws on invalid action name", () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
modelRouter(FoodModel, {
|
|
71
|
+
collectionActions: {
|
|
72
|
+
"foo*bar": {
|
|
73
|
+
handler: async () => ({}),
|
|
74
|
+
method: "GET",
|
|
75
|
+
permissions: [Permissions.IsAny],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
permissions: allPermissions,
|
|
79
|
+
})
|
|
80
|
+
).toThrow(ACTION_NAME_PATTERN.toString());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws on empty action name", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
modelRouter(FoodModel, {
|
|
86
|
+
collectionActions: {
|
|
87
|
+
"": {
|
|
88
|
+
handler: async () => ({}),
|
|
89
|
+
method: "GET",
|
|
90
|
+
permissions: [Permissions.IsAny],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
permissions: allPermissions,
|
|
94
|
+
})
|
|
95
|
+
).toThrow("Action name cannot be empty");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("throws when instance action collides with array field", () => {
|
|
99
|
+
expect(() =>
|
|
100
|
+
modelRouter(FoodModel, {
|
|
101
|
+
instanceActions: {
|
|
102
|
+
tags: {
|
|
103
|
+
handler: async () => ({}),
|
|
104
|
+
method: "GET",
|
|
105
|
+
permissions: [Permissions.IsAny],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
permissions: allPermissions,
|
|
109
|
+
})
|
|
110
|
+
).toThrow(/collides with array field/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("allows same action name on instance and collection scopes", () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
modelRouter(FoodModel, {
|
|
116
|
+
collectionActions: {
|
|
117
|
+
sync: {
|
|
118
|
+
handler: async () => ({scope: "collection"}),
|
|
119
|
+
method: "POST",
|
|
120
|
+
permissions: [Permissions.IsAny],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
instanceActions: {
|
|
124
|
+
sync: {
|
|
125
|
+
handler: async () => ({scope: "instance"}),
|
|
126
|
+
method: "POST",
|
|
127
|
+
permissions: [Permissions.IsAny],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
permissions: allPermissions,
|
|
131
|
+
})
|
|
132
|
+
).not.toThrow();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("integration", () => {
|
|
137
|
+
let app: express.Application;
|
|
138
|
+
let server: TestAgent;
|
|
139
|
+
let admin: any;
|
|
140
|
+
let notAdmin: any;
|
|
141
|
+
let spinach: Food;
|
|
142
|
+
|
|
143
|
+
const mountFoodRouter = (options: Parameters<typeof modelRouter<Food>>[1]): void => {
|
|
144
|
+
app.use(
|
|
145
|
+
"/food",
|
|
146
|
+
modelRouter(FoodModel, {
|
|
147
|
+
allowAnonymous: true,
|
|
148
|
+
...options,
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
if (!app.get("terrenoUnauthorizedMiddleware")) {
|
|
152
|
+
app.use(apiUnauthorizedMiddleware);
|
|
153
|
+
app.set("terrenoUnauthorizedMiddleware", true);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
|
|
159
|
+
[admin, notAdmin] = await setupDb();
|
|
160
|
+
[spinach] = await Promise.all([
|
|
161
|
+
FoodModel.create({
|
|
162
|
+
calories: 1,
|
|
163
|
+
created: new Date(),
|
|
164
|
+
hidden: false,
|
|
165
|
+
name: "Spinach",
|
|
166
|
+
ownerId: notAdmin._id,
|
|
167
|
+
source: {name: "test"},
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
app = getBaseServer();
|
|
171
|
+
setupAuth(app, UserModel as any);
|
|
172
|
+
addAuthRoutes(app, UserModel as any);
|
|
173
|
+
server = supertest(app);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("routing and permissions", () => {
|
|
177
|
+
it("allows empty permissions array and returns 405 at runtime", async () => {
|
|
178
|
+
mountFoodRouter({
|
|
179
|
+
collectionActions: {
|
|
180
|
+
disabled: {
|
|
181
|
+
handler: async () => ({ok: true}),
|
|
182
|
+
method: "POST",
|
|
183
|
+
permissions: [],
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
permissions: allPermissions,
|
|
187
|
+
});
|
|
188
|
+
const agent = await authAsUser(app, "admin");
|
|
189
|
+
const res = await agent.post("/food/disabled").send({}).expect(405);
|
|
190
|
+
expect(res.body.title).toContain("Access to CREATE on Food denied");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("runs instance POST action with ctx.doc and req.obj", async () => {
|
|
194
|
+
let seenDoc: Food | undefined;
|
|
195
|
+
let seenObj: Food | undefined;
|
|
196
|
+
mountFoodRouter({
|
|
197
|
+
instanceActions: {
|
|
198
|
+
mark: {
|
|
199
|
+
handler: async ({doc, req}) => {
|
|
200
|
+
seenDoc = doc;
|
|
201
|
+
seenObj = (req as express.Request & {obj?: Food}).obj;
|
|
202
|
+
return {marked: true};
|
|
203
|
+
},
|
|
204
|
+
method: "POST",
|
|
205
|
+
permissions: [Permissions.IsAny],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
permissions: allPermissions,
|
|
209
|
+
});
|
|
210
|
+
const res = await server.post(`/food/${spinach._id}/mark`).send({}).expect(200);
|
|
211
|
+
expect(res.body.data).toEqual({marked: true});
|
|
212
|
+
expect(seenDoc?._id.toString()).toBe(spinach._id.toString());
|
|
213
|
+
expect(seenObj?._id.toString()).toBe(spinach._id.toString());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("runs collection POST action without doc", async () => {
|
|
217
|
+
mountFoodRouter({
|
|
218
|
+
collectionActions: {
|
|
219
|
+
bulk: {
|
|
220
|
+
handler: async (ctx) => {
|
|
221
|
+
expect((ctx as {doc?: unknown}).doc).toBeUndefined();
|
|
222
|
+
return {count: 1};
|
|
223
|
+
},
|
|
224
|
+
method: "POST",
|
|
225
|
+
permissions: [Permissions.IsAny],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
permissions: allPermissions,
|
|
229
|
+
});
|
|
230
|
+
const res = await server.post("/food/bulk").send({}).expect(200);
|
|
231
|
+
expect(res.body.data).toEqual({count: 1});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("runs GET instance and collection actions", async () => {
|
|
235
|
+
mountFoodRouter({
|
|
236
|
+
collectionActions: {
|
|
237
|
+
stats: {
|
|
238
|
+
handler: async () => ({total: 1}),
|
|
239
|
+
method: "GET",
|
|
240
|
+
permissions: [Permissions.IsAny],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
instanceActions: {
|
|
244
|
+
peek: {
|
|
245
|
+
handler: async ({doc}) => ({name: doc?.name}),
|
|
246
|
+
method: "GET",
|
|
247
|
+
permissions: [Permissions.IsAny],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
permissions: allPermissions,
|
|
251
|
+
});
|
|
252
|
+
const collectionRes = await server.get("/food/stats").expect(200);
|
|
253
|
+
expect(collectionRes.body.data).toEqual({total: 1});
|
|
254
|
+
const instanceRes = await server.get(`/food/${spinach._id}/peek`).expect(200);
|
|
255
|
+
expect(instanceRes.body.data).toEqual({name: "Spinach"});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("returns 404 for missing instance doc", async () => {
|
|
259
|
+
mountFoodRouter({
|
|
260
|
+
instanceActions: {
|
|
261
|
+
peek: {
|
|
262
|
+
handler: async () => ({}),
|
|
263
|
+
method: "GET",
|
|
264
|
+
permissions: [Permissions.IsAny],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
permissions: allPermissions,
|
|
268
|
+
});
|
|
269
|
+
const missingId = "507f1f77bcf86cd799439011";
|
|
270
|
+
const res = await server.get(`/food/${missingId}/peek`).expect(404);
|
|
271
|
+
expect(res.body.title).toContain(missingId);
|
|
272
|
+
expect(res.body.meta).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns 404 with soft-delete metadata on instance action", async () => {
|
|
276
|
+
await StuffModel.deleteMany({});
|
|
277
|
+
const doc = await StuffModel.create({deleted: true, name: "hidden", ownerId: "1"});
|
|
278
|
+
app = getBaseServer();
|
|
279
|
+
setupAuth(app, UserModel as any);
|
|
280
|
+
addAuthRoutes(app, UserModel as any);
|
|
281
|
+
app.use(
|
|
282
|
+
"/stuff",
|
|
283
|
+
modelRouter(StuffModel as Model<Stuff>, {
|
|
284
|
+
allowAnonymous: true,
|
|
285
|
+
instanceActions: {
|
|
286
|
+
peek: {
|
|
287
|
+
handler: async () => ({}),
|
|
288
|
+
method: "GET",
|
|
289
|
+
permissions: [Permissions.IsAny],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
permissions: allPermissions,
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
296
|
+
const res = await agent.get(`/stuff/${doc._id}/peek`).expect(404);
|
|
297
|
+
expect(res.body.meta).toEqual({deleted: "true"});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns 401 when unauthenticated and IsAuthenticated required", async () => {
|
|
301
|
+
mountFoodRouter({
|
|
302
|
+
allowAnonymous: false,
|
|
303
|
+
collectionActions: {
|
|
304
|
+
secure: {
|
|
305
|
+
handler: async () => ({ok: true}),
|
|
306
|
+
method: "POST",
|
|
307
|
+
permissions: [Permissions.IsAuthenticated],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
permissions: allPermissions,
|
|
311
|
+
});
|
|
312
|
+
const res = await server.post("/food/secure").send({}).expect(401);
|
|
313
|
+
expect(res.body.title).toBe("Unauthorized");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("returns 405 for collection action when pre-doc permission denied", async () => {
|
|
317
|
+
mountFoodRouter({
|
|
318
|
+
collectionActions: {
|
|
319
|
+
adminOnly: {
|
|
320
|
+
handler: async () => ({ok: true}),
|
|
321
|
+
method: "POST",
|
|
322
|
+
permissions: [Permissions.IsAdmin],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
permissions: allPermissions,
|
|
326
|
+
});
|
|
327
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
328
|
+
const res = await agent.post("/food/adminOnly").send({}).expect(405);
|
|
329
|
+
expect(res.body.title).toContain("Access to CREATE on Food denied");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("returns 403 for instance action when post-doc permission denied", async () => {
|
|
333
|
+
const adminFood = await FoodModel.create({
|
|
334
|
+
calories: 2,
|
|
335
|
+
created: new Date(),
|
|
336
|
+
hidden: false,
|
|
337
|
+
name: "AdminApple",
|
|
338
|
+
ownerId: admin._id,
|
|
339
|
+
source: {name: "test"},
|
|
340
|
+
});
|
|
341
|
+
mountFoodRouter({
|
|
342
|
+
instanceActions: {
|
|
343
|
+
ownerOnly: {
|
|
344
|
+
handler: async () => ({ok: true}),
|
|
345
|
+
method: "POST",
|
|
346
|
+
permissions: [Permissions.IsOwner],
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
permissions: allPermissions,
|
|
350
|
+
});
|
|
351
|
+
const agent = await authAsUser(app, "notAdmin");
|
|
352
|
+
const res = await agent.post(`/food/${adminFood._id}/ownerOnly`).send({}).expect(403);
|
|
353
|
+
expect(res.body.title).toContain(`Access to UPDATE on Food:${adminFood._id} denied`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("allows IsAuthenticatedOrReadOnly on GET with allowAnonymous", async () => {
|
|
357
|
+
mountFoodRouter({
|
|
358
|
+
allowAnonymous: true,
|
|
359
|
+
instanceActions: {
|
|
360
|
+
publicRead: {
|
|
361
|
+
handler: async () => ({ok: true}),
|
|
362
|
+
method: "GET",
|
|
363
|
+
permissions: [Permissions.IsAuthenticatedOrReadOnly],
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
permissions: allPermissions,
|
|
367
|
+
});
|
|
368
|
+
const res = await server.get(`/food/${spinach._id}/publicRead`).expect(200);
|
|
369
|
+
expect(res.body.data).toEqual({ok: true});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("validation", () => {
|
|
374
|
+
it("passes valid body through ctx", async () => {
|
|
375
|
+
let seenEmail: string | undefined;
|
|
376
|
+
mountFoodRouter({
|
|
377
|
+
collectionActions: {
|
|
378
|
+
notify: {
|
|
379
|
+
body: z.object({email: z.string().email()}),
|
|
380
|
+
handler: async ({body}) => {
|
|
381
|
+
seenEmail = (body as {email: string}).email;
|
|
382
|
+
return {sent: true};
|
|
383
|
+
},
|
|
384
|
+
method: "POST",
|
|
385
|
+
permissions: [Permissions.IsAny],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
permissions: allPermissions,
|
|
389
|
+
});
|
|
390
|
+
await server.post("/food/notify").send({email: "a@b.com"}).expect(200);
|
|
391
|
+
expect(seenEmail).toBe("a@b.com");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("returns 400 with meta.fields for invalid body", async () => {
|
|
395
|
+
mountFoodRouter({
|
|
396
|
+
collectionActions: {
|
|
397
|
+
notify: {
|
|
398
|
+
body: z.object({email: z.string().email()}),
|
|
399
|
+
handler: async () => ({sent: true}),
|
|
400
|
+
method: "POST",
|
|
401
|
+
permissions: [Permissions.IsAny],
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
permissions: allPermissions,
|
|
405
|
+
});
|
|
406
|
+
const res = await server.post("/food/notify").send({email: "not-an-email"}).expect(400);
|
|
407
|
+
expect(res.body.title).toBe("Validation failed");
|
|
408
|
+
expect(res.body.meta.fields.email).toBeDefined();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("validates query schema into ctx without mutating req.query", async () => {
|
|
412
|
+
let seenQ: number | undefined;
|
|
413
|
+
let originalQ: unknown;
|
|
414
|
+
mountFoodRouter({
|
|
415
|
+
collectionActions: {
|
|
416
|
+
search: {
|
|
417
|
+
handler: async ({query, req}) => {
|
|
418
|
+
seenQ = (query as {count: number}).count;
|
|
419
|
+
originalQ = req.query.count;
|
|
420
|
+
},
|
|
421
|
+
method: "GET",
|
|
422
|
+
permissions: [Permissions.IsAny],
|
|
423
|
+
query: z.object({count: z.coerce.number()}),
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
permissions: allPermissions,
|
|
427
|
+
});
|
|
428
|
+
await server.get("/food/search?count=5").expect(200);
|
|
429
|
+
expect(seenQ).toBe(5);
|
|
430
|
+
expect(originalQ).toBe("5");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("coerces body values via zod in ctx", async () => {
|
|
434
|
+
let seenCount: number | undefined;
|
|
435
|
+
mountFoodRouter({
|
|
436
|
+
collectionActions: {
|
|
437
|
+
tally: {
|
|
438
|
+
body: z.object({count: z.coerce.number()}),
|
|
439
|
+
handler: async ({body}) => {
|
|
440
|
+
const parsed = body as {count: number};
|
|
441
|
+
seenCount = parsed.count;
|
|
442
|
+
return {count: parsed.count};
|
|
443
|
+
},
|
|
444
|
+
method: "POST",
|
|
445
|
+
permissions: [Permissions.IsAny],
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
permissions: allPermissions,
|
|
449
|
+
});
|
|
450
|
+
const res = await server.post("/food/tally").send({count: "5"}).expect(200);
|
|
451
|
+
expect(seenCount).toBe(5);
|
|
452
|
+
expect(res.body.data).toEqual({count: 5});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("strips unknown body fields by default", async () => {
|
|
456
|
+
let seenBody: Record<string, unknown> = {};
|
|
457
|
+
mountFoodRouter({
|
|
458
|
+
collectionActions: {
|
|
459
|
+
strictish: {
|
|
460
|
+
body: z.object({known: z.string()}),
|
|
461
|
+
handler: async ({body}) => {
|
|
462
|
+
seenBody = body as Record<string, unknown>;
|
|
463
|
+
return {ok: true};
|
|
464
|
+
},
|
|
465
|
+
method: "POST",
|
|
466
|
+
permissions: [Permissions.IsAny],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
permissions: allPermissions,
|
|
470
|
+
});
|
|
471
|
+
await server.post("/food/strictish").send({extra: "x", known: "y"}).expect(200);
|
|
472
|
+
expect(seenBody).toEqual({known: "y"});
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("response shape", () => {
|
|
477
|
+
it("wraps handler return in data envelope", async () => {
|
|
478
|
+
mountFoodRouter({
|
|
479
|
+
collectionActions: {
|
|
480
|
+
echo: {
|
|
481
|
+
handler: async () => ({x: 1}),
|
|
482
|
+
method: "POST",
|
|
483
|
+
permissions: [Permissions.IsAny],
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
permissions: allPermissions,
|
|
487
|
+
});
|
|
488
|
+
const res = await server.post("/food/echo").send({}).expect(200);
|
|
489
|
+
expect(res.body).toEqual({data: {x: 1}});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("respects custom status code", async () => {
|
|
493
|
+
mountFoodRouter({
|
|
494
|
+
collectionActions: {
|
|
495
|
+
queue: {
|
|
496
|
+
handler: async () => ({queued: true}),
|
|
497
|
+
method: "POST",
|
|
498
|
+
permissions: [Permissions.IsAny],
|
|
499
|
+
status: 202,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
permissions: allPermissions,
|
|
503
|
+
});
|
|
504
|
+
const res = await server.post("/food/queue").send({}).expect(202);
|
|
505
|
+
expect(res.body).toEqual({data: {queued: true}});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("returns data null for undefined handler return", async () => {
|
|
509
|
+
mountFoodRouter({
|
|
510
|
+
collectionActions: {
|
|
511
|
+
noop: {
|
|
512
|
+
handler: async () => undefined,
|
|
513
|
+
method: "POST",
|
|
514
|
+
permissions: [Permissions.IsAny],
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
permissions: allPermissions,
|
|
518
|
+
});
|
|
519
|
+
const res = await server.post("/food/noop").send({}).expect(200);
|
|
520
|
+
expect(res.body).toEqual({data: null});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("skips auto-wrap when res.headersSent", async () => {
|
|
524
|
+
mountFoodRouter({
|
|
525
|
+
collectionActions: {
|
|
526
|
+
custom: {
|
|
527
|
+
handler: async ({res}) => {
|
|
528
|
+
res.json({custom: 1});
|
|
529
|
+
},
|
|
530
|
+
method: "POST",
|
|
531
|
+
permissions: [Permissions.IsAny],
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
permissions: allPermissions,
|
|
535
|
+
});
|
|
536
|
+
const res = await server.post("/food/custom").send({}).expect(200);
|
|
537
|
+
expect(res.body).toEqual({custom: 1});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("allows custom list-style envelope via res.json", async () => {
|
|
541
|
+
mountFoodRouter({
|
|
542
|
+
collectionActions: {
|
|
543
|
+
paged: {
|
|
544
|
+
handler: async ({res}) => {
|
|
545
|
+
res.json({data: [{id: 1}], more: false, page: 1, total: 1});
|
|
546
|
+
},
|
|
547
|
+
method: "GET",
|
|
548
|
+
permissions: [Permissions.IsAny],
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
permissions: allPermissions,
|
|
552
|
+
});
|
|
553
|
+
const res = await server.get("/food/paged").expect(200);
|
|
554
|
+
expect(res.body).toEqual({data: [{id: 1}], more: false, page: 1, total: 1});
|
|
555
|
+
expect(res.body.data).not.toHaveProperty("data");
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe("co-registration precedence", () => {
|
|
560
|
+
it("instance action wins over endpoints route on same path", async () => {
|
|
561
|
+
mountFoodRouter({
|
|
562
|
+
endpoints: (router) => {
|
|
563
|
+
router.get("/:id/foo", (_req, res) => {
|
|
564
|
+
res.json({data: {from: "endpoints"}});
|
|
565
|
+
});
|
|
566
|
+
},
|
|
567
|
+
instanceActions: {
|
|
568
|
+
foo: {
|
|
569
|
+
handler: async () => ({from: "action"}),
|
|
570
|
+
method: "GET",
|
|
571
|
+
permissions: [Permissions.IsAny],
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
permissions: allPermissions,
|
|
575
|
+
});
|
|
576
|
+
const res = await server.get(`/food/${spinach._id}/foo`).expect(200);
|
|
577
|
+
expect(res.body.data).toEqual({from: "action"});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("collection action wins over endpoints route on same path", async () => {
|
|
581
|
+
mountFoodRouter({
|
|
582
|
+
collectionActions: {
|
|
583
|
+
report: {
|
|
584
|
+
handler: async () => ({from: "action"}),
|
|
585
|
+
method: "GET",
|
|
586
|
+
permissions: [Permissions.IsAny],
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
endpoints: (router) => {
|
|
590
|
+
router.get("/report", (_req, res) => {
|
|
591
|
+
res.json({data: {from: "endpoints"}});
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
permissions: allPermissions,
|
|
595
|
+
});
|
|
596
|
+
const res = await server.get("/food/report").expect(200);
|
|
597
|
+
expect(res.body.data).toEqual({from: "action"});
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe("defineInstanceAction type ergonomics", () => {
|
|
603
|
+
it("preserves handler types at compile time", () => {
|
|
604
|
+
interface ScheduleDoc {
|
|
605
|
+
_id: string;
|
|
606
|
+
publishedAt?: Date;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const action = defineInstanceAction<ScheduleDoc, {notifyUsers: boolean}>({
|
|
610
|
+
body: z.object({notifyUsers: z.boolean()}),
|
|
611
|
+
handler: async ({body, doc}) => {
|
|
612
|
+
const _doc: ScheduleDoc = doc;
|
|
613
|
+
const _notify: boolean = body.notifyUsers;
|
|
614
|
+
return {notify: _notify, publishedAt: _doc.publishedAt?.toISOString() ?? null};
|
|
615
|
+
},
|
|
616
|
+
method: "POST",
|
|
617
|
+
permissions: [Permissions.IsAny],
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(action.method).toBe("POST");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("defineCollectionAction preserves body types", () => {
|
|
624
|
+
const action = defineCollectionAction({
|
|
625
|
+
body: z.object({ids: z.array(z.string())}),
|
|
626
|
+
handler: async ({body}) => {
|
|
627
|
+
const _ids: string[] = body.ids;
|
|
628
|
+
return {count: _ids.length};
|
|
629
|
+
},
|
|
630
|
+
method: "POST",
|
|
631
|
+
permissions: [Permissions.IsAny],
|
|
632
|
+
});
|
|
633
|
+
expect(action.method).toBe("POST");
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|