@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/dist/index.d.cts CHANGED
@@ -1183,8 +1183,8 @@ export declare interface TestClientAccessControl {
1183
1183
  */
1184
1184
  aclPath?: string;
1185
1185
  /**
1186
- * Access API entries, keyed `"<access type>:<id>"` — `"project:<projectId>"`,
1187
- * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1186
+ * Access API entries, keyed `"<access type>:<id>"` — `"organization:<id>"`,
1187
+ * `"project:<projectId>"`, `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1188
1188
  */
1189
1189
  access?: Record<string, ResourceAccess>;
1190
1190
  }
@@ -1225,10 +1225,10 @@ export declare interface TestClientIdentity {
1225
1225
  /** Grants served at the ACL path and enforced on reads/writes. */
1226
1226
  grants: Grant[];
1227
1227
  /**
1228
- * Access API entries, keyed `"<access type>:<id>"` (`"media-library:<id>"`,
1229
- * `"canvas:<id>"`, `"dashboard:<id>"`, `"project:<projectId>"`). On a
1230
- * non-dataset resource, an entry with `roles` makes the identity a member
1231
- * there. Project membership needs no entry: every identity is a member of
1228
+ * Access API entries, keyed `"<access type>:<id>"` (`"organization:<id>"`,
1229
+ * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`,
1230
+ * `"project:<projectId>"`). On a non-project resource, an entry with
1231
+ * `roles` makes the identity a member there. Project membership needs no entry: every identity is a member of
1232
1232
  * every project with its project-scoped roles.
1233
1233
  */
1234
1234
  access?: Record<string, ResourceAccess>;
package/dist/index.d.ts CHANGED
@@ -1183,8 +1183,8 @@ export declare interface TestClientAccessControl {
1183
1183
  */
1184
1184
  aclPath?: string;
1185
1185
  /**
1186
- * Access API entries, keyed `"<access type>:<id>"` — `"project:<projectId>"`,
1187
- * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1186
+ * Access API entries, keyed `"<access type>:<id>"` — `"organization:<id>"`,
1187
+ * `"project:<projectId>"`, `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1188
1188
  */
1189
1189
  access?: Record<string, ResourceAccess>;
1190
1190
  }
@@ -1225,10 +1225,10 @@ export declare interface TestClientIdentity {
1225
1225
  /** Grants served at the ACL path and enforced on reads/writes. */
1226
1226
  grants: Grant[];
1227
1227
  /**
1228
- * Access API entries, keyed `"<access type>:<id>"` (`"media-library:<id>"`,
1229
- * `"canvas:<id>"`, `"dashboard:<id>"`, `"project:<projectId>"`). On a
1230
- * non-dataset resource, an entry with `roles` makes the identity a member
1231
- * there. Project membership needs no entry: every identity is a member of
1228
+ * Access API entries, keyed `"<access type>:<id>"` (`"organization:<id>"`,
1229
+ * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`,
1230
+ * `"project:<projectId>"`). On a non-project resource, an entry with
1231
+ * `roles` makes the identity a member there. Project membership needs no entry: every identity is a member of
1232
1232
  * every project with its project-scoped roles.
1233
1233
  */
1234
1234
  access?: Record<string, ResourceAccess>;
package/dist/index.js CHANGED
@@ -102,7 +102,7 @@ function accessApiError(args) {
102
102
  ...request,
103
103
  statusCode,
104
104
  statusMessage,
105
- body: { statusCode, message }
105
+ body: { statusCode, error: statusMessage, message }
106
106
  });
107
107
  }
