@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 +11 -4
- package/dist/index.cjs +77 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -60
- package/dist/index.d.ts +68 -60
- package/dist/index.js +77 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"),
|
|
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"),
|
|
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
|
-
|
|
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 (
|
|
1196
|
-
|
|
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
|
|
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"),
|
|
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")
|
|
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"),
|
|
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"),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|