@fedify/testing 2.4.0-pr.936.41 → 2.5.0-dev.2271

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/mod.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  import { hasProofLike, hasSignatureLike } from "@fedify/fedify/sig";
3
- import { Activity, CryptographicKey, Multikey, lookupObject, traverseCollection } from "@fedify/vocab";
3
+ import { Activity, CryptographicKey, Multikey, PUBLIC_COLLECTION, Tombstone, lookupObject, traverseCollection } from "@fedify/vocab";
4
+ import { arePortableUrisEqual, canonicalizePortableUri, decodeMultibase, formatIri, fromCompatibleEf61Id, getFe34Origin, parseDigestMultibase, parseHashlink, parseIri } from "@fedify/vocab-runtime";
4
5
  import { RouterError } from "@fedify/fedify/federation";
5
6
  import { delay } from "es-toolkit";
6
7
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
@@ -37,7 +38,7 @@ const noopMeterProvider$1 = { getMeter: () => ({
37
38
  removeBatchObservableCallback: () => void 0
38
39
  }) };
39
40
  function createContext(values) {
40
- const { federation, url = new URL("http://example.com/"), canonicalOrigin, data, documentLoader, contextLoader, meterProvider, tracerProvider, clone, getNodeInfoUri, getActorUri, getObjectUri, getCollectionUri, getOutboxUri, getMediaUploaderUri, getInboxUri, getFollowingUri, getFollowersUri, getLikedUri, getFeaturedUri, getFeaturedTagsUri, parseUri, getActorKeyPairs, getDocumentLoader, lookupObject: lookupObject$1, traverseCollection: traverseCollection$1, lookupNodeInfo, lookupWebFinger, sendActivity, routeActivity, enqueueTask, enqueueTaskMany } = values;
41
+ const { federation, url = new URL("http://example.com/"), canonicalOrigin, data, documentLoader, contextLoader, verifyPortableObject, meterProvider, tracerProvider, clone, getNodeInfoUri, getActorUri, getPortableActorUri, getObjectUri, getPortableObjectUri, getPortableInboxUri, getPortableOutboxUri, getPortableFollowingUri, getPortableFollowersUri, getPortableLikedUri, getPortableFeaturedUri, getPortableFeaturedTagsUri, getPortableCollectionUri, getCollectionUri, getOutboxUri, getMediaUploaderUri, getInboxUri, getFollowingUri, getFollowersUri, getLikedUri, getFeaturedUri, getFeaturedTagsUri, parseUri, getActorKeyPairs, getDocumentLoader, lookupObject: lookupObject$1, traverseCollection: traverseCollection$1, lookupNodeInfo, lookupWebFinger, sendActivity, routeActivity, enqueueTask, enqueueTaskMany } = values;
41
42
  function throwRouterError() {
42
43
  throw new RouterError("Not implemented");
43
44
  }
@@ -50,6 +51,7 @@ function createContext(values) {
50
51
  hostname: url.hostname,
51
52
  documentLoader: documentLoader ?? mockDocumentLoader,
52
53
  contextLoader: contextLoader ?? mockDocumentLoader,
54
+ ...verifyPortableObject == null ? {} : { verifyPortableObject },
53
55
  meterProvider: meterProvider ?? noopMeterProvider$1,
54
56
  tracerProvider: tracerProvider ?? noopTracerProvider$1,
55
57
  clone: clone ?? ((data) => createContext({
@@ -58,7 +60,17 @@ function createContext(values) {
58
60
  })),
59
61
  getNodeInfoUri: getNodeInfoUri ?? throwRouterError,
60
62
  getActorUri: getActorUri ?? throwRouterError,
63
+ getPortableActorUri: getPortableActorUri ?? throwRouterError,
61
64
  getObjectUri: getObjectUri ?? throwRouterError,
65
+ getPortableObjectUri: getPortableObjectUri ?? throwRouterError,
66
+ getPortableInboxUri: getPortableInboxUri ?? throwRouterError,
67
+ getPortableOutboxUri: getPortableOutboxUri ?? throwRouterError,
68
+ getPortableFollowingUri: getPortableFollowingUri ?? throwRouterError,
69
+ getPortableFollowersUri: getPortableFollowersUri ?? throwRouterError,
70
+ getPortableLikedUri: getPortableLikedUri ?? throwRouterError,
71
+ getPortableFeaturedUri: getPortableFeaturedUri ?? throwRouterError,
72
+ getPortableFeaturedTagsUri: getPortableFeaturedTagsUri ?? throwRouterError,
73
+ getPortableCollectionUri: getPortableCollectionUri ?? throwRouterError,
62
74
  getCollectionUri: getCollectionUri ?? throwRouterError,
63
75
  getOutboxUri: getOutboxUri ?? throwRouterError,
64
76
  getMediaUploaderUri: getMediaUploaderUri ?? throwRouterError,
@@ -78,13 +90,15 @@ function createContext(values) {
78
90
  lookupObject: lookupObject$1 ?? ((uri, options = {}) => {
79
91
  return lookupObject(uri, {
80
92
  documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader,
81
- contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader
93
+ contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader,
94
+ verifyPortableObject: options.verifyPortableObject ?? verifyPortableObject
82
95
  });
83
96
  }),
84
97
  traverseCollection: traverseCollection$1 ?? ((collection, options = {}) => {
85
98
  return traverseCollection(collection, {
86
99
  documentLoader: options.documentLoader ?? documentLoader ?? mockDocumentLoader,
87
- contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader
100
+ contextLoader: options.contextLoader ?? contextLoader ?? mockDocumentLoader,
101
+ verifyPortableObject: options.verifyPortableObject ?? verifyPortableObject
88
102
  });
89
103
  }),
90
104
  lookupNodeInfo: lookupNodeInfo ?? ((_params) => {
@@ -123,15 +137,59 @@ function createRequestContext(args) {
123
137
  })),
124
138
  request: args.request ?? new Request(args.url),
125
139
  url: args.url,
140
+ portableRequest: args.portableRequest,
126
141
  getActor: args.getActor ?? (() => Promise.resolve(null)),
127
142
  getObject: args.getObject ?? (() => Promise.resolve(null)),
128
143
  getSignedKey: args.getSignedKey ?? (() => Promise.resolve(null)),
129
144
  getSignedKeyOwner: args.getSignedKeyOwner ?? (() => Promise.resolve(null)),
145
+ isSignedByAudience: args.isSignedByAudience ?? ((object, options) => isSignedByAudience(() => args.getSignedKeyOwner == null ? Promise.resolve(null) : args.getSignedKeyOwner(options ?? {}), object, options?.isMember)),
130
146
  sendActivity: args.sendActivity ?? ((_params) => {
131
147
  throw new Error("Not implemented");
132
148
  })
133
149
  };
134
150
  }
151
+ const PUBLIC_IDS = /* @__PURE__ */ new Set([
152
+ PUBLIC_COLLECTION.href,
153
+ "as:Public",
154
+ "Public"
155
+ ]);
156
+ function getPortableIri(id) {
157
+ try {
158
+ if (id.protocol === "ap:" || id.protocol === "ap+ef61:") return formatIri(id);
159
+ const portable = fromCompatibleEf61Id(id);
160
+ return portable == null ? null : formatIri(portable);
161
+ } catch (error) {
162
+ if (error instanceof TypeError) return null;
163
+ throw error;
164
+ }
165
+ }
166
+ function isSameActorId(a, b) {
167
+ const portableA = getPortableIri(a);
168
+ const portableB = getPortableIri(b);
169
+ if (portableA != null || portableB != null) return portableA != null && portableB != null && arePortableUrisEqual(portableA, portableB);
170
+ return a.href === b.href;
171
+ }
172
+ /**
173
+ * The default implementation of `RequestContext.isSignedByAudience()` for
174
+ * testing, which checks the audience against `getSignedKeyOwner()`.
175
+ */
176
+ async function isSignedByAudience(getSignedKeyOwner, object, isMember) {
177
+ const addressees = [
178
+ ...object.toIds,
179
+ ...object.ccIds,
180
+ ...object.btoIds,
181
+ ...object.bccIds,
182
+ ...object.audienceIds
183
+ ];
184
+ if (addressees.some((id) => PUBLIC_IDS.has(id.href))) return true;
185
+ const actor = await getSignedKeyOwner();
186
+ const actorId = actor?.id;
187
+ if (actor == null || actorId == null) return false;
188
+ if (addressees.some((id) => isSameActorId(id, actorId))) return true;
189
+ if (isMember == null) return false;
190
+ for (const addressee of addressees) if (await isMember(addressee, actor)) return true;
191
+ return false;
192
+ }
135
193
  /**
136
194
  * Creates an InboxContext for testing purposes.
137
195
  * Not exported - used internally only. Public API is in mock.ts
@@ -225,6 +283,63 @@ function expandUriTemplate(template, values) {
225
283
  }
226
284
  });
227
285
  }
286
+ /**
287
+ * Builds an FEP-ef61 portable ID from a DID authority and a path, validating
288
+ * the authority the same way as `@fedify/fedify` does.
289
+ */
290
+ function buildPortableUri(authority, path) {
291
+ if (typeof authority !== "string" || !/^did:[a-z0-9]+:[^/?#]+$/i.test(authority)) throw new TypeError("The authority of a portable ID must be a DID without a path, query, or fragment.");
292
+ const did = getFe34Origin(authority);
293
+ if (!isBase58BtcDidKey(did)) throw new TypeError("The did:key authority of a portable ID must be encoded in base58-btc, i.e., start with z.");
294
+ return parseIri(`ap+ef61://${did}${path}`);
295
+ }
296
+ /**
297
+ * Checks that a normalized DID, if it is a `did:key` DID, is encoded in
298
+ * base58-btc, as `@fedify/fedify` requires of portable IDs.
299
+ */
300
+ function isBase58BtcDidKey(did) {
301
+ return !did.startsWith("did:key:") || /^z[1-9A-HJ-NP-Za-km-z]+$/.test(did.slice(8));
302
+ }
303
+ /**
304
+ * Recognizes an FEP-ef61 portable ID, i.e., an `ap:` or `ap+ef61:` URI, or
305
+ * a compatible identifier on any gateway, validating it the same way as
306
+ * `Context.parseUri()` of `@fedify/fedify` does.
307
+ * @returns The DID and the path of the portable ID, `null` if it is
308
+ * malformed, or `undefined` if the URI is not a portable ID.
309
+ */
310
+ function parseMockPortableId(uri) {
311
+ const portable = uri.protocol === "ap:" || uri.protocol === "ap+ef61:";
312
+ if (!portable && !((uri.protocol === "http:" || uri.protocol === "https:") && /^\/\.well-known\/apgateway\/did(?::|%3A)/i.test(uri.pathname))) return;
313
+ if (uri.username !== "" || uri.password !== "") return null;
314
+ try {
315
+ const id = portable ? parseIri(uri) : fromCompatibleEf61Id(uri.origin + uri.pathname);
316
+ if (id == null) return null;
317
+ canonicalizePortableUri(formatIri(id));
318
+ const authority = getFe34Origin(id);
319
+ if (!isBase58BtcDidKey(authority)) return null;
320
+ return {
321
+ authority,
322
+ path: id.pathname
323
+ };
324
+ } catch (error) {
325
+ if (error instanceof TypeError) return null;
326
+ throw error;
327
+ }
328
+ }
329
+ /**
330
+ * Parses a path in the way {@link MockContext.parseUri} does, i.e., only
331
+ * `/users/{identifier}` and its subpaths are recognized, as actors.
332
+ */
333
+ function parseMockPath(path) {
334
+ if (path.startsWith("/users/")) {
335
+ const parts = path.split("/");
336
+ if (parts.length >= 3) return {
337
+ type: "actor",
338
+ identifier: parts[2]
339
+ };
340
+ }
341
+ return null;
342
+ }
228
343
  function validateOutboxListenerPath(path, dispatcherPath) {
229
344
  if (!path.startsWith("/")) throw new TypeError("Path must start with a slash.");
230
345
  if (dispatcherPath != null && dispatcherPath !== path) throw new TypeError("Outbox listener path and outbox dispatcher path must match.");
@@ -301,6 +416,7 @@ var MockFederation = class {
301
416
  outboxListenerErrorHandler;
302
417
  mediaUploaderCallback;
303
418
  mediaUploaderAuthorizePredicate;
419
+ hashlinkMediaDispatcher;
304
420
  followingDispatcher;
305
421
  followersDispatcher;
306
422
  likedDispatcher;
@@ -338,93 +454,103 @@ var MockFederation = class {
338
454
  setActorDispatcher(path, dispatcher) {
339
455
  this.actorDispatchers.set(path, dispatcher);
340
456
  this.actorPath = path;
341
- return {
457
+ const setters = {
342
458
  setKeyPairsDispatcher: (keyPairsDispatcher) => {
343
459
  this.actorKeyPairsDispatcher = keyPairsDispatcher;
344
- return this;
460
+ return setters;
345
461
  },
346
- mapHandle: () => this,
347
- mapAlias: () => this,
348
- authorize: () => this
462
+ mapHandle: () => setters,
463
+ mapAlias: () => setters,
464
+ mapPortableActorId: () => setters,
465
+ authorize: () => setters
349
466
  };
467
+ return setters;
350
468
  }
351
469
  setObjectDispatcher(cls, path, dispatcher) {
352
470
  this.objectDispatchers.set(path, dispatcher);
353
471
  this.objectPaths.set(cls.typeId.href, path);
354
- return { authorize: () => this };
472
+ const setters = { authorize: () => setters };
473
+ return setters;
355
474
  }
356
475
  setInboxDispatcher(_path, dispatcher) {
357
476
  this.inboxDispatcher = dispatcher;
358
- return {
359
- setCounter: () => this,
360
- setFirstCursor: () => this,
361
- setLastCursor: () => this,
362
- authorize: () => this
477
+ const setters = {
478
+ setCounter: () => setters,
479
+ setFirstCursor: () => setters,
480
+ setLastCursor: () => setters,
481
+ authorize: () => setters
363
482
  };
483
+ return setters;
364
484
  }
365
485
  setOutboxDispatcher(path, dispatcher) {
366
486
  validateOutboxListenerPath(path, this.outboxListenersInitialized ? this.outboxPath : void 0);
367
487
  this.outboxDispatcher = dispatcher;
368
488
  this.outboxPath = path;
369
- return {
370
- setCounter: () => this,
371
- setFirstCursor: () => this,
372
- setLastCursor: () => this,
489
+ const setters = {
490
+ setCounter: () => setters,
491
+ setFirstCursor: () => setters,
492
+ setLastCursor: () => setters,
373
493
  authorize: (predicate) => {
374
494
  this.outboxDispatcherAuthorizePredicate = predicate;
375
- return this;
495
+ return setters;
376
496
  }
377
497
  };
498
+ return setters;
378
499
  }
379
500
  setFollowingDispatcher(path, dispatcher) {
380
501
  this.followingDispatcher = dispatcher;
381
502
  this.followingPath = path;
382
- return {
383
- setCounter: () => this,
384
- setFirstCursor: () => this,
385
- setLastCursor: () => this,
386
- authorize: () => this
503
+ const setters = {
504
+ setCounter: () => setters,
505
+ setFirstCursor: () => setters,
506
+ setLastCursor: () => setters,
507
+ authorize: () => setters
387
508
  };
509
+ return setters;
388
510
  }
389
511
  setFollowersDispatcher(path, dispatcher) {
390
512
  this.followersDispatcher = dispatcher;
391
513
  this.followersPath = path;
392
- return {
393
- setCounter: () => this,
394
- setFirstCursor: () => this,
395
- setLastCursor: () => this,
396
- authorize: () => this
514
+ const setters = {
515
+ setCounter: () => setters,
516
+ setFirstCursor: () => setters,
517
+ setLastCursor: () => setters,
518
+ authorize: () => setters
397
519
  };
520
+ return setters;
398
521
  }
399
522
  setLikedDispatcher(path, dispatcher) {
400
523
  this.likedDispatcher = dispatcher;
401
524
  this.likedPath = path;
402
- return {
403
- setCounter: () => this,
404
- setFirstCursor: () => this,
405
- setLastCursor: () => this,
406
- authorize: () => this
525
+ const setters = {
526
+ setCounter: () => setters,
527
+ setFirstCursor: () => setters,
528
+ setLastCursor: () => setters,
529
+ authorize: () => setters
407
530
  };
531
+ return setters;
408
532
  }
409
533
  setFeaturedDispatcher(path, dispatcher) {
410
534
  this.featuredDispatcher = dispatcher;
411
535
  this.featuredPath = path;
412
- return {
413
- setCounter: () => this,
414
- setFirstCursor: () => this,
415
- setLastCursor: () => this,
416
- authorize: () => this
536
+ const setters = {
537
+ setCounter: () => setters,
538
+ setFirstCursor: () => setters,
539
+ setLastCursor: () => setters,
540
+ authorize: () => setters
417
541
  };
542
+ return setters;
418
543
  }
419
544
  setFeaturedTagsDispatcher(path, dispatcher) {
420
545
  this.featuredTagsDispatcher = dispatcher;
421
546
  this.featuredTagsPath = path;
422
- return {
423
- setCounter: () => this,
424
- setFirstCursor: () => this,
425
- setLastCursor: () => this,
426
- authorize: () => this
547
+ const setters = {
548
+ setCounter: () => setters,
549
+ setFirstCursor: () => setters,
550
+ setLastCursor: () => setters,
551
+ authorize: () => setters
427
552
  };
553
+ return setters;
428
554
  }
429
555
  setInboxListeners(inboxPath, sharedInboxPath) {
430
556
  this.inboxPath = inboxPath;
@@ -443,6 +569,9 @@ var MockFederation = class {
443
569
  onUnverifiedActivity() {
444
570
  return this;
445
571
  },
572
+ onRequestFinished() {
573
+ return this;
574
+ },
446
575
  setSharedKeyDispatcher() {
447
576
  return this;
448
577
  },
@@ -483,6 +612,10 @@ var MockFederation = class {
483
612
  return this;
484
613
  } };
485
614
  }
615
+ setHashlinkMediaDispatcher(dispatcher) {
616
+ if (this.hashlinkMediaDispatcher != null) throw new TypeError("Hashlink media dispatcher already set.");
617
+ this.hashlinkMediaDispatcher = dispatcher;
618
+ }
486
619
  setOutboxPermanentFailureHandler(_handler) {}
487
620
  async startQueue(contextData, options) {
488
621
  this.contextData = contextData;
@@ -498,7 +631,7 @@ var MockFederation = class {
498
631
  async processQueuedTask(contextData, _message) {
499
632
  this.contextData = contextData;
500
633
  }
501
- createContext(baseUrlOrRequest, contextData) {
634
+ createContext(baseUrlOrRequest, contextData, options = {}) {
502
635
  const mockFederation = this;
503
636
  const request = baseUrlOrRequest instanceof Request ? baseUrlOrRequest : null;
504
637
  return new MockContext({
@@ -507,10 +640,63 @@ var MockFederation = class {
507
640
  data: contextData,
508
641
  federation: mockFederation,
509
642
  meterProvider: this.options.meterProvider,
510
- tracerProvider: this.options.tracerProvider
643
+ tracerProvider: this.options.tracerProvider,
644
+ documentLoader: this.options.documentLoader,
645
+ contextLoader: this.options.contextLoader,
646
+ verifyPortableObject: this.options.verifyPortableObject,
647
+ portableRequest: options.portableRequest
511
648
  });
512
649
  }
513
650
  async fetch(request, options) {
651
+ if (this.hashlinkMediaDispatcher != null) {
652
+ const url = new URL(request.url);
653
+ if ((url.protocol === "http:" || url.protocol === "https:") && url.pathname.startsWith("/.well-known/apgateway/")) {
654
+ const encoded = url.pathname.slice(23);
655
+ if (/^hl(?::|%3A)/i.test(encoded)) {
656
+ if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed.", {
657
+ status: 405,
658
+ headers: {
659
+ Allow: "GET, HEAD",
660
+ "Content-Type": "text/plain; charset=utf-8"
661
+ }
662
+ });
663
+ let media;
664
+ try {
665
+ let hashlink;
666
+ try {
667
+ hashlink = decodeURIComponent(encoded);
668
+ } catch (error) {
669
+ throw new TypeError("Invalid percent-encoding in the hashlink.", { cause: error });
670
+ }
671
+ const { digestMultibase } = parseHashlink(hashlink);
672
+ const { digest } = parseDigestMultibase(digestMultibase);
673
+ media = globalThis.Object.freeze({
674
+ hashlink: `hl:${digestMultibase}`,
675
+ digestMultibase,
676
+ algorithm: "sha2-256",
677
+ digest,
678
+ multihash: decodeMultibase(digestMultibase)
679
+ });
680
+ } catch (error) {
681
+ if (!(error instanceof TypeError)) throw error;
682
+ return new Response(request.method === "HEAD" ? null : "Malformed hashlink.", {
683
+ status: 400,
684
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
685
+ });
686
+ }
687
+ const context = this.createContext(request, options.contextData);
688
+ const response = await this.hashlinkMediaDispatcher(context, media);
689
+ if (response == null) return options.onNotFound == null ? new Response("Not Found", { status: 404 }) : await options.onNotFound(request);
690
+ if (request.method !== "HEAD" || response.body == null) return response;
691
+ response.body.cancel().catch(() => {});
692
+ return new Response(null, {
693
+ status: response.status,
694
+ statusText: response.statusText,
695
+ headers: response.headers
696
+ });
697
+ }
698
+ }
699
+ }
514
700
  if (options.onNotFound) return options.onNotFound(request);
515
701
  return new Response("Not Found", { status: 404 });
516
702
  }
@@ -618,20 +804,24 @@ var MockFederation = class {
618
804
  this.sentActivities = [];
619
805
  }
620
806
  setCollectionDispatcher(_name, _itemType, _path, _dispatcher) {
621
- return {
622
- setCounter: () => this,
623
- setFirstCursor: () => this,
624
- setLastCursor: () => this,
625
- authorize: () => this
807
+ const setters = {
808
+ setCounter: () => setters,
809
+ setFirstCursor: () => setters,
810
+ setLastCursor: () => setters,
811
+ authorize: () => setters,
812
+ mapPortableOwner: () => setters
626
813
  };
814
+ return setters;
627
815
  }
628
816
  setOrderedCollectionDispatcher(_name, _itemType, _path, _dispatcher) {
629
- return {
630
- setCounter: () => this,
631
- setFirstCursor: () => this,
632
- setLastCursor: () => this,
633
- authorize: () => this
817
+ const setters = {
818
+ setCounter: () => setters,
819
+ setFirstCursor: () => setters,
820
+ setLastCursor: () => setters,
821
+ authorize: () => setters,
822
+ mapPortableOwner: () => setters
634
823
  };
824
+ return setters;
635
825
  }
636
826
  };
637
827
  /**
@@ -729,6 +919,10 @@ var MockContext = class MockContext {
729
919
  tracerProvider;
730
920
  request;
731
921
  url;
922
+ portableRequest;
923
+ verifyPortableObject;
924
+ hasDocumentLoader;
925
+ hasContextLoader;
732
926
  sentActivities = [];
733
927
  constructor(options) {
734
928
  const url = options.url ?? new URL("https://example.com");
@@ -738,6 +932,10 @@ var MockContext = class MockContext {
738
932
  this.hostname = url.hostname;
739
933
  this.url = url;
740
934
  this.request = options.request ?? new Request(url);
935
+ this.portableRequest = options.portableRequest;
936
+ this.verifyPortableObject = options.verifyPortableObject;
937
+ this.hasDocumentLoader = options.hasDocumentLoader ?? options.documentLoader != null;
938
+ this.hasContextLoader = options.hasContextLoader ?? options.contextLoader != null;
741
939
  this.data = options.data;
742
940
  this.federation = options.federation;
743
941
  this.documentLoader = options.documentLoader ?? (async (url) => ({
@@ -756,12 +954,16 @@ var MockContext = class MockContext {
756
954
  }
757
955
  return null;
758
956
  }
759
- async getObject(cls, values) {
957
+ async getObject(cls, values, options) {
760
958
  if (this.federation instanceof MockFederation) {
761
959
  const path = this.federation.objectPaths.get(cls.typeId.href);
762
960
  if (path) {
763
961
  const dispatcher = this.federation.objectDispatchers.get(path);
764
- if (dispatcher) return await dispatcher(this, values);
962
+ if (dispatcher) {
963
+ const object = await dispatcher(this, values);
964
+ if (object instanceof Tombstone && !(object instanceof cls) && options?.tombstone !== "passthrough") return null;
965
+ return object;
966
+ }
765
967
  }
766
968
  }
767
969
  return null;
@@ -772,6 +974,9 @@ var MockContext = class MockContext {
772
974
  getSignedKeyOwner() {
773
975
  return Promise.resolve(null);
774
976
  }
977
+ isSignedByAudience(object, options) {
978
+ return isSignedByAudience(() => this.getSignedKeyOwner(), object, options?.isMember);
979
+ }
775
980
  #resolveTaskDefinition(task) {
776
981
  if (!(this.federation instanceof MockFederation)) throw new TypeError("No task definitions are available.");
777
982
  const def = this.federation.taskDefinitions.get(task.name);
@@ -795,10 +1000,15 @@ var MockContext = class MockContext {
795
1000
  clone(data) {
796
1001
  return new MockContext({
797
1002
  url: this.url,
1003
+ request: this.request,
798
1004
  data,
799
1005
  federation: this.federation,
800
1006
  documentLoader: this.documentLoader,
801
1007
  contextLoader: this.contextLoader,
1008
+ verifyPortableObject: this.verifyPortableObject,
1009
+ portableRequest: this.portableRequest,
1010
+ hasDocumentLoader: this.hasDocumentLoader,
1011
+ hasContextLoader: this.hasContextLoader,
802
1012
  meterProvider: this.meterProvider,
803
1013
  tracerProvider: this.tracerProvider
804
1014
  });
@@ -825,6 +1035,46 @@ var MockContext = class MockContext {
825
1035
  const path = globalThis.Object.entries(values).map(([key, value]) => `${key}/${value}`).join("/");
826
1036
  return new URL(`/objects/${cls.name.toLowerCase()}/${path}`, this.origin);
827
1037
  }
1038
+ getPortableActorUri(identifier, authority) {
1039
+ const { pathname } = this.getActorUri(identifier);
1040
+ return buildPortableUri(authority, pathname);
1041
+ }
1042
+ getPortableObjectUri(cls, values, authority) {
1043
+ const { pathname } = this.getObjectUri(cls, values);
1044
+ return buildPortableUri(authority, pathname);
1045
+ }
1046
+ getPortableInboxUri(identifier, authority) {
1047
+ const { pathname } = this.getInboxUri(identifier);
1048
+ return buildPortableUri(authority, pathname);
1049
+ }
1050
+ getPortableOutboxUri(identifier, authority) {
1051
+ const { pathname } = this.getOutboxUri(identifier);
1052
+ return buildPortableUri(authority, pathname);
1053
+ }
1054
+ getPortableFollowingUri(identifier, authority) {
1055
+ const { pathname } = this.getFollowingUri(identifier);
1056
+ return buildPortableUri(authority, pathname);
1057
+ }
1058
+ getPortableFollowersUri(identifier, authority) {
1059
+ const { pathname } = this.getFollowersUri(identifier);
1060
+ return buildPortableUri(authority, pathname);
1061
+ }
1062
+ getPortableLikedUri(identifier, authority) {
1063
+ const { pathname } = this.getLikedUri(identifier);
1064
+ return buildPortableUri(authority, pathname);
1065
+ }
1066
+ getPortableFeaturedUri(identifier, authority) {
1067
+ const { pathname } = this.getFeaturedUri(identifier);
1068
+ return buildPortableUri(authority, pathname);
1069
+ }
1070
+ getPortableFeaturedTagsUri(identifier, authority) {
1071
+ const { pathname } = this.getFeaturedTagsUri(identifier);
1072
+ return buildPortableUri(authority, pathname);
1073
+ }
1074
+ getPortableCollectionUri(name, values, authority) {
1075
+ const { pathname } = this.getCollectionUri(name, values);
1076
+ return buildPortableUri(authority, pathname);
1077
+ }
828
1078
  getOutboxUri(identifier) {
829
1079
  if (this.federation instanceof MockFederation && this.federation.outboxPath) {
830
1080
  const path = expandUriTemplate(this.federation.outboxPath, { identifier });
@@ -889,15 +1139,16 @@ var MockContext = class MockContext {
889
1139
  const path = globalThis.Object.entries(values).map(([key, value]) => `${key}/${value}`).join("/");
890
1140
  return new URL(`/collections/${String(_name)}/${path}`, this.origin);
891
1141
  }
892
- parseUri(uri) {
893
- if (uri.pathname.startsWith("/users/")) {
894
- const parts = uri.pathname.split("/");
895
- if (parts.length >= 3) return {
896
- type: "actor",
897
- identifier: parts[2]
898
- };
899
- }
900
- return null;
1142
+ parseUri(uri, options = {}) {
1143
+ if (uri == null) return null;
1144
+ const portable = parseMockPortableId(uri);
1145
+ if (portable === void 0) return parseMockPath(uri.pathname);
1146
+ if (portable === null || !options.portable) return null;
1147
+ const result = parseMockPath(portable.path);
1148
+ return result == null ? null : {
1149
+ ...result,
1150
+ authority: portable.authority
1151
+ };
901
1152
  }
902
1153
  async getActorKeyPairs(identifier) {
903
1154
  if (this.federation instanceof MockFederation && this.federation.actorKeyPairsDispatcher) {
@@ -923,8 +1174,21 @@ var MockContext = class MockContext {
923
1174
  if ("keyId" in params) return this.documentLoader;
924
1175
  return Promise.resolve(this.documentLoader);
925
1176
  }
926
- lookupObject(_uri, _options) {
927
- return Promise.resolve(null);
1177
+ lookupObject(uri, options = {}) {
1178
+ if (!this.hasDocumentLoader && options.documentLoader == null || (options.verifyPortableObject ?? this.verifyPortableObject) == null) return Promise.resolve(null);
1179
+ const value = typeof uri === "string" ? uri : formatIri(uri);
1180
+ if (!/^ap(?:\+ef61)?:\/\//i.test(value)) try {
1181
+ if (fromCompatibleEf61Id(value) == null) return Promise.resolve(null);
1182
+ } catch (error) {
1183
+ if (error instanceof TypeError) return Promise.resolve(null);
1184
+ throw error;
1185
+ }
1186
+ return lookupObject(uri, {
1187
+ ...options,
1188
+ documentLoader: options.documentLoader ?? this.documentLoader,
1189
+ contextLoader: options.contextLoader ?? (this.hasContextLoader ? this.contextLoader : options.documentLoader ?? this.contextLoader),
1190
+ verifyPortableObject: options.verifyPortableObject ?? this.verifyPortableObject
1191
+ });
928
1192
  }
929
1193
  traverseCollection(_collection, _options) {
930
1194
  return { async *[Symbol.asyncIterator]() {} };
@@ -977,6 +1241,147 @@ var MockContext = class MockContext {
977
1241
  }
978
1242
  };
979
1243
  //#endregion
1244
+ //#region src/kv-tester.ts
1245
+ /**
1246
+ * Tests a {@link KvStore} implementation with a standard set of tests.
1247
+ *
1248
+ * The suite covers reads and writes, deletion, structured keys and values,
1249
+ * prefix listing, TTL expiration, and optional compare-and-swap behavior.
1250
+ * Every key is isolated below a random prefix and is deleted after the suite.
1251
+ *
1252
+ * @example
1253
+ * ```typescript ignore
1254
+ * import { test } from "@fedify/fixture";
1255
+ * import { testKvStore } from "@fedify/testing";
1256
+ * import { MyKvStore } from "./my-kv.ts";
1257
+ *
1258
+ * test("MyKvStore", () =>
1259
+ * testKvStore(
1260
+ * () => new MyKvStore(),
1261
+ * async ({ store }) => await store.close(),
1262
+ * )
1263
+ * );
1264
+ * ```
1265
+ *
1266
+ * @param getKvStore A factory that creates the store to test.
1267
+ * @param onFinally A cleanup function called after the suite completes.
1268
+ * @param options Optional configuration for the suite.
1269
+ */
1270
+ async function testKvStore(getKvStore, onFinally, options = {}) {
1271
+ const store = await getKvStore();
1272
+ const prefix = [`fedify_test_${crypto.randomUUID()}`];
1273
+ const key = (...segments) => [...prefix, ...segments];
1274
+ try {
1275
+ strictEqual(await store.get(key("missing")), void 0);
1276
+ for (const [index, value] of [
1277
+ "text",
1278
+ 42,
1279
+ true,
1280
+ null,
1281
+ [
1282
+ 1,
1283
+ "two",
1284
+ false
1285
+ ],
1286
+ { nested: { value: "test" } }
1287
+ ].entries()) {
1288
+ const valueKey = key("values", index.toString());
1289
+ await store.set(valueKey, value);
1290
+ deepStrictEqual(await store.get(valueKey), value);
1291
+ }
1292
+ const overwriteKey = key("overwrite");
1293
+ await store.set(overwriteKey, "before");
1294
+ await store.set(overwriteKey, "after");
1295
+ strictEqual(await store.get(overwriteKey), "after");
1296
+ const deleteKey = key("delete");
1297
+ await store.set(deleteKey, "value");
1298
+ await store.delete(deleteKey);
1299
+ strictEqual(await store.get(deleteKey), void 0);
1300
+ await store.delete(deleteKey);
1301
+ const isolatedKeys = [
1302
+ key("a", "b"),
1303
+ key("a,b"),
1304
+ key("a:b")
1305
+ ];
1306
+ for (const [index, isolatedKey] of isolatedKeys.entries()) await store.set(isolatedKey, index);
1307
+ for (const [index, isolatedKey] of isolatedKeys.entries()) strictEqual(await store.get(isolatedKey), index);
1308
+ if (options.testTtl ?? true) {
1309
+ const expiringKey = key("ttl", "expiring");
1310
+ await store.set(expiringKey, "expired", { ttl: Temporal.Duration.from({ milliseconds: 50 }) });
1311
+ await delay(100);
1312
+ strictEqual(await store.get(expiringKey), void 0);
1313
+ deepStrictEqual(await collect(store.list(key("ttl"))), []);
1314
+ const refreshedKey = key("ttl", "refreshed");
1315
+ await store.set(refreshedKey, "temporary", { ttl: Temporal.Duration.from({ milliseconds: 50 }) });
1316
+ await store.set(refreshedKey, "persistent");
1317
+ await delay(100);
1318
+ strictEqual(await store.get(refreshedKey), "persistent");
1319
+ }
1320
+ const listPrefix = key("list");
1321
+ const listEntries = [
1322
+ {
1323
+ key: listPrefix,
1324
+ value: "exact"
1325
+ },
1326
+ {
1327
+ key: key("list", "child"),
1328
+ value: "child"
1329
+ },
1330
+ {
1331
+ key: key("list", "child", "nested"),
1332
+ value: "nested"
1333
+ },
1334
+ {
1335
+ key: key("list-other"),
1336
+ value: "other"
1337
+ }
1338
+ ];
1339
+ for (const entry of listEntries) await store.set(entry.key, entry.value);
1340
+ deepStrictEqual(normalize(await collect(store.list(listPrefix))), normalize(listEntries.slice(0, 3)));
1341
+ const prefixedEntries = await collect(store.list(prefix));
1342
+ deepStrictEqual(normalize(filterByPrefix(await collect(store.list()), prefix)), normalize(prefixedEntries));
1343
+ deepStrictEqual(normalize(filterByPrefix(await collect(store.list([])), prefix)), normalize(prefixedEntries));
1344
+ if (options.testCas ?? store.cas != null) {
1345
+ if (store.cas == null) throw new TypeError("KvStore.cas is not implemented");
1346
+ const cas = store.cas.bind(store);
1347
+ const casKey = key("cas", "value");
1348
+ strictEqual(await cas(casKey, "wrong", "value"), false);
1349
+ strictEqual(await cas(casKey, void 0, "created"), true);
1350
+ strictEqual(await cas(casKey, "wrong", "updated"), false);
1351
+ strictEqual(await cas(casKey, "created", "updated"), true);
1352
+ strictEqual(await store.get(casKey), "updated");
1353
+ strictEqual(await cas(casKey, "updated", void 0), true);
1354
+ strictEqual(await store.get(casKey), void 0);
1355
+ if (options.testTtl ?? true) {
1356
+ const expiredCasKey = key("cas", "expired");
1357
+ await store.set(expiredCasKey, "old", { ttl: Temporal.Duration.from({ milliseconds: 50 }) });
1358
+ await delay(100);
1359
+ strictEqual(await cas(expiredCasKey, void 0, "replaced"), true);
1360
+ const ttlCasKey = key("cas", "ttl");
1361
+ strictEqual(await cas(ttlCasKey, void 0, "temporary", { ttl: Temporal.Duration.from({ milliseconds: 50 }) }), true);
1362
+ await delay(100);
1363
+ strictEqual(await store.get(ttlCasKey), void 0);
1364
+ }
1365
+ const concurrentKey = key("cas", "concurrent");
1366
+ strictEqual((await Promise.all(Array.from({ length: 8 }, (_, index) => cas(concurrentKey, void 0, index)))).filter(Boolean).length, 1);
1367
+ }
1368
+ } finally {
1369
+ for await (const entry of store.list(prefix)) await store.delete(entry.key);
1370
+ await onFinally({ store });
1371
+ }
1372
+ }
1373
+ async function collect(entries) {
1374
+ const results = [];
1375
+ for await (const entry of entries) results.push(entry);
1376
+ return results;
1377
+ }
1378
+ function normalize(entries) {
1379
+ return entries.map((entry) => JSON.stringify(entry)).sort();
1380
+ }
1381
+ function filterByPrefix(entries, prefix) {
1382
+ return entries.filter((entry) => prefix.every((segment, index) => entry.key[index] === segment));
1383
+ }
1384
+ //#endregion
980
1385
  //#region src/mq-tester.ts
981
1386
  /**
982
1387
  * Tests a {@link MessageQueue} implementation with a standard set of tests.
@@ -1157,4 +1562,4 @@ async function waitFor(predicate, timeoutMs) {
1157
1562
  }
1158
1563
  const getRandomKey = (prefix) => `fedify_test_${prefix}_${crypto.randomUUID()}`;
1159
1564
  //#endregion
1160
- export { createContext, createFederation, createInboxContext, createOutboxContext, createRequestContext, getRandomKey, testMessageQueue, waitFor };
1565
+ export { createContext, createFederation, createInboxContext, createOutboxContext, createRequestContext, getRandomKey, testKvStore, testMessageQueue, waitFor };