@sanity-labs/client-fake-for-test 0.13.0 → 0.14.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/README.md CHANGED
@@ -198,10 +198,12 @@ await canvas.releases.create({ releaseId: "r1" }); // 404 Cannot POST /canvases/
198
198
  | ACL `<data base>/acl` | ✅ | ✅ | ✅ | 404 `Cannot GET …` |
199
199
  | Access API `…/user-permissions/me`, `…/me/check` | ✅ | ✅ | ✅ | ✅ |
200
200
  | Access API `…/users` | ✅ | ✅ | 403 | 403 |
201
+ | Access API `…/users/<sanityUserId>` (served on `organization`, below) | 403 | 403 | 403 | 403 |
201
202
  | Access API `…/roles` | ✅ | 403 | 403 | 403 |
202
203
  | Org lookup | `/projects/<projectId>` | `GET /media-libraries/<id>` | `GET /canvases/<id>` | `GET /dashboards/<id>` |
203
204
 
204
- - **Access API.** Paths are `/access/<type>/<id>/…` with type `project`, `media-library`, `canvas` or `dashboard`. An identity's `access` entries, keyed `"<type>:<id>"`, hold its `roles` there (non-dataset membership) and the `permissions` the `user-permissions` endpoints report. Every identity is a project member with its project roles. Listings come back on one page (`nextCursor: null`). An unknown type answers 400 `Invalid resource type <type>`. An id the registry does not hold (never seeded, written or addressed) answers 404 `Media library not found` / `Canvas not found` / `Dashboard not found`, except `…/me/check`, which answers `false` as staging does. The 403 cells were not proven on staging (no token could list them), so they answer the way the platform refuses a missing permission: `{ statusCode: 403, message: "You do not have permission to access this resource" }`.
205
+ - **Access API.** Paths are `/access/<type>/<id>/…` with type `organization`, `project`, `media-library`, `canvas` or `dashboard`. An identity's `access` entries, keyed `"<type>:<id>"`, hold its `roles` there (membership) and the `permissions` the `user-permissions` endpoints report. Every identity is a project member with its project roles. A user row is `{ sanityUserId, profile: { id, displayName, email, provider, createdAt }, memberships: [{ resourceType, resourceId, roleNames }] }`; the fake does not model `provider` or `createdAt`, so they are always `"sanity"` and the epoch, and `email` is `""` where the identity seeds none. Listings come back on one page (`nextCursor: null`); users listings add `totalCount`. `includeImpliedRoles` is accepted and ignored: rows carry the seeded roles only, so seed an implied role explicitly when a test needs it. An unknown type answers 400 `Invalid resource type <type>`. An id the registry does not hold (never seeded, written or addressed) answers 404 `Media library not found` / `Canvas not found` / `Dashboard not found`, except `…/me/check`, which answers `false` as staging does. `…/users/<sanityUserId>` is served only on the organization, where the fake checks members read; elsewhere it answers 403 (the reference requires `sanity.<type>.members.read` for it; no type's single-user read was probed on staging, only the media-library listing). The other 403 cells were not proven on staging (no token could list them), so they answer the way the platform refuses a missing permission: `{ statusCode: 403, error: "Forbidden", message: "You do not have permission to access this resource" }`.
206
+ - **Organization.** `/access/organization/<organizationId>/users` and `…/users/<sanityUserId>` (one row, or 404 `User not found` for a non-member) serve the members of the client's `organizationId`: identities with roles in their `"organization:<id>"` entry. Each row lists the organization membership, then the member's roles on every resource the client holds (its libraries, canvases, dashboards and projects), as the platform's organization row does. Like the platform, it needs `sanity.organization.members.read` in the actor's `"organization:<id>"` `permissions` (the built-in `member` role grants it), else 403. Another organization id answers 404 `Organization not found`; `…/roles` answers 403.
205
207
  - **History.** Every committed write since the client was created is recorded per resource, authored by the acting identity; seeded documents can be read at their seeded `_rev`. A handle answers for its own resource, and hides documents its grants cannot read, as reads do (a transaction that also touched such a document is served without it). Events model `createLiveDocument` / `updateLiveDocument` only.
206
208
  - **Org lookup.** `createTestClient({ organizationId })` names the organization every resource of the client belongs to.
207
209
  - **Identity.** `@sanity/client` sends a `resource` client's requests to the global host, so `/users/me` answers `globalUser`.
