@btst/stack 5.0.0 → 5.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/dist/api/index.d.cts +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/client/index.d.cts +2 -2
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/context/index.d.cts +1 -1
- package/dist/context/index.d.mts +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/packages/stack/src/plugins/cms/api/getters.cjs +142 -6
- package/dist/packages/stack/src/plugins/cms/api/getters.mjs +139 -8
- package/dist/packages/stack/src/plugins/cms/api/operations.cjs +67 -61
- package/dist/packages/stack/src/plugins/cms/api/operations.mjs +69 -63
- package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +26 -8
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +27 -9
- package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +15 -1
- package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +14 -2
- package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +45 -24
- package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +45 -24
- package/dist/packages/stack/src/plugins/cms/schemas.cjs +22 -0
- package/dist/packages/stack/src/plugins/cms/schemas.mjs +20 -1
- package/dist/plugins/api/index.d.cts +2 -2
- package/dist/plugins/api/index.d.mts +2 -2
- package/dist/plugins/api/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +2 -2
- package/dist/plugins/blog/client/index.d.mts +2 -2
- package/dist/plugins/blog/client/index.d.ts +2 -2
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/client/index.d.cts +2 -2
- package/dist/plugins/client/index.d.mts +2 -2
- package/dist/plugins/client/index.d.ts +2 -2
- package/dist/plugins/cms/api/index.cjs +1 -0
- package/dist/plugins/cms/api/index.d.cts +43 -11
- package/dist/plugins/cms/api/index.d.mts +43 -11
- package/dist/plugins/cms/api/index.d.ts +43 -11
- package/dist/plugins/cms/api/index.mjs +1 -1
- package/dist/plugins/cms/client/hooks/index.d.cts +90 -45
- package/dist/plugins/cms/client/hooks/index.d.mts +90 -45
- package/dist/plugins/cms/client/hooks/index.d.ts +90 -45
- package/dist/plugins/cms/client/index.d.cts +2 -1
- package/dist/plugins/cms/client/index.d.mts +2 -1
- package/dist/plugins/cms/client/index.d.ts +2 -1
- package/dist/plugins/cms/query-keys.cjs +69 -13
- package/dist/plugins/cms/query-keys.d.cts +89 -212
- package/dist/plugins/cms/query-keys.d.mts +89 -212
- package/dist/plugins/cms/query-keys.d.ts +89 -212
- package/dist/plugins/cms/query-keys.mjs +70 -14
- package/dist/plugins/kanban/client/index.d.cts +1 -1
- package/dist/plugins/kanban/client/index.d.mts +1 -1
- package/dist/plugins/kanban/client/index.d.ts +1 -1
- package/dist/plugins/route-docs/client/index.d.cts +1 -1
- package/dist/plugins/route-docs/client/index.d.mts +1 -1
- package/dist/plugins/route-docs/client/index.d.ts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.cts +3 -3
- package/dist/plugins/ui-builder/client/hooks/index.d.mts +3 -3
- package/dist/plugins/ui-builder/client/hooks/index.d.ts +3 -3
- package/dist/plugins/ui-builder/client/index.d.cts +3 -3
- package/dist/plugins/ui-builder/client/index.d.mts +3 -3
- package/dist/plugins/ui-builder/client/index.d.ts +3 -3
- package/dist/plugins/ui-builder/query-keys.d.cts +4 -3
- package/dist/plugins/ui-builder/query-keys.d.mts +4 -3
- package/dist/plugins/ui-builder/query-keys.d.ts +4 -3
- package/dist/shared/{stack.DMj68qY0.d.mts → stack.B2jlVVhD.d.mts} +5 -5
- package/dist/shared/{stack.Cvnj99cs.d.ts → stack.BB9tubJJ.d.ts} +9 -3
- package/dist/shared/{stack.Bt3WG2yb.d.cts → stack.BWduIMKG.d.cts} +35 -35
- package/dist/shared/stack.Bw09Lg2z.d.ts +1420 -0
- package/dist/shared/{stack.CkEz7vSt.d.mts → stack.C2Pvl-RS.d.mts} +9 -3
- package/dist/shared/{stack.C2l2wd_X.d.ts → stack.Ch9-ppmQ.d.ts} +35 -35
- package/dist/shared/{stack.Ht8mhDj5.d.cts → stack.D17yFglU.d.cts} +5 -5
- package/dist/shared/{stack.CiD115Ov.d.mts → stack.D6fPBfYe.d.mts} +35 -35
- package/dist/shared/stack.DFGj0gb7.d.mts +1420 -0
- package/dist/shared/{stack.pi0FxoEa.d.ts → stack.Dcfg4GNN.d.ts} +5 -5
- package/dist/shared/{stack.3rxARhgS.d.cts → stack.DrB3L7Ih.d.cts} +52 -3
- package/dist/shared/{stack.3rxARhgS.d.mts → stack.DrB3L7Ih.d.mts} +52 -3
- package/dist/shared/{stack.3rxARhgS.d.ts → stack.DrB3L7Ih.d.ts} +52 -3
- package/dist/shared/stack.NNrBKBkM.d.cts +1420 -0
- package/dist/shared/{stack.mRRyZBUg.d.cts → stack.Rb87lHqK.d.cts} +9 -3
- package/package.json +1 -1
- package/src/__tests__/cms-query-keys.test.ts +57 -0
- package/src/plugins/api/index.ts +1 -0
- package/src/plugins/api/operation.ts +16 -3
- package/src/plugins/cms/__tests__/authorization.test.ts +652 -187
- package/src/plugins/cms/__tests__/client-runtime.browser.test.tsx +655 -0
- package/src/plugins/cms/__tests__/content-read.typecheck.ts +351 -0
- package/src/plugins/cms/__tests__/getters.test.ts +286 -0
- package/src/plugins/cms/api/getters.ts +270 -12
- package/src/plugins/cms/api/index.ts +10 -0
- package/src/plugins/cms/api/operations.ts +163 -75
- package/src/plugins/cms/api/plugin.ts +50 -12
- package/src/plugins/cms/api/query-key-defs.ts +29 -2
- package/src/plugins/cms/client/hooks/cms-hooks.tsx +400 -91
- package/src/plugins/cms/client/index.ts +9 -0
- package/src/plugins/cms/query-keys.ts +171 -16
- package/src/plugins/cms/schemas.ts +30 -0
- package/src/plugins/cms/types.ts +49 -2
- package/src/plugins/media/__tests__/identity-partition.test.tsx +26 -16
- package/dist/shared/stack.CAmVUwjV.d.cts +0 -764
- package/dist/shared/stack.CQK02ujt.d.ts +0 -764
- package/dist/shared/stack.D9x1fR5a.d.mts +0 -764
- package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
- package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
- package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
|
@@ -3,6 +3,10 @@ import { defineDb, type DatabaseDefinition } from "@btst/db";
|
|
|
3
3
|
import { zodToFormSchema } from "@workspace/ui/lib/schema-converter";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { createCMSQueryKeys, CMS_QUERY_KEYS } from "../api";
|
|
8
|
+
import { createApiClient } from "../../client";
|
|
9
|
+
import type { CMSApiRouter } from "../api";
|
|
6
10
|
import { createBackendStack } from "../../../api";
|
|
7
11
|
import { defineAuthorization } from "../../../authorization";
|
|
8
12
|
import {
|
|
@@ -217,6 +221,318 @@ async function seedRecord(
|
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
describe("CMS authorization inventory", () => {
|
|
224
|
+
it("selects literal declared content fields without changing stored value semantics", async () => {
|
|
225
|
+
const backend = createBackendStack({
|
|
226
|
+
basePath: "/api",
|
|
227
|
+
plugins: {
|
|
228
|
+
cms: cmsBackendPlugin({
|
|
229
|
+
contentTypes: [
|
|
230
|
+
{
|
|
231
|
+
name: "Article",
|
|
232
|
+
slug: "article",
|
|
233
|
+
schema: z.object({
|
|
234
|
+
title: z.string(),
|
|
235
|
+
body: z.string(),
|
|
236
|
+
optional: z.string().optional(),
|
|
237
|
+
nil: z.string().nullable(),
|
|
238
|
+
nested: z.object({ label: z.string() }),
|
|
239
|
+
entries: z.array(z.string()),
|
|
240
|
+
'headline,quoted". 标题': z.string(),
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
adapter: memoryAdapter,
|
|
247
|
+
auth: createAuth(),
|
|
248
|
+
});
|
|
249
|
+
const record = await seedRecord(backend, { slug: "selected-content" });
|
|
250
|
+
await backend.adapter.update({
|
|
251
|
+
model: "contentItem",
|
|
252
|
+
where: [{ field: "id", value: record.id }],
|
|
253
|
+
update: {
|
|
254
|
+
data: JSON.stringify({
|
|
255
|
+
title: "Selected title",
|
|
256
|
+
body: "Searchable long body",
|
|
257
|
+
nil: null,
|
|
258
|
+
nested: { label: "Whole nested value" },
|
|
259
|
+
entries: ["one", "two"],
|
|
260
|
+
'headline,quoted". 标题': "Literal property",
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const result = await backend.trusted.cms.listContentItems({
|
|
265
|
+
typeSlug: "article",
|
|
266
|
+
query: {
|
|
267
|
+
view: "content",
|
|
268
|
+
fields: [
|
|
269
|
+
"title",
|
|
270
|
+
"optional",
|
|
271
|
+
"nil",
|
|
272
|
+
"nested",
|
|
273
|
+
"entries",
|
|
274
|
+
'headline,quoted". 标题',
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
expect(result.items[0]).toEqual({
|
|
279
|
+
id: record.id,
|
|
280
|
+
slug: record.slug,
|
|
281
|
+
createdAt: record.createdAt.toISOString(),
|
|
282
|
+
updatedAt: record.updatedAt.toISOString(),
|
|
283
|
+
parsedData: {
|
|
284
|
+
title: "Selected title",
|
|
285
|
+
nil: null,
|
|
286
|
+
nested: { label: "Whole nested value" },
|
|
287
|
+
entries: ["one", "two"],
|
|
288
|
+
'headline,quoted". 标题': "Literal property",
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
expect(result).toMatchObject({ total: 1, limit: 20, offset: 0 });
|
|
292
|
+
const fields = JSON.stringify(['headline,quoted". 标题', "title", "title"]);
|
|
293
|
+
for (const path of [
|
|
294
|
+
`/content/article/${record.id}`,
|
|
295
|
+
"/content/article?slug=selected-content",
|
|
296
|
+
"/content/article?search=Searchable",
|
|
297
|
+
]) {
|
|
298
|
+
const response = await backend.handler(
|
|
299
|
+
request(
|
|
300
|
+
`${path}${path.includes("?") ? "&" : "?"}view=content&fields=${encodeURIComponent(fields)}`,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
expect(response.status).toBe(200);
|
|
304
|
+
const value = await response.json();
|
|
305
|
+
expect((value.items ? value.items[0] : value).parsedData).toEqual({
|
|
306
|
+
title: "Selected title",
|
|
307
|
+
'headline,quoted". 标题': "Literal property",
|
|
308
|
+
});
|
|
309
|
+
if (value.items) expect(value.total).toBe(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("rejects invalid field selections even when an existing collection is empty", async () => {
|
|
314
|
+
const backend = makeBackend({ auth: createAuth() });
|
|
315
|
+
for (const fields of [
|
|
316
|
+
"not-json",
|
|
317
|
+
"[]",
|
|
318
|
+
'["unknown"]',
|
|
319
|
+
'["title.nested"]',
|
|
320
|
+
"[1]",
|
|
321
|
+
"{}",
|
|
322
|
+
"null",
|
|
323
|
+
'"title"',
|
|
324
|
+
'["__proto__"]',
|
|
325
|
+
'["constructor"]',
|
|
326
|
+
]) {
|
|
327
|
+
const response = await backend.handler(
|
|
328
|
+
request(
|
|
329
|
+
`/content/article?view=content&fields=${encodeURIComponent(fields)}`,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
expect(response.status, fields).toBe(400);
|
|
333
|
+
}
|
|
334
|
+
expect(
|
|
335
|
+
(
|
|
336
|
+
await backend.handler(
|
|
337
|
+
request("/content/article?fields=%5B%22title%22%5D"),
|
|
338
|
+
)
|
|
339
|
+
).status,
|
|
340
|
+
).toBe(400);
|
|
341
|
+
await expect(
|
|
342
|
+
backend.trusted.cms.listContentItems({
|
|
343
|
+
typeSlug: "article",
|
|
344
|
+
query: { fields: ["title"] },
|
|
345
|
+
}),
|
|
346
|
+
).rejects.toMatchObject({ statusCode: 400 });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("returns the content envelope only when requested on a collection read", async () => {
|
|
350
|
+
const backend = makeBackend();
|
|
351
|
+
const record = await seedRecord(backend, {
|
|
352
|
+
slug: "content-view",
|
|
353
|
+
authorId: "author-1",
|
|
354
|
+
});
|
|
355
|
+
const full = await backend.trusted.cms.listContentItems({
|
|
356
|
+
typeSlug: "article",
|
|
357
|
+
query: {},
|
|
358
|
+
});
|
|
359
|
+
const content = await backend.trusted.cms.listContentItems({
|
|
360
|
+
typeSlug: "article",
|
|
361
|
+
query: { view: "content" },
|
|
362
|
+
});
|
|
363
|
+
expect(full.items[0]).toHaveProperty("data");
|
|
364
|
+
expect(content).toEqual({
|
|
365
|
+
items: [
|
|
366
|
+
{
|
|
367
|
+
id: record.id,
|
|
368
|
+
slug: "content-view",
|
|
369
|
+
parsedData: { title: "content-view" },
|
|
370
|
+
createdAt: record.createdAt.toISOString(),
|
|
371
|
+
updatedAt: record.updatedAt.toISOString(),
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
total: 1,
|
|
375
|
+
limit: 20,
|
|
376
|
+
offset: 0,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("keeps content views on existing ID and slug HTTP reads with the same access checks", async () => {
|
|
381
|
+
const backend = makeBackend({ auth: createAuth() });
|
|
382
|
+
const record = await seedRecord(backend, {
|
|
383
|
+
slug: "read-content",
|
|
384
|
+
authorId: "owner",
|
|
385
|
+
});
|
|
386
|
+
for (const path of [
|
|
387
|
+
`/content/article/${record.id}?view=content`,
|
|
388
|
+
"/content/article?slug=read-content&view=content",
|
|
389
|
+
]) {
|
|
390
|
+
const response = await backend.handler(request(path));
|
|
391
|
+
expect(response.status).toBe(200);
|
|
392
|
+
const value = await response.json();
|
|
393
|
+
const item = path.includes("?slug=") ? value.items[0] : value;
|
|
394
|
+
expect(Object.keys(item).sort()).toEqual([
|
|
395
|
+
"createdAt",
|
|
396
|
+
"id",
|
|
397
|
+
"parsedData",
|
|
398
|
+
"slug",
|
|
399
|
+
"updatedAt",
|
|
400
|
+
]);
|
|
401
|
+
expect(item.parsedData).toEqual({ title: "read-content" });
|
|
402
|
+
}
|
|
403
|
+
expect(
|
|
404
|
+
(
|
|
405
|
+
await backend.handler(
|
|
406
|
+
request(`/content/article/${record.id}?view=invalid`),
|
|
407
|
+
)
|
|
408
|
+
).status,
|
|
409
|
+
).toBe(400);
|
|
410
|
+
expect(
|
|
411
|
+
(await backend.handler(request("/content/article?view=invalid"))).status,
|
|
412
|
+
).toBe(400);
|
|
413
|
+
expect(
|
|
414
|
+
(await backend.handler(request("/content/article/missing?view=content")))
|
|
415
|
+
.status,
|
|
416
|
+
).toBe(404);
|
|
417
|
+
expect(
|
|
418
|
+
(await backend.handler(request("/content/missing?view=content"))).status,
|
|
419
|
+
).toBe(404);
|
|
420
|
+
const closed = makeBackend({
|
|
421
|
+
auth: createServerAuth({
|
|
422
|
+
authorization: defineAuthorization({
|
|
423
|
+
identity: z.object({ id: z.string() }),
|
|
424
|
+
permissions: [cmsPermissions] as const,
|
|
425
|
+
rules: ({ cms }) => [cms.contentType.read.allow()],
|
|
426
|
+
}),
|
|
427
|
+
getIdentity: () => ({ id: "reader" }),
|
|
428
|
+
}),
|
|
429
|
+
});
|
|
430
|
+
const privateRecord = await seedRecord(closed, { slug: "private-content" });
|
|
431
|
+
expect(
|
|
432
|
+
(
|
|
433
|
+
await closed.handler(
|
|
434
|
+
request(`/content/article/${privateRecord.id}?view=content`),
|
|
435
|
+
)
|
|
436
|
+
).status,
|
|
437
|
+
).toBe(403);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("prefetches content views with matching client keys and serialized infinite pages", async () => {
|
|
441
|
+
const backend = makeBackend();
|
|
442
|
+
const record = await seedRecord(backend, { slug: "prefetch-content" });
|
|
443
|
+
const queryClient = new QueryClient();
|
|
444
|
+
await backend.raw.cms.prefetchForRoute("contentList", queryClient, {
|
|
445
|
+
typeSlug: "article",
|
|
446
|
+
view: "content",
|
|
447
|
+
fields: ["title", "title"],
|
|
448
|
+
limit: 5,
|
|
449
|
+
});
|
|
450
|
+
await backend.raw.cms.prefetchForRoute("editContent", queryClient, {
|
|
451
|
+
typeSlug: "article",
|
|
452
|
+
id: record.id,
|
|
453
|
+
view: "content",
|
|
454
|
+
fields: ["title", "title"],
|
|
455
|
+
});
|
|
456
|
+
const queries = createCMSQueryKeys(
|
|
457
|
+
createApiClient<CMSApiRouter>({
|
|
458
|
+
baseURL: "http://localhost",
|
|
459
|
+
basePath: "/api",
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
const listKey = queries.cmsContent.list({
|
|
463
|
+
typeSlug: "article",
|
|
464
|
+
view: "content",
|
|
465
|
+
fields: ["title", "title"],
|
|
466
|
+
limit: 5,
|
|
467
|
+
}).queryKey;
|
|
468
|
+
expect(listKey).toEqual(
|
|
469
|
+
CMS_QUERY_KEYS.contentList({
|
|
470
|
+
typeSlug: "article",
|
|
471
|
+
view: "content",
|
|
472
|
+
fields: ["title", "title"],
|
|
473
|
+
limit: 5,
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
expect(queryClient.getQueryData(listKey)).toEqual({
|
|
477
|
+
pages: [
|
|
478
|
+
{
|
|
479
|
+
items: [
|
|
480
|
+
{
|
|
481
|
+
id: record.id,
|
|
482
|
+
slug: "prefetch-content",
|
|
483
|
+
parsedData: { title: "prefetch-content" },
|
|
484
|
+
createdAt: record.createdAt.toISOString(),
|
|
485
|
+
updatedAt: record.updatedAt.toISOString(),
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
total: 1,
|
|
489
|
+
limit: 5,
|
|
490
|
+
offset: 0,
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
pageParams: [0],
|
|
494
|
+
});
|
|
495
|
+
const detail = queryClient.getQueryData(
|
|
496
|
+
queries.cmsContent.detail("article", record.id, {
|
|
497
|
+
view: "content",
|
|
498
|
+
fields: ["title"],
|
|
499
|
+
}).queryKey,
|
|
500
|
+
);
|
|
501
|
+
expect(detail).toHaveProperty("parsedData.title", "prefetch-content");
|
|
502
|
+
expect(detail).not.toHaveProperty("data");
|
|
503
|
+
expect(
|
|
504
|
+
queryClient.getQueryData(
|
|
505
|
+
CMS_QUERY_KEYS.contentDetail("article", record.id),
|
|
506
|
+
),
|
|
507
|
+
).toBeUndefined();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it.each(["", "&fields=%5B%22title%22%5D"])(
|
|
511
|
+
"still requires content-type metadata access for content-only ID, slug and collection reads (%s)",
|
|
512
|
+
async (selection) => {
|
|
513
|
+
const backend = makeBackend({
|
|
514
|
+
auth: createServerAuth({
|
|
515
|
+
authorization: defineAuthorization({
|
|
516
|
+
identity: z.object({ id: z.string() }),
|
|
517
|
+
permissions: [cmsPermissions] as const,
|
|
518
|
+
rules: ({ cms }) => [cms.record.read.allow()],
|
|
519
|
+
}),
|
|
520
|
+
getIdentity: () => ({ id: "reader" }),
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
const record = await seedRecord(backend, { slug: "metadata-denied" });
|
|
524
|
+
for (const path of [
|
|
525
|
+
`/content/article/${record.id}?view=content`,
|
|
526
|
+
"/content/article?slug=metadata-denied&view=content",
|
|
527
|
+
"/content/article?view=content",
|
|
528
|
+
]) {
|
|
529
|
+
expect(
|
|
530
|
+
(await backend.handler(request(`${path}${selection}`))).status,
|
|
531
|
+
).toBe(403);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
);
|
|
535
|
+
|
|
220
536
|
it("records every removed lifecycle name and canonical replacement", () => {
|
|
221
537
|
expect(CMS_LIFECYCLE_HOOK_MIGRATIONS).toEqual({
|
|
222
538
|
onBeforeCreate: "onBeforeCreateContent",
|
|
@@ -1150,16 +1466,153 @@ describe("CMS operation-first authorization", () => {
|
|
|
1150
1466
|
return findMany(query);
|
|
1151
1467
|
});
|
|
1152
1468
|
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1469
|
+
for (const view of [undefined, "content"]) {
|
|
1470
|
+
const response = await backend.handler(
|
|
1471
|
+
request(
|
|
1472
|
+
`/content/resource/by-relation?field=categoryIds&targetId=${target.id}${view ? `&view=${view}&fields=%5B%22name%22%5D` : ""}`,
|
|
1473
|
+
),
|
|
1474
|
+
);
|
|
1475
|
+
expect(response.status).toBe(403);
|
|
1476
|
+
}
|
|
1159
1477
|
expect(relationReads).toBe(0);
|
|
1160
1478
|
expect(events).toEqual([]);
|
|
1161
1479
|
});
|
|
1162
1480
|
|
|
1481
|
+
it("pages every unique related record without fetching unrequested content", async () => {
|
|
1482
|
+
const backend = makeBackend({ auth: createAuth() });
|
|
1483
|
+
const target = await seedRecord(backend, {
|
|
1484
|
+
typeSlug: "category",
|
|
1485
|
+
slug: "large-category",
|
|
1486
|
+
});
|
|
1487
|
+
const types = await allContentTypes(backend);
|
|
1488
|
+
const noteType = types.find((type) => type.slug === "category-note")!;
|
|
1489
|
+
const articleType = types.find((type) => type.slug === "article")!;
|
|
1490
|
+
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
|
1491
|
+
for (let index = 0; index < 235; index++) {
|
|
1492
|
+
const isNote = index >= 110;
|
|
1493
|
+
const id = `${isNote ? "note" : "article"}-${String(isNote ? index - 110 : index).padStart(3, "0")}`;
|
|
1494
|
+
await backend.adapter.create<ContentItem>({
|
|
1495
|
+
model: "contentItem",
|
|
1496
|
+
forceAllowId: true,
|
|
1497
|
+
data: {
|
|
1498
|
+
id,
|
|
1499
|
+
contentTypeId: isNote ? noteType.id : articleType.id,
|
|
1500
|
+
slug: id,
|
|
1501
|
+
data: JSON.stringify({ message: id, unrequested: "Long body" }),
|
|
1502
|
+
createdAt,
|
|
1503
|
+
updatedAt: createdAt,
|
|
1504
|
+
} as ContentItem,
|
|
1505
|
+
});
|
|
1506
|
+
await backend.adapter.create<ContentRelation>({
|
|
1507
|
+
model: "contentRelation",
|
|
1508
|
+
data: {
|
|
1509
|
+
sourceId: id,
|
|
1510
|
+
targetId: target.id,
|
|
1511
|
+
fieldName: "categoryId",
|
|
1512
|
+
createdAt,
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
await backend.adapter.create<ContentRelation>({
|
|
1517
|
+
model: "contentRelation",
|
|
1518
|
+
data: {
|
|
1519
|
+
sourceId: "note-124",
|
|
1520
|
+
targetId: target.id,
|
|
1521
|
+
fieldName: "categoryId",
|
|
1522
|
+
createdAt,
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
const findMany = backend.adapter.findMany.bind(backend.adapter);
|
|
1526
|
+
const findOne = backend.adapter.findOne.bind(backend.adapter);
|
|
1527
|
+
const fullReads: string[] = [];
|
|
1528
|
+
const individualSourceReads: string[] = [];
|
|
1529
|
+
vi.spyOn(backend.adapter, "findMany").mockImplementation(async (query) => {
|
|
1530
|
+
const rows = await findMany({ ...query, limit: query.limit ?? 100 });
|
|
1531
|
+
if (
|
|
1532
|
+
query.model === "contentItem" &&
|
|
1533
|
+
(!query.select || query.select.includes("data"))
|
|
1534
|
+
) {
|
|
1535
|
+
fullReads.push(...(rows as ContentItem[]).map((item) => item.id));
|
|
1536
|
+
}
|
|
1537
|
+
return rows;
|
|
1538
|
+
});
|
|
1539
|
+
vi.spyOn(backend.adapter, "findOne").mockImplementation(async (query) => {
|
|
1540
|
+
const row = await findOne(query);
|
|
1541
|
+
if (
|
|
1542
|
+
query.model === "contentItem" &&
|
|
1543
|
+
(row as ContentItem | null)?.contentTypeId === noteType.id
|
|
1544
|
+
) {
|
|
1545
|
+
individualSourceReads.push((row as ContentItem).id);
|
|
1546
|
+
}
|
|
1547
|
+
return row;
|
|
1548
|
+
});
|
|
1549
|
+
const expected = Array.from(
|
|
1550
|
+
{ length: 125 },
|
|
1551
|
+
(_, index) => `note-${String(124 - index).padStart(3, "0")}`,
|
|
1552
|
+
);
|
|
1553
|
+
for (const path of [
|
|
1554
|
+
`/content/category-note/by-relation?field=categoryId&targetId=${target.id}`,
|
|
1555
|
+
`/content-types/category/inverse-relations/category-note?fieldName=categoryId&itemId=${target.id}`,
|
|
1556
|
+
]) {
|
|
1557
|
+
expect(
|
|
1558
|
+
(await backend.handler(request(`${path}&view=editor`))).status,
|
|
1559
|
+
).toBe(400);
|
|
1560
|
+
for (const fields of ['["unknown"]', "[]", '["message.nested"]']) {
|
|
1561
|
+
expect(
|
|
1562
|
+
(
|
|
1563
|
+
await backend.handler(
|
|
1564
|
+
request(
|
|
1565
|
+
`${path}&view=content&fields=${encodeURIComponent(fields)}`,
|
|
1566
|
+
),
|
|
1567
|
+
)
|
|
1568
|
+
).status,
|
|
1569
|
+
).toBe(400);
|
|
1570
|
+
}
|
|
1571
|
+
for (const view of [undefined, "content", "projected"]) {
|
|
1572
|
+
const seen: string[] = [];
|
|
1573
|
+
for (let offset = 0; offset < 125; offset += 25) {
|
|
1574
|
+
fullReads.length = 0;
|
|
1575
|
+
const response = await backend.handler(
|
|
1576
|
+
request(
|
|
1577
|
+
`${path}&limit=25&offset=${offset}${view ? `&view=content${view === "projected" ? "&fields=%5B%22message%22%5D" : ""}` : ""}`,
|
|
1578
|
+
),
|
|
1579
|
+
);
|
|
1580
|
+
expect(response.status).toBe(200);
|
|
1581
|
+
const page = await response.json();
|
|
1582
|
+
expect(page).toMatchObject({ total: 125, limit: 25, offset });
|
|
1583
|
+
expect(page.items.map((item: ContentItem) => item.id)).toEqual(
|
|
1584
|
+
expected.slice(offset, offset + 25),
|
|
1585
|
+
);
|
|
1586
|
+
if (view) {
|
|
1587
|
+
expect(page.items[0]).toEqual({
|
|
1588
|
+
id: expected[offset],
|
|
1589
|
+
slug: expected[offset],
|
|
1590
|
+
parsedData: {
|
|
1591
|
+
message: expected[offset],
|
|
1592
|
+
...(view === "content" ? { unrequested: "Long body" } : {}),
|
|
1593
|
+
},
|
|
1594
|
+
createdAt: createdAt.toISOString(),
|
|
1595
|
+
updatedAt: createdAt.toISOString(),
|
|
1596
|
+
});
|
|
1597
|
+
} else {
|
|
1598
|
+
expect(page.items[0]).toMatchObject({
|
|
1599
|
+
data: expect.any(String),
|
|
1600
|
+
parsedData: { message: expected[offset] },
|
|
1601
|
+
contentType: { slug: "category-note" },
|
|
1602
|
+
createdAt: createdAt.toISOString(),
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
expect([...fullReads].sort()).toEqual(
|
|
1606
|
+
[...expected.slice(offset, offset + 25)].sort(),
|
|
1607
|
+
);
|
|
1608
|
+
seen.push(...page.items.map((item: ContentItem) => item.id));
|
|
1609
|
+
}
|
|
1610
|
+
expect(seen).toEqual(expected);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
expect(individualSourceReads).toEqual([]);
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1163
1616
|
it("fails closed when relation-filter schemas or targets change after authorization", async () => {
|
|
1164
1617
|
const relationFilterAuthorization = defineAuthorization({
|
|
1165
1618
|
identity: z.object({ id: z.string(), role: z.literal("user") }),
|
|
@@ -1169,7 +1622,7 @@ describe("CMS operation-first authorization", () => {
|
|
|
1169
1622
|
cms.record.read.allow(),
|
|
1170
1623
|
],
|
|
1171
1624
|
});
|
|
1172
|
-
const runRace = async (kind: "schema" | "target") => {
|
|
1625
|
+
const runRace = async (kind: "schema" | "target", view?: "content") => {
|
|
1173
1626
|
const baseAuth = createServerAuth({
|
|
1174
1627
|
authorization: relationFilterAuthorization,
|
|
1175
1628
|
getIdentity: () => ({ id: "reader-1", role: "user" as const }),
|
|
@@ -1231,7 +1684,7 @@ describe("CMS operation-first authorization", () => {
|
|
|
1231
1684
|
});
|
|
1232
1685
|
const response = await backend.handler(
|
|
1233
1686
|
request(
|
|
1234
|
-
`/content/resource/by-relation?field=categoryIds&targetId=${target.id}`,
|
|
1687
|
+
`/content/resource/by-relation?field=categoryIds&targetId=${target.id}${view ? `&view=${view}` : ""}`,
|
|
1235
1688
|
),
|
|
1236
1689
|
);
|
|
1237
1690
|
expect(response.status).toBe(409);
|
|
@@ -1245,6 +1698,8 @@ describe("CMS operation-first authorization", () => {
|
|
|
1245
1698
|
|
|
1246
1699
|
await runRace("schema");
|
|
1247
1700
|
await runRace("target");
|
|
1701
|
+
await runRace("schema", "content");
|
|
1702
|
+
await runRace("target", "content");
|
|
1248
1703
|
});
|
|
1249
1704
|
|
|
1250
1705
|
it("authorizes every populated relation target before returning its record", async () => {
|
|
@@ -1397,191 +1852,201 @@ describe("CMS operation-first authorization", () => {
|
|
|
1397
1852
|
expect(response.status).toBe(403);
|
|
1398
1853
|
});
|
|
1399
1854
|
|
|
1400
|
-
it(
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
rules: ({ cms }) => [
|
|
1414
|
-
cms.contentType.read.when(
|
|
1415
|
-
({ facts }) =>
|
|
1416
|
-
facts.contentType === "category" ||
|
|
1417
|
-
facts.contentType === "category-note",
|
|
1418
|
-
),
|
|
1419
|
-
cms.record.read.when(({ identity, facts }) => {
|
|
1420
|
-
const targetRecord =
|
|
1421
|
-
facts.contentType === "category" && facts.scope === "record";
|
|
1422
|
-
const sourceCollection =
|
|
1423
|
-
facts.contentType === "category-note" &&
|
|
1424
|
-
facts.scope === "collection";
|
|
1425
|
-
if (identity?.mode === "target-only") return targetRecord;
|
|
1426
|
-
if (identity?.mode === "source-only") return sourceCollection;
|
|
1427
|
-
return (
|
|
1428
|
-
(identity?.mode === "all" ||
|
|
1429
|
-
identity?.mode === "racing" ||
|
|
1430
|
-
identity?.mode === "schema-racing") &&
|
|
1431
|
-
(targetRecord || sourceCollection)
|
|
1432
|
-
);
|
|
1855
|
+
it.each([undefined, "content"] as const)(
|
|
1856
|
+
"authorizes the target record and source collections before inverse counts (%s view)",
|
|
1857
|
+
async (view) => {
|
|
1858
|
+
const inverseCountAuthorization = defineAuthorization({
|
|
1859
|
+
identity: z.object({
|
|
1860
|
+
id: z.string(),
|
|
1861
|
+
mode: z.enum([
|
|
1862
|
+
"target-only",
|
|
1863
|
+
"source-only",
|
|
1864
|
+
"all",
|
|
1865
|
+
"racing",
|
|
1866
|
+
"schema-racing",
|
|
1867
|
+
]),
|
|
1433
1868
|
}),
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1869
|
+
permissions: [cmsPermissions] as const,
|
|
1870
|
+
rules: ({ cms }) => [
|
|
1871
|
+
cms.contentType.read.when(
|
|
1872
|
+
({ facts }) =>
|
|
1873
|
+
facts.contentType === "category" ||
|
|
1874
|
+
facts.contentType === "category-note",
|
|
1875
|
+
),
|
|
1876
|
+
cms.record.read.when(({ identity, facts }) => {
|
|
1877
|
+
const targetRecord =
|
|
1878
|
+
facts.contentType === "category" && facts.scope === "record";
|
|
1879
|
+
const sourceCollection =
|
|
1880
|
+
facts.contentType === "category-note" &&
|
|
1881
|
+
facts.scope === "collection";
|
|
1882
|
+
if (identity?.mode === "target-only") return targetRecord;
|
|
1883
|
+
if (identity?.mode === "source-only") return sourceCollection;
|
|
1884
|
+
return (
|
|
1885
|
+
(identity?.mode === "all" ||
|
|
1886
|
+
identity?.mode === "racing" ||
|
|
1887
|
+
identity?.mode === "schema-racing") &&
|
|
1888
|
+
(targetRecord || sourceCollection)
|
|
1889
|
+
);
|
|
1890
|
+
}),
|
|
1891
|
+
],
|
|
1892
|
+
});
|
|
1893
|
+
const modes = [
|
|
1894
|
+
"target-only",
|
|
1895
|
+
"source-only",
|
|
1896
|
+
"all",
|
|
1897
|
+
"racing",
|
|
1898
|
+
"schema-racing",
|
|
1899
|
+
] as const;
|
|
1900
|
+
const baseAuth = createServerAuth({
|
|
1901
|
+
authorization: inverseCountAuthorization,
|
|
1902
|
+
getIdentity: ({ request: currentRequest }) => {
|
|
1903
|
+
const mode = currentRequest.headers.get("x-mode");
|
|
1904
|
+
return modes.includes(mode as (typeof modes)[number])
|
|
1905
|
+
? { id: "reader-1", mode: mode as (typeof modes)[number] }
|
|
1906
|
+
: null;
|
|
1907
|
+
},
|
|
1908
|
+
});
|
|
1909
|
+
let backend: ReturnType<typeof makeBackend>;
|
|
1910
|
+
let targetId = "";
|
|
1911
|
+
const authorizationCalls = new WeakMap<Request, number>();
|
|
1912
|
+
const authorize = baseAuth.authorize.bind(baseAuth);
|
|
1913
|
+
const racingAuth: typeof baseAuth = {
|
|
1914
|
+
...baseAuth,
|
|
1915
|
+
async authorize(currentRequest, permissionRequest) {
|
|
1916
|
+
const identity = await authorize(currentRequest, permissionRequest);
|
|
1917
|
+
const count = (authorizationCalls.get(currentRequest) ?? 0) + 1;
|
|
1918
|
+
authorizationCalls.set(currentRequest, count);
|
|
1919
|
+
if (
|
|
1920
|
+
currentRequest.headers.get("x-mode") === "racing" &&
|
|
1921
|
+
count === 4
|
|
1922
|
+
) {
|
|
1923
|
+
await backend.adapter.update<ContentItem>({
|
|
1924
|
+
model: "contentItem",
|
|
1925
|
+
where: [{ field: "id", value: targetId }],
|
|
1926
|
+
update: { authorId: "other-owner" },
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
if (
|
|
1930
|
+
currentRequest.headers.get("x-mode") === "schema-racing" &&
|
|
1931
|
+
count === 2
|
|
1932
|
+
) {
|
|
1933
|
+
const sourceType = await backend.adapter.findOne<ContentType>({
|
|
1934
|
+
model: "contentType",
|
|
1935
|
+
where: [{ field: "slug", value: "category-note" }],
|
|
1936
|
+
});
|
|
1937
|
+
if (!sourceType)
|
|
1938
|
+
throw new Error("Missing category-note content type");
|
|
1939
|
+
await backend.adapter.update<ContentType>({
|
|
1940
|
+
model: "contentType",
|
|
1941
|
+
where: [{ field: "id", value: sourceType.id }],
|
|
1942
|
+
update: {
|
|
1943
|
+
jsonSchema: JSON.stringify(zodToFormSchema(articleSchema)),
|
|
1944
|
+
updatedAt: new Date(),
|
|
1945
|
+
},
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
return identity;
|
|
1949
|
+
},
|
|
1950
|
+
};
|
|
1951
|
+
backend = makeBackend({ auth: racingAuth });
|
|
1952
|
+
const target = await seedRecord(backend, {
|
|
1953
|
+
typeSlug: "category",
|
|
1954
|
+
slug: "counted-category",
|
|
1955
|
+
authorId: "reader-1",
|
|
1956
|
+
});
|
|
1957
|
+
targetId = target.id;
|
|
1958
|
+
const source = await seedRecord(backend, {
|
|
1959
|
+
typeSlug: "category-note",
|
|
1960
|
+
slug: "counted-note",
|
|
1961
|
+
});
|
|
1962
|
+
const wrongTypeTarget = await seedRecord(backend, {
|
|
1963
|
+
typeSlug: "article",
|
|
1964
|
+
slug: "wrong-target-type",
|
|
1965
|
+
});
|
|
1966
|
+
await backend.adapter.create<ContentRelation>({
|
|
1967
|
+
model: "contentRelation",
|
|
1968
|
+
data: {
|
|
1969
|
+
sourceId: source.id,
|
|
1970
|
+
targetId: target.id,
|
|
1971
|
+
fieldName: "categoryId",
|
|
1972
|
+
createdAt: new Date(),
|
|
1973
|
+
},
|
|
1974
|
+
});
|
|
1975
|
+
const findMany = backend.adapter.findMany.bind(backend.adapter);
|
|
1976
|
+
let relationReads = 0;
|
|
1977
|
+
vi.spyOn(backend.adapter, "findMany").mockImplementation(
|
|
1978
|
+
async (query) => {
|
|
1979
|
+
if (query.model === "contentRelation") relationReads += 1;
|
|
1980
|
+
return findMany(query);
|
|
1981
|
+
},
|
|
1525
1982
|
);
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1983
|
+
const countRequest = (mode: (typeof modes)[number]) =>
|
|
1984
|
+
new Request(
|
|
1985
|
+
`http://localhost/api/content-types/category/inverse-relations?itemId=${target.id}`,
|
|
1986
|
+
{ headers: { "x-mode": mode } },
|
|
1987
|
+
);
|
|
1988
|
+
const listRequest = (
|
|
1989
|
+
mode: (typeof modes)[number],
|
|
1990
|
+
options?: { itemId?: string; fieldName?: string },
|
|
1991
|
+
) =>
|
|
1992
|
+
new Request(
|
|
1993
|
+
`http://localhost/api/content-types/category/inverse-relations/category-note?itemId=${options?.itemId ?? target.id}&fieldName=${options?.fieldName ?? "categoryId"}${view ? `&view=${view}` : ""}`,
|
|
1994
|
+
{ headers: { "x-mode": mode } },
|
|
1995
|
+
);
|
|
1996
|
+
|
|
1997
|
+
const listTargetDenied = await backend.handler(
|
|
1998
|
+
listRequest("source-only"),
|
|
1533
1999
|
);
|
|
2000
|
+
expect(listTargetDenied.status).toBe(403);
|
|
2001
|
+
const wrongTargetType = await backend.handler(
|
|
2002
|
+
listRequest("all", { itemId: wrongTypeTarget.id }),
|
|
2003
|
+
);
|
|
2004
|
+
expect(wrongTargetType.status).toBe(404);
|
|
2005
|
+
const invalidField = await backend.handler(
|
|
2006
|
+
listRequest("all", { fieldName: "notACategoryRelation" }),
|
|
2007
|
+
);
|
|
2008
|
+
expect(invalidField.status).toBe(404);
|
|
2009
|
+
expect(await invalidField.json()).toMatchObject({
|
|
2010
|
+
code: "INVERSE_RELATION_NOT_FOUND",
|
|
2011
|
+
});
|
|
2012
|
+
expect(relationReads).toBe(0);
|
|
1534
2013
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
});
|
|
1556
|
-
expect(relationReads).toBe(1);
|
|
1557
|
-
|
|
1558
|
-
const sourceDenied = await backend.handler(countRequest("target-only"));
|
|
1559
|
-
expect(sourceDenied.status).toBe(403);
|
|
1560
|
-
const targetDenied = await backend.handler(countRequest("source-only"));
|
|
1561
|
-
expect(targetDenied.status).toBe(403);
|
|
1562
|
-
expect(relationReads).toBe(1);
|
|
1563
|
-
|
|
1564
|
-
const allowed = await backend.handler(countRequest("all"));
|
|
1565
|
-
expect(allowed.status).toBe(200);
|
|
1566
|
-
expect(await allowed.json()).toMatchObject({
|
|
1567
|
-
inverseRelations: [{ sourceType: "category-note", count: 1 }],
|
|
1568
|
-
});
|
|
1569
|
-
expect(relationReads).toBe(2);
|
|
2014
|
+
const allowedList = await backend.handler(listRequest("all"));
|
|
2015
|
+
expect(allowedList.status).toBe(200);
|
|
2016
|
+
expect(await allowedList.json()).toMatchObject({
|
|
2017
|
+
total: 1,
|
|
2018
|
+
items: [{ id: source.id }],
|
|
2019
|
+
});
|
|
2020
|
+
expect(relationReads).toBe(1);
|
|
2021
|
+
|
|
2022
|
+
const sourceDenied = await backend.handler(countRequest("target-only"));
|
|
2023
|
+
expect(sourceDenied.status).toBe(403);
|
|
2024
|
+
const targetDenied = await backend.handler(countRequest("source-only"));
|
|
2025
|
+
expect(targetDenied.status).toBe(403);
|
|
2026
|
+
expect(relationReads).toBe(1);
|
|
2027
|
+
|
|
2028
|
+
const allowed = await backend.handler(countRequest("all"));
|
|
2029
|
+
expect(allowed.status).toBe(200);
|
|
2030
|
+
expect(await allowed.json()).toMatchObject({
|
|
2031
|
+
inverseRelations: [{ sourceType: "category-note", count: 1 }],
|
|
2032
|
+
});
|
|
2033
|
+
expect(relationReads).toBe(2);
|
|
1570
2034
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2035
|
+
const staleTarget = await backend.handler(countRequest("racing"));
|
|
2036
|
+
expect(staleTarget.status).toBe(409);
|
|
2037
|
+
expect(await staleTarget.json()).toMatchObject({
|
|
2038
|
+
code: "RECORD_STATE_CHANGED",
|
|
2039
|
+
});
|
|
2040
|
+
expect(relationReads).toBe(2);
|
|
1577
2041
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
2042
|
+
const staleSchema = await backend.handler(listRequest("schema-racing"));
|
|
2043
|
+
expect(staleSchema.status).toBe(409);
|
|
2044
|
+
expect(await staleSchema.json()).toMatchObject({
|
|
2045
|
+
code: "RELATION_SCHEMA_CHANGED",
|
|
2046
|
+
});
|
|
2047
|
+
expect(relationReads).toBe(2);
|
|
2048
|
+
},
|
|
2049
|
+
);
|
|
1585
2050
|
|
|
1586
2051
|
it("fails closed when inverse relation schemas change after authorization", async () => {
|
|
1587
2052
|
const inverseAuthorization = defineAuthorization({
|