@sanity-labs/client-fake-for-test 0.11.0 → 0.12.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
@@ -98,9 +98,9 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
98
98
 
99
99
  ### Gating writes — `mutationGuard`
100
100
 
101
- `createTestClient({ mutationGuard })` installs a pluggable write interceptor. It runs on every write through the document mutation API — `create` / `createOrReplace` / `createIfNotExists` / `patch` / `delete` and the equivalent `transaction()` / `mutate()` operations — _before_ the store changes, and **throws to reject** the write (the throw propagates out of the client call, like a real lake rejection). The release/version action API (`action`, `releases.*`, `createVersion`) is **not** intercepted.
101
+ `createTestClient({ mutationGuard })` installs a pluggable write interceptor. It runs once for each document's net change in a commit through the document mutation API — `create` / `createOrReplace` / `createIfNotExists` / `patch` / `delete` and the equivalent `transaction()` / `mutate()` operations. Document publish and unpublish actions are expanded into their published and draft mutations. Release and version actions (`releases.*`, `createVersion`, and `unpublishVersion`) are not intercepted.
102
102
 
103
- The client owns the _seam_; the caller owns the _policy_. This is the hook a workflow test bench uses to enforce Content Lake **mutation guards** at the write path without re-implementing guard evaluation. Each call receives the before/after image (`before` is `null` on create, `after` is `null` on delete), the `action`, and the acting `identity`.
103
+ The client owns the _seam_; the caller owns the _policy_. This is the hook a workflow test bench uses to enforce Content Lake **mutation guards** at the write path without re-implementing guard evaluation. Each call receives the original/final image (`before` is `null` on create, `after` is `null` on delete), the `action`, and the acting `identity`. Return every denying guard as `{ guardID, name? }`; the fake evaluates every net document mutation, aggregates the returned denials, deduplicates them by `guardID` in first-observed order, rolls back the commit, and throws a 403-shaped `GuardDeniedError` with `type: "guardDeniedError"` and `denials`. Other thrown callback errors propagate unchanged.
104
104
 
105
105
  ```ts
106
106
  const client = createTestClient({
@@ -108,13 +108,16 @@ const client = createTestClient({
108
108
  mutationGuard: ({ action, before, after }) => {
109
109
  // Freeze `body` once an article is locked.
110
110
  if (action === "update" && before?.locked && before.body !== after?.body) {
111
- throw new Error("body is locked");
111
+ return [{ guardID: "_.guards.article-body", name: "Locked article body" }];
112
112
  }
113
113
  },
114
114
  });
115
115
 
116
116
  await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
117
- await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
117
+ await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toMatchObject({
118
+ statusCode: 403,
119
+ type: "guardDeniedError",
120
+ });
118
121
  ```
119
122
 
120
123
  > [!NOTE]
@@ -140,6 +143,10 @@ const bob = alice.withConfig({ token: "t-bob" });
140
143
 
141
144
  Register more identities after creation with `client.addIdentity(token, { user, grants })` — an upsert into the family-shared registry; already-bound siblings resolve lazily and pick the change up immediately. For a one-off identity without a registry, `withConfig({ accessControl })` still works (mutually exclusive with `token` in the same call).
142
145
 
146
+ #### System-document grants
147
+
148
+ User-issued writes to system document IDs beginning with `_.` require the `manage` permission. Ordinary `create` and `update` grants do not authorize these writes; add `manage` to identities whose tests intentionally create, replace, patch, delete, transact, mutate, publish, or unpublish system documents. The fake's internal release/version bookkeeping remains outside access-control interception.
149
+
143
150
  #### Dual-namespace identities: global vs project user ids
144
151
 
145
152
  In production the same token resolves to a **different project-scoped user id in every project** (what lake `identity()` returns) and **one account-global id** (what the global API host's `/users/me` returns); robot tokens carry a single id on every host. An identity can model that split with `globalUser` (served by `/users/me` on a `useProjectHostname: false` handle) and `usersByProject` (per-project records; the handle's project falls back to `user`):
package/dist/index.cjs CHANGED
@@ -153,6 +153,48 @@ function makeRequestImpl(ctx) {
153
153
  );