108
108
  const noPermission = (request) => accessApiError({
@@ -185,14 +185,7 @@ const RESOURCE_CAPABILITIES = {
185
185
  history: "served",
186
186
  actions: "served",
187
187
  refusedActions: {},
188
- access: {
189
- type: "project",
190
- id: (id) => datasetParts(id).projectId,
191
- roles: "served",
192
- users: "served",
193
- // Every registered identity is a member of every project, with its project roles.
194
- memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
195
- }
188
+ access: { type: "project", id: (id) => datasetParts(id).projectId }
196
189
  },
197
190
  "media-library": {
198
191
  label: (id) => `media library "${id}"`,
@@ -204,15 +197,7 @@ const RESOURCE_CAPABILITIES = {
204
197
  history: "served",
205
198
  actions: "served",
206
199
  refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
207
- access: {
208
- type: "media-library",
209
- id: (id) => id,
210
- // Unproven on staging (no token held roles read): refused like a missing permission.
211
- roles: noPermission,
212
- users: "served",
213
- unknownId: notFound("Media library"),
214
- memberRoles: accessEntryRoles("media-library")
215
- }
200
+ access: { type: "media-library", id: (id) => id }
216
201
  },
217
202
  canvas: {
218
203
  label: (id) => `canvas "${id}"`,
@@ -224,15 +209,7 @@ const RESOURCE_CAPABILITIES = {
224
209
  history: "served",
225
210
  actions: notMounted,
226
211
  refusedActions: {},
227
- access: {
228
- type: "canvas",
229
- id: (id) => id,
230
- // Unproven on staging (no canvas roles/members permission exists to grant).
231
- roles: noPermission,
232
- users: noPermission,
233
- unknownId: notFound("Canvas"),
234
- memberRoles: accessEntryRoles("canvas")
235
- }
212
+ access: { type: "canvas", id: (id) => id }
236
213
  },
237
214
  dashboard: {
238
215
  label: (id) => `dashboard "${id}"`,
@@ -244,19 +221,11 @@ const RESOURCE_CAPABILITIES = {
244
221
  history: notMounted,
245
222
  actions: notMounted,
246
223
  refusedActions: {},
247
- access: {
248
- type: "dashboard",
249
- id: (id) => id,
250
- // Unproven on staging, as for canvas.
251
- roles: noPermission,
252
- users: noPermission,
253
- unknownId: notFound("Dashboard"),
254
- memberRoles: accessEntryRoles("dashboard")
255
- }
224
+ access: { type: "dashboard", id: (id) => id }
256
225
  }
257
226
  };
258
227
  function capabilitiesOf(type) {
259
- const row = RESOURCE_CAPABILITIES[type];
228
+ const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
260
229
  if (row === void 0)
261
230
  throw fakeError(
262
231
  `Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
@@ -278,9 +247,76 @@ function matchResourcePath(path) {
278
247
  return { resource: { type, id: match.id }, rest: match.rest };
279
248
  }
280
249
  }
281
- function capabilitiesForAccessType(type) {
282
- for (const [resourceType, row] of Object.entries(RESOURCE_CAPABILITIES))
283
- if (row.access.type === type) return { resourceType, row };
250
+ function accessResourceOf(resource) {
251
+ const { access } = capabilitiesOf(resource.type);
252
+ return { type: access.type, id: access.id(resource.id) };
253
+ }
254
+ function holdsResource(type) {
255
+ return (registry, id) => registry.resources().some((resource) => {
256
+ const access = accessResourceOf(resource);
257
+ return access.type === type && access.id === id;
258
+ });
259
+ }
260
+ const ACCESS_CAPABILITIES = {
261
+ organization: {
262
+ // Proven on staging, but outside what the fake serves: refused like a missing permission.
263
+ roles: noPermission,
264
+ users: "served",
265
+ user: "served",
266
+ // Granted by the built-in organization `member` role.
267
+ usersPermission: "sanity.organization.members.read",
268
+ unknown: {
269
+ holds: (registry, id) => id === registry.organizationId,
270
+ refusal: notFound("Organization")
271
+ },
272
+ memberRoles: accessEntryRoles("organization"),
273
+ // Every resource of the family belongs to its organization (see `organizationId`).
274
+ owns: (registry) => {
275
+ const byKey = /* @__PURE__ */ new Map();
276
+ for (const resource of registry.resources()) {
277
+ const access = accessResourceOf(resource);
278
+ byKey.set(accessKey(access.type, access.id), access);
279
+ }
280
+ return [...byKey.values()];
281
+ }
282
+ },
283
+ project: {
284
+ roles: "served",
285
+ users: "served",
286
+ // Single-user reads are served only where the fake checks members read (the
287
+ // organization); elsewhere they refuse rather than answer unchecked.
288
+ user: noPermission,
289
+ // Every registered identity is a member of every project, with its project roles.
290
+ memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
291
+ },
292
+ "media-library": {
293
+ // Unproven on staging (no token held roles read): refused like a missing permission.
294
+ roles: noPermission,
295
+ users: "served",
296
+ // Unproven on staging (only the listing was probed); refused as on `project`.
297
+ user: noPermission,
298
+ unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
299
+ memberRoles: accessEntryRoles("media-library")
300
+ },
301
+ canvas: {
302
+ // Unproven on staging (no canvas roles/members permission exists to grant).
303
+ roles: noPermission,
304
+ users: noPermission,
305
+ user: noPermission,
306
+ unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
307
+ memberRoles: accessEntryRoles("canvas")
308
+ },
309
+ dashboard: {
310
+ // Unproven on staging, as for canvas.
311
+ roles: noPermission,
312
+ users: noPermission,
313
+ user: noPermission,
314
+ unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
315
+ memberRoles: accessEntryRoles("dashboard")
316
+ }
317
+ };
318
+ function accessCapabilitiesOf(type) {
319
+ return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
284
320
  }
285
321
  function dataUrl(args) {
286
322
  const { resource, dataset, operation, path } = args;
@@ -294,10 +330,14 @@ function dataUrl(args) {
294
330
  const base = `/${operation}/${dataset}`;
295
331
  return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
296
332
  }
297
- const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
333
+ const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
298
334
  function page(data) {
299
335
  return { data, nextCursor: null };
300
336
  }
337
+ function usersPage(data) {
338
+ return { ...page(data), totalCount: data.length };
339
+ }
340
+ const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
301
341
  async function servePlatformRequest(ctx, url) {
302
342
  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);
303
343
  if (access !== null) {
@@ -367,20 +407,41 @@ async function readHistory(args) {
367
407
  };
368
408
  }
369
409
  function serveAccess(args) {
370
- const { ctx, request, type, id, rest, query } = args, found = capabilitiesForAccessType(type);
371
- if (found === void 0) throw invalidAccessType(request, type);
372
- const accessType = found.row.access.type, { unknownId } = found.row.access;
373
- if (unknownId !== void 0 && rest !== "/user-permissions/me/check" && !ctx.exists({ type: found.resourceType, id }))
374
- throw unknownId(request);
375
- const permissions = ctx.actorAccess()?.[accessKey(accessType, id)]?.permissions ?? [];
410
+ const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
411
+ if (row === void 0) throw invalidAccessType(request, type);
412
+ const resource = { type, id }, registry = {
413
+ organizationId: ctx.organizationId,
414
+ resources: ctx.resources
415
+ }, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
416
+ if (rest === "/user-permissions/me/check") {
417
+ const requested = query.getAll("permissions").flatMap((value) => value.split(","));
418
+ return {
419
+ value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
420
+ };
421
+ }
422
+ if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
423
+ throw row.unknown.refusal(request);
424
+ const users = (capability, serve) => serveCapability(capability, request, () => {
425
+ if (row.usersPermission !== void 0 && !permissions.includes(row.usersPermission))
426
+ throw noPermission(request);
427
+ return serve();
428
+ }), user = rest.match(ACCESS_USER_PATH);
429
+ if (user !== null)
430
+ return users(row.user, () => {
431
+ const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
432
+ (candidate) => candidate.sanityUserId === sanityUserId
433
+ );
434
+ if (record === void 0) throw notFound("User")(request);
435
+ return { value: record };
436
+ });
376
437
  switch (rest) {
377
438
  case "/roles":
378
- return serveCapability(found.row.access.roles, request, () => ({
379
- value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
439
+ return serveCapability(row.roles, request, () => ({
440
+ value: page(rolesOn({ resource, identities: ctx.identities() }))
380
441
  }));
381
442
  case "/users":
382
- return serveCapability(found.row.access.users, request, () => ({
383
- value: page(usersOn({ row: found.row, id, identities: ctx.identities() }))
443
+ return users(row.users, () => ({
444
+ value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
384
445
  }));
385
446
  case "/user-permissions/me":
386
447
  return {
@@ -389,58 +450,64 @@ function serveAccess(args) {
389
450
  const dot = permission.lastIndexOf(".");
390
451
  return {
391
452
  type: permission.slice(0, dot),
392
- resourceType: accessType,
453
+ resourceType: resource.type,
393
454
  resourceId: id,
394
455
  action: permission.slice(dot + 1)
395
456
  };
396
457
  })
397
458
  )
398
459
  };
399
- case "/user-permissions/me/check": {
400
- const requested = query.getAll("permissions").flatMap((value) => value.split(","));
401
- return {
402
- value: {
403
- data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)]))
404
- }
405
- };
406
- }
407
460
  default:
408
461
  return;
409
462
  }
410
463
  }
411
464
  function rolesOn(args) {
412
- const byName = /* @__PURE__ */ new Map();
413
- for (const identity of args.identities)
414
- for (const role of args.row.access.memberRoles({ identity, id: args.id }) ?? [])
465
+ const { resource } = args, byName = /* @__PURE__ */ new Map();
466
+ for (const identity of args.identities) {
467
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
468
+ for (const role of roles ?? [])
415
469
  byName.set(role.name, {
416
470
  name: role.name,
417
471
  ...role.title !== void 0 ? { title: role.title } : {},
418
472
  ...role.description !== void 0 ? { description: role.description } : {}
419
473
  });
474
+ }
420
475
  return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
421
476
  }
477
+ function membershipOn(identity, resource) {
478
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
479
+ if (roles !== void 0)
480
+ return {
481
+ resourceType: resource.type,
482
+ resourceId: resource.id,
483
+ roleNames: roles.map((role) => role.name).toSorted()
484
+ };
485
+ }
422
486
  function usersOn(args) {
423
- const { row, id, identities } = args, bySanityUserId = /* @__PURE__ */ new Map();
487
+ const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
424
488
  for (const identity of identities) {
425
- const roles = row.access.memberRoles({ identity, id });
426
- if (roles === void 0) continue;
489
+ const own = membershipOn(identity, resource);
490
+ if (own === void 0) continue;
427
491
  const user = identity.globalUser ?? identity.user, record = {
428
492
  sanityUserId: user.id,
429
493
  profile: {
494
+ id: user.id,
430
495
  displayName: user.name ?? user.id,
431
- ...user.email !== void 0 ? { email: user.email } : {}
496
+ email: user.email ?? "",
497
+ ...UNMODELLED_PROFILE
432
498
  },
433
499
  memberships: [
434
- {
435
- resourceType: row.access.type,
436
- resourceId: id,
437
- roleNames: roles.map((role) => role.name).toSorted()
438
- }
500
+ own,
501
+ // A user is part of a resource only through a role on it.
502
+ ...owned.flatMap((child) => {
503
+ const membership = membershipOn(identity, child);
504
+ return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
505
+ })
439
506
  ]
440
507
  }, existing = bySanityUserId.get(record.sanityUserId);
441
508
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
442
509
  throw fakeError(
443
- `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.`
510
+ `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.`
444
511
  );
445
512
  bySanityUserId.set(record.sanityUserId, record);
446
513
  }
@@ -602,7 +669,7 @@ function makeRequestImpl(ctx) {
602
669
  const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
603
670
  if (served !== void 0) return served.value;
604
671
  throw fakeError(
605
- `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.`
672
+ `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.`
606
673
  );
607
674
  };
608
675
  }
@@ -2033,7 +2100,7 @@ function makeHandle(handleArgs) {
2033
2100
  } : void 0,
2034
2101
  actorAccess: () => requireActiveAccess().access,
2035
2102
  identities: () => [...backend.identities.values()],
2036
- exists: (asked) => registry.has(registryKey(asked))
2103
+ resources: () => [...registry.values()].map((entry) => entry.resource)
2037
2104
  }
2038
2105
  })
2039
2106
  } : {},