@sanity-labs/client-fake-for-test 0.14.0 → 0.15.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 +4 -1
- package/dist/index.cjs +76 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +76 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,11 +199,14 @@ await canvas.releases.create({ releaseId: "r1" }); // 404 Cannot POST /canvases/
|
|
|
199
199
|
| Access API `…/user-permissions/me`, `…/me/check` | ✅ | ✅ | ✅ | ✅ |
|
|
200
200
|
| Access API `…/users` | ✅ | ✅ | 403 | 403 |
|
|
201
201
|
| Access API `…/users/<sanityUserId>` (served on `organization`, below) | 403 | 403 | 403 | 403 |
|
|
202
|
-
| Access API `…/roles
|
|
202
|
+
| Access API `…/roles`, `…/roles/<roleName>` | ✅ | ✅ with `sanity.media-library.roles.read`, else 403 | 403 | 403 |
|
|
203
203
|
| Org lookup | `/projects/<projectId>` | `GET /media-libraries/<id>` | `GET /canvases/<id>` | `GET /dashboards/<id>` |
|
|
204
204
|
|
|
205
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
|
+
- **Members.** Every member listing (`/projects/<projectId>`, `…/users`, `…/roles`) sees the registered identities plus, on a handle created with inline `accessControl`, that handle's own actor. Inline config belongs to its handle: a token sibling does not see it.
|
|
207
|
+
- **Roles.** `…/roles` answers `{ data: [role], nextCursor: null }` and `…/roles/<roleName>` one role (404 `Role not found` for a name no member holds). A role is `{ name, title, description?, resourceType, resourceId, appliesToUsers, appliesToRobots, permissions }`, collected from the roles the members hold on the resource. The fake does not model role definitions, so `title` is the name where the seed gives none, both `appliesTo…` flags are `true` and `permissions` is `[]`. A media library serves its roles only to an actor whose `"media-library:<id>"` entry holds `sanity.media-library.roles.read` in `permissions`, else 403, as the reference requires.
|
|
206
208
|
- **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.
|
|
209
|
+
- **User attributes.** `GET /organizations/<organizationId>/users/me/attributes` answers the caller's own attributes there: `{ sanityUserId, organizationId, attributes: [{ type, key, values: { saml?, sanity? }, activeSource, activeValue }] }`, ordered by key. Seed them as `attributes: [{ key, type, values }]` on the identity's `"organization:<id>"` entry; the Sanity value takes precedence over the SAML one for `activeSource` and `activeValue`, as on the platform. No permission is needed. `limit` (default 50) and `cursor` (the last key of the previous page) page the list. A member with no seeded attributes gets `attributes: []`; a caller without roles in the organization gets 404 `User not found`, and another organization id 404 `Organization not found`.
|
|
207
210
|
- **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.
|
|
208
211
|
- **Org lookup.** `createTestClient({ organizationId })` names the organization every resource of the client belongs to.
|
|
209
212
|
- **Identity.** `@sanity/client` sends a `resource` client's requests to the global host, so `/users/me` answers `globalUser`.
|
package/dist/index.cjs
CHANGED
|
@@ -292,8 +292,9 @@ const ACCESS_CAPABILITIES = {
|
|
|
292
292
|
memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
|
|
293
293
|
},
|
|
294
294
|
"media-library": {
|
|
295
|
-
//
|
|
296
|
-
roles:
|
|
295
|
+
// Staging refuses it only because no probe token held roles read.
|
|
296
|
+
roles: "served",
|
|
297
|
+
rolesPermission: "sanity.media-library.roles.read",
|
|
297
298
|
users: "served",
|
|
298
299
|
// Unproven on staging (only the listing was probed); refused as on `project`.
|
|
299
300
|
user: noPermission,
|
|
@@ -332,7 +333,7 @@ function dataUrl(args) {
|
|
|
332
333
|
const base = `/${operation}/${dataset}`;
|
|
333
334
|
return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
|
|
334
335
|
}
|
|
335
|
-
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
336
|
+
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, ACCESS_ROLE_PATH = /^\/roles\/([^/]+)$/, MY_ATTRIBUTES_PATH = /^\/organizations\/([^/]+)\/users\/me\/attributes$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
336
337
|
function page(data) {
|
|
337
338
|
return { data, nextCursor: null };
|
|
338
339
|
}
|
|
@@ -341,7 +342,12 @@ function usersPage(data) {
|
|
|
341
342
|
}
|
|
342
343
|
const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
|
|
343
344
|
async function servePlatformRequest(ctx, url) {
|
|
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 },
|
|
345
|
+
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 }, myAttributes = path.match(MY_ATTRIBUTES_PATH);
|
|
346
|
+
if (myAttributes !== null) {
|
|
347
|
+
const organizationId = decodeURIComponent(myAttributes[1]);
|
|
348
|
+
return serveMyAttributes({ ctx, request, organizationId, query });
|
|
349
|
+
}
|
|
350
|
+
const access = path.match(ACCESS_PATH);
|
|
345
351
|
if (access !== null) {
|
|
346
352
|
const [, type, id, rest2] = access;
|
|
347
353
|
return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
|
|
@@ -423,11 +429,11 @@ function serveAccess(args) {
|
|
|
423
429
|
}
|
|
424
430
|
if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
|
|
425
431
|
throw row.unknown.refusal(request);
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
432
|
+
const gated = (capability, permission, serve) => serveCapability(capability, request, () => {
|
|
433
|
+
if (permission !== void 0 && !permissions.includes(permission))
|
|
428
434
|
throw noPermission(request);
|
|
429
435
|
return serve();
|
|
430
|
-
}), user = rest.match(ACCESS_USER_PATH);
|
|
436
|
+
}), users = (capability, serve) => gated(capability, row.usersPermission, serve), roles = () => rolesOn({ resource, identities: ctx.identities() }), user = rest.match(ACCESS_USER_PATH);
|
|
431
437
|
if (user !== null)
|
|
432
438
|
return users(row.user, () => {
|
|
433
439
|
const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
|
|
@@ -436,11 +442,16 @@ function serveAccess(args) {
|
|
|
436
442
|
if (record === void 0) throw notFound("User")(request);
|
|
437
443
|
return { value: record };
|
|
438
444
|
});
|
|
445
|
+
const role = rest.match(ACCESS_ROLE_PATH);
|
|
446
|
+
if (role !== null)
|
|
447
|
+
return gated(row.roles, row.rolesPermission, () => {
|
|
448
|
+
const name = decodeURIComponent(role[1]), record = roles().find((candidate) => candidate.name === name);
|
|
449
|
+
if (record === void 0) throw notFound("Role")(request);
|
|
450
|
+
return { value: record };
|
|
451
|
+
});
|
|
439
452
|
switch (rest) {
|
|
440
453
|
case "/roles":
|
|
441
|
-
return
|
|
442
|
-
value: page(rolesOn({ resource, identities: ctx.identities() }))
|
|
443
|
-
}));
|
|
454
|
+
return gated(row.roles, row.rolesPermission, () => ({ value: page(roles()) }));
|
|
444
455
|
case "/users":
|
|
445
456
|
return users(row.users, () => ({
|
|
446
457
|
value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
|
|
@@ -463,6 +474,7 @@ function serveAccess(args) {
|
|
|
463
474
|
return;
|
|
464
475
|
}
|
|
465
476
|
}
|
|
477
|
+
const UNMODELLED_ROLE = { appliesToUsers: !0, appliesToRobots: !0 };
|
|
466
478
|
function rolesOn(args) {
|
|
467
479
|
const { resource } = args, byName = /* @__PURE__ */ new Map();
|
|
468
480
|
for (const identity of args.identities) {
|
|
@@ -470,8 +482,12 @@ function rolesOn(args) {
|
|
|
470
482
|
for (const role of roles ?? [])
|
|
471
483
|
byName.set(role.name, {
|
|
472
484
|
name: role.name,
|
|
473
|
-
|
|
474
|
-
...role.description !== void 0 ? { description: role.description } : {}
|
|
485
|
+
title: role.title ?? role.name,
|
|
486
|
+
...role.description !== void 0 ? { description: role.description } : {},
|
|
487
|
+
resourceType: resource.type,
|
|
488
|
+
resourceId: resource.id,
|
|
489
|
+
...UNMODELLED_ROLE,
|
|
490
|
+
permissions: []
|
|
475
491
|
});
|
|
476
492
|
}
|
|
477
493
|
return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
@@ -509,12 +525,40 @@ function usersOn(args) {
|
|
|
509
525
|
}, existing = bySanityUserId.get(record.sanityUserId);
|
|
510
526
|
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
511
527
|
throw fakeError(
|
|
512
|
-
`request(): two registered
|
|
528
|
+
`request(): two identities (registered, or the handle's inline accessControl) resolve to user "${record.sanityUserId}" on ${resource.type} "${resource.id}" with different records. Fix the identities so duplicate ids carry identical records.`
|
|
513
529
|
);
|
|
514
530
|
bySanityUserId.set(record.sanityUserId, record);
|
|
515
531
|
}
|
|
516
532
|
return [...bySanityUserId.values()];
|
|
517
533
|
}
|
|
534
|
+
const DEFAULT_ATTRIBUTES_LIMIT = 50;
|
|
535
|
+
function serveMyAttributes(args) {
|
|
536
|
+
const { ctx, request, organizationId, query } = args, organization = ACCESS_CAPABILITIES.organization, registry = { organizationId: ctx.organizationId, resources: ctx.resources };
|
|
537
|
+
if (organization.unknown !== void 0 && !organization.unknown.holds(registry, organizationId))
|
|
538
|
+
throw organization.unknown.refusal(request);
|
|
539
|
+
const entry = ctx.actorAccess()?.[accessKey("organization", organizationId)];
|
|
540
|
+
if (entry?.roles === void 0) throw notFound("User")(request);
|
|
541
|
+
const limit = attributesLimit(query), cursor = query.get("cursor"), attributes = (entry.attributes ?? []).map(attributeRecord).toSorted((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0).filter((attribute) => cursor === null || attribute.key > cursor).slice(0, limit);
|
|
542
|
+
return { value: { sanityUserId: ctx.actorUser().id, organizationId, attributes } };
|
|
543
|
+
}
|
|
544
|
+
function attributesLimit(query) {
|
|
545
|
+
const raw = query.get("limit");
|
|
546
|
+
if (raw === null) return DEFAULT_ATTRIBUTES_LIMIT;
|
|
547
|
+
const limit = Number(raw);
|
|
548
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
549
|
+
throw fakeError(
|
|
550
|
+
`request(): users/me/attributes got limit=${JSON.stringify(raw)}; the fake serves positive integer limits only (the platform's answer to others is not simulated).`
|
|
551
|
+
);
|
|
552
|
+
return limit;
|
|
553
|
+
}
|
|
554
|
+
function attributeRecord(attribute) {
|
|
555
|
+
const { key, type, values } = attribute, activeSource = values.sanity !== void 0 ? "sanity" : "saml", activeValue = values[activeSource];
|
|
556
|
+
if (activeValue === void 0)
|
|
557
|
+
throw fakeError(
|
|
558
|
+
`request(): user attribute "${key}" is seeded with no value; give it a \`saml\` or \`sanity\` value.`
|
|
559
|
+
);
|
|
560
|
+
return { type, key, values: { ...values }, activeSource, activeValue };
|
|
561
|
+
}
|
|
518
562
|
class WriteAccessDeniedError extends Error {
|
|
519
563
|
documentId;
|
|
520
564
|
permission;
|
|
@@ -586,7 +630,7 @@ function consistentProjectRecords(args) {
|
|
|
586
630
|
const existing = byId.get(record.id);
|
|
587
631
|
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
588
632
|
throw fakeError(
|
|
589
|
-
`request(): two registered
|
|
633
|
+
`request(): two identities (registered, or the handle's inline accessControl) resolve to project user "${record.id}" in project "${args.projectId}" with different records. The project records cannot be served unambiguously \u2014 fix the identities so duplicate ids carry identical records.`
|
|
590
634
|
);
|
|
591
635
|
byId.set(record.id, record);
|
|
592
636
|
}
|
|
@@ -671,7 +715,7 @@ function makeRequestImpl(ctx) {
|
|
|
671
715
|
const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
|
|
672
716
|
if (served !== void 0) return served.value;
|
|
673
717
|
throw fakeError(
|
|
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.`
|
|
718
|
+
`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[/<name>] | users[/<id>] | user-permissions/me[/check]") and the caller's attributes ("/organizations/<id>/users/me/attributes"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
|
|
675
719
|
);
|
|
676
720
|
};
|
|
677
721
|
}
|
|
@@ -1734,7 +1778,17 @@ function makeHandle(handleArgs) {
|
|
|
1734
1778
|
if (access === void 0)
|
|
1735
1779
|
throw fakeError("this handle has no acting identity (no token or accessControl bound).");
|
|
1736
1780
|
return access;
|
|
1737
|
-
},
|
|
1781
|
+
}, members = () => [
|
|
1782
|
+
...backend.identities.values(),
|
|
1783
|
+
...token === void 0 && accessControl !== void 0 ? [
|
|
1784
|
+
{
|
|
1785
|
+
user: accessControl.currentUser,
|
|
1786
|
+
...accessControl.globalUser !== void 0 ? { globalUser: accessControl.globalUser } : {},
|
|
1787
|
+
grants: accessControl.grants,
|
|
1788
|
+
...accessControl.access !== void 0 ? { access: accessControl.access } : {}
|
|
1789
|
+
}
|
|
1790
|
+
] : []
|
|
1791
|
+
], capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
|
|
1738
1792
|
const access = activeAccess();
|
|
1739
1793
|
if (access !== void 0)
|
|
1740
1794
|
return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
|
|
@@ -2090,7 +2144,7 @@ function makeHandle(handleArgs) {
|
|
|
2090
2144
|
// client to the global API host.
|
|
2091
2145
|
useProjectHostname: useProjectHostname && resource === void 0,
|
|
2092
2146
|
organizationId: backend.organizationId,
|
|
2093
|
-
listIdentities:
|
|
2147
|
+
listIdentities: members,
|
|
2094
2148
|
platform: {
|
|
2095
2149
|
projectId,
|
|
2096
2150
|
organizationId: backend.organizationId,
|
|
@@ -2100,8 +2154,12 @@ function makeHandle(handleArgs) {
|
|
|
2100
2154
|
history,
|
|
2101
2155
|
canRead
|
|
2102
2156
|
} : void 0,
|
|
2157
|
+
actorUser: () => {
|
|
2158
|
+
const access = requireActiveAccess();
|
|
2159
|
+
return access.globalUser ?? access.currentUser;
|
|
2160
|
+
},
|
|
2103
2161
|
actorAccess: () => requireActiveAccess().access,
|
|
2104
|
-
identities:
|
|
2162
|
+
identities: members,
|
|
2105
2163
|
resources: () => [...registry.values()].map((entry) => entry.resource)
|
|
2106
2164
|
}
|
|
2107
2165
|
})
|