154
154
  };
155
155
  }
156
+ class GuardDeniedError extends Error {
157
+ statusCode = 403;
158
+ type = "guardDeniedError";
159
+ denials;
160
+ details;
161
+ response;
162
+ constructor(denials) {
163
+ const unique = uniqueDenials(denials);
164
+ super(`Mutation denied by guard(s) [${unique.map((denial) => denial.guardID).join(", ")}]`), this.name = "GuardDeniedError", this.denials = unique, this.details = { type: this.type, denials: unique }, this.response = {
165
+ body: { error: this.details },
166
+ url: "memory://client-fake-for-test/data/mutate",
167
+ method: "POST",
168
+ headers: {},
169
+ statusCode: this.statusCode,
170
+ statusMessage: "Forbidden"
171
+ };
172
+ }
173
+ }
174
+ function uniqueDenials(denials) {
175
+ const seen = /* @__PURE__ */ new Set();
176
+ return denials.filter((denial) => seen.has(denial.guardID) ? !1 : (seen.add(denial.guardID), !0));
177
+ }
178
+ function mutationContext(images) {
179
+ const { before, after } = images;
180
+ if (before !== after)
181
+ return before === void 0 ? { action: "create", before: null, after: after ?? null } : after === void 0 ? { action: "delete", before, after: null } : { action: "update", before, after };
182
+ }
183
+ function mutationContexts(images) {
184
+ return [...images].flatMap((image) => {
185
+ const context = mutationContext(image);
186
+ return context === void 0 ? [] : [context];
187
+ });
188
+ }
189
+ async function enforceMutationGuards(guard, contexts) {
190
+ if (guard === void 0) return;
191
+ const denials = [];
192
+ for (const context of contexts) {
193
+ const denied = await guard(context);
194
+ denied !== void 0 && denials.push(...denied);
195
+ }
196
+ if (denials.length > 0) throw new GuardDeniedError(denials);
197
+ }
156
198
  function describeType(value) {
157
199
  return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
158
200
  }
@@ -686,7 +728,7 @@ async function applyAction(store, action, hooks) {
686
728
  const published = store.get(action.publishedId);
687
729
  requireRevisionMatch(action.draftId, draft._rev, action.ifDraftRevisionId), requireRevisionMatch(action.publishedId, published?._rev, action.ifPublishedRevisionId);
688
730
  const promoted = promote({ ...draft, _id: action.publishedId });
689
- return published !== void 0 && (promoted._createdAt = published._createdAt), await hooks?.gate?.(promoted, published === void 0 ? "create" : "update"), await hooks?.guard?.("publish", published ?? null, promoted), store.put(promoted), store.remove(action.draftId), { transactionId: cryptoRandomKey() };
731
+ return published !== void 0 && (promoted._createdAt = published._createdAt), await hooks?.gate?.(promoted, published === void 0 ? "create" : "update"), store.put(promoted), store.remove(action.draftId), { transactionId: cryptoRandomKey() };
690
732
  }