package/dist/index.cjs CHANGED
@@ -104,7 +104,7 @@ function accessApiError(args) {
104
104
  ...request,
105
105
  statusCode,
106
106
  statusMessage,
107
- body: { statusCode, message }
107
+ body: { statusCode, error: statusMessage, message }
108
108
  });
109
109
  }
110
110
  const noPermission = (request) => accessApiError({
@@ -187,14 +187,7 @@ const RESOURCE_CAPABILITIES = {
187
187
  history: "served",
188
188
  actions: "served",
189
189
  refusedActions: {},
190
- access: {
191
- type: "project",
192
- id: (id) => datasetParts(id).projectId,
193
- roles: "served",
194
- users: "served",
195
- // Every registered identity is a member of every project, with its project roles.
196
- memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
197
- }
190
+ access: { type: "project", id: (id) => datasetParts(id).projectId }
198
191
  },
199
192
  "media-library": {
200
193
  label: (id) => `media library "${id}"`,
@@ -206,15 +199,7 @@ const RESOURCE_CAPABILITIES = {
206
199
  history: "served",
207
200
  actions: "served",
208
201
  refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
209
- access: {
210
- type: "media-library",
211
- id: (id) => id,
212
- // Unproven on staging (no token held roles read): refused like a missing permission.
213
- roles: noPermission,
214
- users: "served",
215
- unknownId: notFound("Media library"),
216
- memberRoles: accessEntryRoles("media-library")
217
- }
202
+ access: { type: "media-library", id: (id) => id }
218
203
  },
219
204
  canvas: {
220
205
  label: (id) => `canvas "${id}"`,
@@ -226,15 +211,7 @@ const RESOURCE_CAPABILITIES = {
226
211
  history: "served",
227
212
  actions: notMounted,
228
213
  refusedActions: {},
229
- access: {
230
- type: "canvas",
231
- id: (id) => id,
232
- // Unproven on staging (no canvas roles/members permission exists to grant).
233
- roles: noPermission,
234
- users: noPermission,
235
- unknownId: notFound("Canvas"),
236
- memberRoles: accessEntryRoles("canvas")
237
- }
214
+ access: { type: "canvas", id: (id) => id }
238
215
  },
239
216
  dashboard: {
240
217
  label: (id) => `dashboard "${id}"`,
@@ -246,19 +223,11 @@ const RESOURCE_CAPABILITIES = {
246
223
  history: notMounted,
247
224
  actions: notMounted,
248
225
  refusedActions: {},
249
- access: {
250
- type: "dashboard",
251
- id: (id) => id,
252
- // Unproven on staging, as for canvas.
253
- roles: noPermission,
254
- users: noPermission,
255
- unknownId: notFound("Dashboard"),
256
- memberRoles: accessEntryRoles("dashboard")
257
- }
226
+ access: { type: "dashboard", id: (id) => id }
258
227
  }
259
228
  };
260
229
  function capabilitiesOf(type) {
261
- const row = RESOURCE_CAPABILITIES[type];
230
+ const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
262
231
  if (row === void 0)
263
232
  throw fakeError(
264
233
  `Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
@@ -280,9 +249,76 @@ function matchResourcePath(path) {
280
249
  return { resource: { type, id: match.id }, rest: match.rest };
281
250
  }
282
251
  }
283
- function capabilitiesForAccessType(type) {
284
- for (const [resourceType, row] of Object.entries(RESOURCE_CAPABILITIES))
285
- if (row.access.type === type) return { resourceType, row };
252
+ function accessResourceOf(resource) {
253
+ const { access } = capabilitiesOf(resource.type);
254
+ return { type: access.type, id: access.id(resource.id) };
255
+ }
256
+ function holdsResource(type) {
257
+ return (registry, id) => registry.resources().some((resource) => {
258
+ const access = accessResourceOf(resource);
259
+ return access.type === type && access.id === id;
260
+ });
261
+ }
262
+ const ACCESS_CAPABILITIES = {
263
+ organization: {
264
+ // Proven on staging, but outside what the fake serves: refused like a missing permission.
265
+ roles: noPermission,
266
+ users: "served",
267
+ user: "served",
268
+ // Granted by the built-in organization `member` role.
269
+ usersPermission: "sanity.organization.members.read",
270
+ unknown: {
271
+ holds: (registry, id) => id === registry.organizationId,
272
+ refusal: notFound("Organization")
273
+ },
274
+ memberRoles: accessEntryRoles("organization"),
275
+ // Every resource of the family belongs to its organization (see `organizationId`).
276
+ owns: (registry) => {
277
+ const byKey = /* @__PURE__ */ new Map();
278
+ for (const resource of registry.resources()) {
279
+ const access = accessResourceOf(resource);
280
+ byKey.set(accessKey(access.type, access.id), access);
281
+ }
282
+ return [...byKey.values()];
283
+ }
284
+ },
285
+ project: {
286
+ roles: "served",
287
+ users: "served",
288
+ // Single-user reads are served only where the fake checks members read (the
289
+ // organization); elsewhere they refuse rather than answer unchecked.
290
+ user: noPermission,
291
+ // Every registered identity is a member of every project, with its project roles.
292
+ memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
293
+ },
294
+ "media-library": {
295
+ // Unproven on staging (no token held roles read): refused like a missing permission.
296
+ roles: noPermission,
297
+ users: "served",
298
+ // Unproven on staging (only the listing was probed); refused as on `project`.
299
+ user: noPermission,
300
+ unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
301
+ memberRoles: accessEntryRoles("media-library")
302
+ },
303
+ canvas: {
304
+ // Unproven on staging (no canvas roles/members permission exists to grant).
305
+ roles: noPermission,
306
+ users: noPermission,
307
+ user: noPermission,
308
+ unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
309
+ memberRoles: accessEntryRoles("canvas")
310
+ },
311
+ dashboard: {
312
+ // Unproven on staging, as for canvas.
313
+ roles: noPermission,
314
+ users: noPermission,
315
+ user: noPermission,
316
+ unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
317
+ memberRoles: accessEntryRoles("dashboard")
318
+ }
319
+ };
320
+ function accessCapabilitiesOf(type) {
321
+ return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
286
322
  }
287
323
  function dataUrl(args) {
288
324
  const { resource, dataset, operation, path } = args;
@@ -296,10 +332,14 @@ function dataUrl(args) {
296
332
  const base = `/${operation}/${dataset}`;
297
333
  return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
298
334
  }
299
- const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
335
+ const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
300
336
  function page(data) {
301
337
  return { data, nextCursor: null };
302
338
  }
339
+ function usersPage(data) {
340
+ return { ...page(data), totalCount: data.length };
341
+ }
342
+ const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
303
343
  async function servePlatformRequest(ctx, url) {
304
344
  const queryStart = url.indexOf("?"), path = queryStart === -1 ? url : url.slice(0, queryStart), query = new URLSearchParams(queryStart === -1 ? "" : url.slice(queryStart + 1)), request = { method: "GET", path }, access = path.match(ACCESS_PATH);
305
345
  if (access !== null) {
@@ -369,20 +409,41 @@ async function readHistory(args) {
369
409
  };
370
410
  }
371
411
  function serveAccess(args) {
372
- const { ctx, request, type, id, rest, query } = args, found = capabilitiesForAccessType(type);
373
- if (found === void 0) throw invalidAccessType(request, type);
374
- const accessType = found.row.access.type, { unknownId } = found.row.access;
375
- if (unknownId !== void 0 && rest !== "/user-permissions/me/check" && !ctx.exists({ type: found.resourceType, id }))
376
- throw unknownId(request);
377
- const permissions = ctx.actorAccess()?.[accessKey(accessType, id)]?.permissions ?? [];
412
+ const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
413
+ if (row === void 0) throw invalidAccessType(request, type);
414
+ const resource = { type, id }, registry = {
415
+ organizationId: ctx.organizationId,
416
+ resources: ctx.resources
417
+ }, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
418
+ if (rest === "/user-permissions/me/check") {
419
+ const requested = query.getAll("permissions").flatMap((value) => value.split(","));
420
+ return {
421
+ value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
422
+ };
423
+ }
424
+ if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
425
+ throw row.unknown.refusal(request);
426
+ const users = (capability, serve) => serveCapability(capability, request, () => {
427
+ if (row.usersPermission !== void 0 && !permissions.includes(row.usersPermission))
428
+ throw noPermission(request);
429
+ return serve();
430
+ }), user = rest.match(ACCESS_USER_PATH);
431
+ if (user !== null)
432
+ return users(row.user, () => {
433
+ const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
434
+ (candidate) => candidate.sanityUserId === sanityUserId
435
+ );
436
+ if (record === void 0) throw notFound("User")(request);
437
+ return { value: record };
438
+ });
378
439
  switch (rest) {
379
440
  case "/roles":
380
- return serveCapability(found.row.access.roles, request, () => ({
381
- value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
441
+ return serveCapability(row.roles, request, () => ({
442
+ value: page(rolesOn({ resource, identities: ctx.identities() }))
382
443
  }));
383
444
  case "/users":
384
- return serveCapability(found.row.access.users, request, () => ({
385
- value: page(usersOn({ row: found.row, id, identities: ctx.identities() }))
445
+ return users(row.users, () => ({
446
+ value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
386
447
  }));
387
448
  case "/user-permissions/me":
388
449
  return {
@@ -391,58 +452,64 @@ function serveAccess(args) {
391
452
  const dot = permission.lastIndexOf(".");
392
453
  return {
393
454
  type: permission.slice(0, dot),
394
- resourceType: accessType,
455
+ resourceType: resource.type,
395
456
  resourceId: id,
396
457
  action: permission.slice(dot + 1)
397
458
  };
398
459
  })
399
460
  )
400
461
  };
401
- case "/user-permissions/me/check": {
402
- const requested = query.getAll("permissions").flatMap((value) => value.split(","));
403
- return {
404
- value: {
405
- data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)]))
406
- }
407
- };
408
- }
409
462
  default:
410
463
  return;
411
464
  }
412
465
  }
413
466
  function rolesOn(args) {
414
- const byName = /* @__PURE__ */ new Map();
415
- for (const identity of args.identities)
416
- for (const role of args.row.access.memberRoles({ identity, id: args.id }) ?? [])
467
+ const { resource } = args, byName = /* @__PURE__ */ new Map();
468
+ for (const identity of args.identities) {
469
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
470
+ for (const role of roles ?? [])
417
471
  byName.set(role.name, {
418
472
  name: role.name,
419
473
  ...role.title !== void 0 ? { title: role.title } : {},
420
474
  ...role.description !== void 0 ? { description: role.description } : {}
421
475
  });
476
+ }
422
477
  return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
423
478
  }
479
+ function membershipOn(identity, resource) {
480
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
481
+ if (roles !== void 0)
482
+ return {
483
+ resourceType: resource.type,
484
+ resourceId: resource.id,
485
+ roleNames: roles.map((role) => role.name).toSorted()
486
+ };
487
+ }
424
488
  function usersOn(args) {
425
- const { row, id, identities } = args, bySanityUserId = /* @__PURE__ */ new Map();
489
+ const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
426
490
  for (const identity of identities) {
427
- const roles = row.access.memberRoles({ identity, id });
428
- if (roles === void 0) continue;
491
+ const own = membershipOn(identity, resource);
492
+ if (own === void 0) continue;
429
493
  const user = identity.globalUser ?? identity.user, record = {
430
494
  sanityUserId: user.id,
431
495
  profile: {
496
+ id: user.id,
432
497
  displayName: user.name ?? user.id,
433
- ...user.email !== void 0 ? { email: user.email } : {}
498
+ email: user.email ?? "",
499
+ ...UNMODELLED_PROFILE
434
500
  },
435
501
  memberships: [
436
- {
437
- resourceType: row.access.type,
438
- resourceId: id,
439
- roleNames: roles.map((role) => role.name).toSorted()
440
- }
502
+ own,
503
+ // A user is part of a resource only through a role on it.
504
+ ...owned.flatMap((child) => {
505
+ const membership = membershipOn(identity, child);
506
+ return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
507
+ })
441
508
  ]
442
509
  }, existing = bySanityUserId.get(record.sanityUserId);
443
510
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
444
511
  throw fakeError(
445
- `request(): two registered identities resolve to user "${record.sanityUserId}" on ${row.access.type} "${id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
512
+ `request(): two registered identities resolve to user "${record.sanityUserId}" on ${resource.type} "${resource.id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
446
513
  );
447
514
  bySanityUserId.set(record.sanityUserId, record);
448
515
  }
@@ -604,7 +671,7 @@ function makeRequestImpl(ctx) {
604
671
  const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
605
672
  if (served !== void 0) return served.value;
606
673
  throw fakeError(
607
- `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
674
+ `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users[/<id>] | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
608
675
  );
609
676
  };
610
677
  }
@@ -2035,7 +2102,7 @@ function makeHandle(handleArgs) {
2035
2102
  } : void 0,
2036
2103
  actorAccess: () => requireActiveAccess().access,
2037
2104
  identities: () => [...backend.identities.values()],
2038
- exists: (asked) => registry.has(registryKey(asked))
2105
+ resources: () => [...registry.values()].map((entry) => entry.resource)
2039
2106
  }
2040
2107
  })
2041
2108
  } : {},