691
733
  case "sanity.action.document.unpublish": {
692
734
  requireDraftOf(action);
@@ -695,7 +737,7 @@ async function applyAction(store, action, hooks) {
695
737
  throw fakeError(
696
738
  `Cannot unpublish "${action.publishedId}": no published document with that _id exists. document.unpublish retracts a live published document into "${action.draftId}".`
697
739
  );
698
- if (await hooks?.gate?.(published, "update"), await hooks?.guard?.("unpublish", published, null), store.get(action.draftId) === void 0) {
740
+ if (await hooks?.gate?.(published, "update"), store.get(action.draftId) === void 0) {
699
741
  const draft = promote({ ...published, _id: action.draftId });
700
742
  draft._createdAt = published._createdAt, store.put(draft);
701
743
  }
@@ -716,7 +758,7 @@ function requireRevisionMatch(documentId, actualRev, expectedRev) {
716
758
  );
717
759
  }
718
760
  async function applyActions(store, actions, hooks) {
719
- const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), remember = (id) => {
761
+ const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), guardedIds = /* @__PURE__ */ new Set(), remember = (id) => {
720
762
  snapshot.has(id) || snapshot.set(id, store.get(id));
721
763
  };
722
764
  try {
@@ -752,13 +794,18 @@ async function applyActions(store, actions, hooks) {
752
794
  remember(a.versionId);
753
795
  break;
754
796
  case "sanity.action.document.publish":
797
+ remember(a.draftId), remember(a.publishedId), guardedIds.add(a.publishedId), guardedIds.add(a.draftId);
798
+ break;
755
799
  case "sanity.action.document.unpublish":
756
- remember(a.draftId), remember(a.publishedId);
800
+ remember(a.draftId), remember(a.publishedId), guardedIds.add(a.draftId), guardedIds.add(a.publishedId);
757
801
  break;
758
802
  }
759
803
  last = await applyAction(store, a, hooks);
760
804
  }
761
- return last;
805
+ const contexts = mutationContexts(
806
+ [...guardedIds].map((id) => ({ before: snapshot.get(id), after: store.get(id) }))
807
+ );
808
+ return await hooks?.guard?.(contexts), last;
762
809
  } catch (err) {
763
810
  for (const [id, doc] of snapshot)
764
811
  doc === void 0 ? store.remove(id) : store.put(doc);
@@ -1189,11 +1236,17 @@ function makeHandle(handleArgs) {
1189
1236
  access !== void 0 && await enforceWriteAccess({
1190
1237
  document,
1191
1238
  grants: access.grants,
1192
- permission,
1239
+ permission: document._id !== void 0 && isSystemId(document._id) ? "manage" : permission,
1193
1240
  userId: access.currentUser.id
1194
1241
  });
1195
- }, guardGate = async (action, before, after) => {
1196
- mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: activeAccess()?.currentUser.id });
1242
+ }, guardGate = async (contexts) => {
1243
+ const identity = activeAccess()?.currentUser.id;
1244
+ await enforceMutationGuards(
1245
+ mutationGuard,
1246
+ contexts.map((context) => ({ ...context, identity }))
1247
+ );
1248
+ }, guardOne = async (action, before, after) => {
1249
+ await guardGate([{ action, before, after }]);
1197
1250
  }, canRead = async (document) => {
1198
1251
  const access = activeAccess();
1199
1252
  return access === void 0 ? !0 : canAccess({
@@ -1202,7 +1255,7 @@ function makeHandle(handleArgs) {
1202
1255
  permission: "read",
1203
1256
  userId: access.currentUser.id
1204
1257
  });
1205
- }, applyPatch = async (documentId, ops, ifRev) => {
1258
+ }, applyPatch = async (documentId, ops, ifRev, options = {}) => {
1206
1259
  const existing = store.get(documentId);
1207
1260
  if (existing === void 0)
1208
1261
  throw fakeError(
@@ -1214,7 +1267,7 @@ function makeHandle(handleArgs) {
1214
1267
  _rev: cryptoRandomKey(),
1215
1268
  _updatedAt: now
1216
1269
  };
1217
- return await guardGate("update", existing, next), store.put(next), next;
1270
+ return options.evaluateGuard !== !1 && await guardOne("update", existing, next), store.put(next), next;
1218
1271
  }, applyTransaction = async (mutations, txnOptions) => {
1219
1272
  const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
1220
1273
  snapshot.has(id) || snapshot.set(id, store.get(id));
@@ -1229,17 +1282,13 @@ function makeHandle(handleArgs) {
1229
1282
  throw fakeError(
1230
1283
  `create() failed: a document with _id "${promoted._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1231
1284
  );
1232
- await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1285
+ await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1233
1286
  break;
1234
1287
  }
1235
1288
  case "createOrReplace": {
1236
1289
  remember(mut.doc._id);
1237
1290
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
1238
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
1239
- existing !== void 0 ? "update" : "create",
1240
- existing ?? null,
1241
- promoted
1242
- );
1291
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1243
1292
  const wasPresent = existing !== void 0;
1244
1293
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
1245
1294
  break;
@@ -1247,21 +1296,25 @@ function makeHandle(handleArgs) {
1247
1296
  case "createIfNotExists": {
1248
1297
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
1249
1298
  const promoted = promote2(mut.doc);
1250
- await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1299
+ await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1251
1300
  }
1252
1301
  break;
1253
1302
  }
1254
1303
  case "delete": {
1255
1304
  remember(mut.id);
1256
1305
  const existing = store.get(mut.id);
1257
- existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1306
+ existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1258
1307
  break;
1259
1308
  }
1260
1309
  case "patch": {
1261
- remember(mut.documentId), await applyPatch(mut.documentId, mut.ops, mut.ifRev), touchedIds.push(mut.documentId), operations.push({ id: mut.documentId, operation: "update" });
1310
+ remember(mut.documentId), await applyPatch(mut.documentId, mut.ops, mut.ifRev, { evaluateGuard: !1 }), touchedIds.push(mut.documentId), operations.push({ id: mut.documentId, operation: "update" });
1262
1311
  break;
1263
1312
  }
1264
1313
  }
1314
+ const contexts = mutationContexts(
1315
+ [...snapshot].map(([id, before]) => ({ before, after: store.get(id) }))
1316
+ );
1317
+ await guardGate(contexts);
1265
1318
  } catch (error) {
1266
1319
  for (const [id, doc] of snapshot)
1267
1320
  doc === void 0 ? store.remove(id) : store.put(doc);
@@ -1326,7 +1379,7 @@ function makeHandle(handleArgs) {
1326
1379
  `create() failed: a document with _id "${doc._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1327
1380
  );
1328
1381
  const promoted = promote2(doc);
1329
- await gate(promoted, "create"), await guardGate("create", null, promoted);
1382
+ await gate(promoted, "create"), await guardOne("create", null, promoted);
1330
1383
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1331
1384
  return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }], opts?.tag)), writeResult(
1332
1385
  [promoted],
@@ -1338,7 +1391,7 @@ function makeHandle(handleArgs) {
1338
1391
  createOrReplace: (doc, opts) => serializeWrite(backend, async () => {
1339
1392
  checkRevision(doc._id, opts?.ifRevisionId);
1340
1393
  const promoted = promote2(doc), existing = store.get(doc._id);
1341
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1394
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardOne(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1342
1395
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1343
1396
  opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }], opts?.tag));
1344
1397
  const operation = existing !== void 0 ? "update" : "create";
@@ -1360,7 +1413,7 @@ function makeHandle(handleArgs) {
1360
1413
  opts
1361
1414
  );
1362
1415
  const promoted = promote2(doc);
1363
- return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }], opts?.tag)), writeResult(
1416
+ return await gate(promoted, "create"), await guardOne("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }], opts?.tag)), writeResult(
1364
1417
  [promoted],
1365
1418
  [{ id: promoted._id, operation: "create" }],
1366
1419
  transactionId,
@@ -1370,7 +1423,7 @@ function makeHandle(handleArgs) {
1370
1423
  delete: (id, opts) => serializeWrite(backend, async () => {
1371
1424
  checkRevision(id, opts?.ifRevisionId);
1372
1425
  const existing = store.get(id);
1373
- existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1426
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardOne("delete", existing, null);
1374
1427
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1375
1428
  let removed = existing;
1376
1429
  return opts?.dryRun !== !0 && (removed = store.remove(id), removed !== void 0 && record(transactionId, [{ kind: "delete", id }], opts?.tag)), opts?.returnDocuments === void 0 && opts?.returnFirst === void 0 ? removed !== void 0 ? { _id: id } : null : formatMutationResult(
@@ -1520,6 +1573,7 @@ function makeHandle(handleArgs) {
1520
1573
  });
1521
1574
  }
1522
1575
  exports.ERR_PREFIX = ERR_PREFIX;
1576
+ exports.GuardDeniedError = GuardDeniedError;
1523
1577
  exports.SUPPORTED_SURFACE = SUPPORTED_SURFACE;
1524
1578
  exports.UNSUPPORTED_MEMBERS = UNSUPPORTED_MEMBERS;
1525
1579
  exports.WriteAccessDeniedError = WriteAccessDeniedError;