@fedify/webfinger 2.5.0-dev.2446 → 2.5.0-dev.2492

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.
@@ -4,44 +4,122 @@ import { configure, type LogRecord, reset } from "@logtape/logtape";
4
4
  import { withTimeout } from "es-toolkit";
5
5
  import fetchMock from "fetch-mock";
6
6
  import { deepStrictEqual, ok } from "node:assert/strict";
7
- import dns from "node:dns/promises";
8
7
  import type { ResourceDescriptor } from "./jrd.ts";
9
- import { lookupWebFinger, type LookupWebFingerOptions } from "./lookup.ts";
8
+ import {
9
+ lookupWebFinger,
10
+ type LookupWebFingerOptions,
11
+ lookupWebFingerWithDependencies,
12
+ type WebFingerLookupOutcome,
13
+ } from "./lookup.ts";
14
+ import {
15
+ type LookupWebFingerDependencies,
16
+ lookupWebFingerInternal,
17
+ } from "./lookup-impl.ts";
18
+ import {
19
+ openDirectFetch,
20
+ openPublicFetch,
21
+ type PublicFetchSession,
22
+ } from "@fedify/vocab-runtime/internal";
23
+ import type { LookupAddress } from "node:dns";
24
+ import { test as nodeTest } from "node:test";
25
+
26
+ // Inject only the transport boundary, so the public tracing and metrics
27
+ // wrapper remains under test while fetch-mock sees the original hostname.
28
+ function lookupMock(
29
+ resource: URL | string,
30
+ options: LookupWebFingerOptions & { withDiagnostics: true },
31
+ ): Promise<WebFingerLookupOutcome>;
32
+ function lookupMock(
33
+ resource: URL | string,
34
+ options?: LookupWebFingerOptions & { withDiagnostics?: false },
35
+ ): Promise<ResourceDescriptor | null>;
36
+ function lookupMock(
37
+ resource: URL | string,
38
+ options: LookupWebFingerOptions & { withDiagnostics?: boolean } = {},
39
+ ): Promise<ResourceDescriptor | null | WebFingerLookupOutcome> {
40
+ if (resource.toString().includes("fedify-test.internal")) {
41
+ return lookupWebFingerWithDependencies(resource, options, {
42
+ openHop: (url) =>
43
+ Promise.resolve({
44
+ url: new URL(url),
45
+ fetch: () => Promise.reject(new TypeError("Connection refused")),
46
+ close: () => Promise.resolve(),
47
+ }),
48
+ });
49
+ }
50
+ return lookupWebFingerWithDependencies(resource, options, {
51
+ openHop: options.allowPrivateAddress === true
52
+ ? openDirectFetch
53
+ : openExampleDirectly,
54
+ });
55
+ }
56
+
57
+ function lookupDirect(
58
+ resource: URL | string,
59
+ options?: LookupWebFingerOptions,
60
+ ): Promise<ResourceDescriptor | null> {
61
+ return lookupWebFingerWithDependencies(resource, options, {
62
+ openHop: openDirectFetch,
63
+ });
64
+ }
65
+
66
+ function lookupResolving(
67
+ addresses: readonly LookupAddress[],
68
+ ): (hostname: string) => Promise<readonly LookupAddress[]> {
69
+ return () => Promise.resolve(addresses);
70
+ }
71
+
72
+ function openExampleDirectly(url: string): Promise<PublicFetchSession> {
73
+ return new URL(url).hostname === "example.com"
74
+ ? openDirectFetch(url)
75
+ : openPublicFetch(url, {
76
+ lookup: lookupResolving([{ address: "93.184.215.14", family: 4 }]),
77
+ });
78
+ }
79
+
80
+ async function lookupResourceWithDependencies(
81
+ resource: URL | string,
82
+ options: LookupWebFingerOptions,
83
+ deps: LookupWebFingerDependencies,
84
+ ): Promise<ResourceDescriptor | null> {
85
+ return (await lookupWebFingerInternal(resource, options, deps)).resource;
86
+ }
10
87
 
11
88
  test({
12
89
  name: "lookupWebFinger()",
13
90
  sanitizeOps: false,
14
91
  sanitizeResources: false,
15
92
  async fn(t) {
93
+ const responseTimers: ReturnType<typeof setTimeout>[] = [];
16
94
  await t.step("invalid resource", async () => {
17
- deepStrictEqual(await lookupWebFinger("acct:johndoe"), null);
18
- deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe")), null);
19
- deepStrictEqual(await lookupWebFinger("acct:johndoe@"), null);
20
- deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe@")), null);
95
+ deepStrictEqual(await lookupMock("acct:johndoe"), null);
96
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe")), null);
97
+ deepStrictEqual(await lookupMock("acct:johndoe@"), null);
98
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe@")), null);
21
99
  // Per RFC 7565, the acct: authority is bare `host`: no path,
22
100
  // query, or fragment is allowed. Reject such inputs rather than
23
101
  // forwarding them to a remote WebFinger lookup.
24
102
  deepStrictEqual(
25
- await lookupWebFinger("acct:johndoe@example.com/exploit"),
103
+ await lookupMock("acct:johndoe@example.com/exploit"),
26
104
  null,
27
105
  );
28
106
  deepStrictEqual(
29
- await lookupWebFinger("acct:johndoe@example.com?x=1"),
107
+ await lookupMock("acct:johndoe@example.com?x=1"),
30
108
  null,
31
109
  );
32
110
  deepStrictEqual(
33
- await lookupWebFinger("acct:johndoe@example.com#frag"),
111
+ await lookupMock("acct:johndoe@example.com#frag"),
34
112
  null,
35
113
  );
36
114
  });
37
115
 
38
116
  await t.step("connection refused", async () => {
39
117
  deepStrictEqual(
40
- await lookupWebFinger("acct:johndoe@fedify-test.internal"),
118
+ await lookupMock("acct:johndoe@fedify-test.internal"),
41
119
  null,
42
120
  );
43
121
  deepStrictEqual(
44
- await lookupWebFinger("https://fedify-test.internal/foo"),
122
+ await lookupMock("https://fedify-test.internal/foo"),
45
123
  null,
46
124
  );
47
125
  });
@@ -59,10 +137,10 @@ test({
59
137
 
60
138
  await t.step("not found", async () => {
61
139
  deepStrictEqual(
62
- await lookupWebFinger("acct:johndoe@example.com"),
140
+ await lookupMock("acct:johndoe@example.com"),
63
141
  null,
64
142
  );
65
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
143
+ deepStrictEqual(await lookupMock("https://example.com/foo"), null);
66
144
  });
67
145
 
68
146
  const expected: ResourceDescriptor = {
@@ -77,7 +155,7 @@ test({
77
155
 
78
156
  await t.step("acct", async () => {
79
157
  deepStrictEqual(
80
- await lookupWebFinger("acct:johndoe@example.com"),
158
+ await lookupMock("acct:johndoe@example.com"),
81
159
  expected,
82
160
  );
83
161
  });
@@ -94,7 +172,7 @@ test({
94
172
 
95
173
  await t.step("https", async () => {
96
174
  deepStrictEqual(
97
- await lookupWebFinger("https://example.com/foo"),
175
+ await lookupMock("https://example.com/foo"),
98
176
  expected2,
99
177
  );
100
178
  });
@@ -115,7 +193,7 @@ test({
115
193
  // host extraction (after the last `@`) applies to `mailto:` just
116
194
  // like `acct:`.
117
195
  deepStrictEqual(
118
- await lookupWebFinger("mailto:juliet@example.com"),
196
+ await lookupMock("mailto:juliet@example.com"),
119
197
  mailtoExpected,
120
198
  );
121
199
  });
@@ -137,7 +215,7 @@ test({
137
215
  // must accept them and forward the full resource URI to the
138
216
  // WebFinger endpoint.
139
217
  deepStrictEqual(
140
- await lookupWebFinger("mailto:juliet@example.com?subject=Hi"),
218
+ await lookupMock("mailto:juliet@example.com?subject=Hi"),
141
219
  mailtoQueryExpected,
142
220
  );
143
221
  });
@@ -150,7 +228,7 @@ test({
150
228
 
151
229
  await t.step("invalid response", async () => {
152
230
  deepStrictEqual(
153
- await lookupWebFinger("acct:johndoe@example.com"),
231
+ await lookupMock("acct:johndoe@example.com"),
154
232
  null,
155
233
  );
156
234
  });
@@ -171,9 +249,9 @@ test({
171
249
  );
172
250
 
173
251
  await t.step("private address", async () => {
174
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
252
+ deepStrictEqual(await lookupMock("acct:test@localhost"), null);
175
253
  deepStrictEqual(
176
- await lookupWebFinger("acct:test@localhost", {
254
+ await lookupMock("acct:test@localhost", {
177
255
  allowPrivateAddress: true,
178
256
  }),
179
257
  {
@@ -204,7 +282,7 @@ test({
204
282
 
205
283
  await t.step("redirection", async () => {
206
284
  deepStrictEqual(
207
- await lookupWebFinger("acct:johndoe@example.com"),
285
+ await lookupMock("acct:johndoe@example.com"),
208
286
  expected,
209
287
  );
210
288
  });
@@ -220,7 +298,7 @@ test({
220
298
 
221
299
  await t.step("infinite redirection", async () => {
222
300
  const result = await withTimeout(
223
- () => lookupWebFinger("acct:johndoe@example.com"),
301
+ () => lookupMock("acct:johndoe@example.com"),
224
302
  2000,
225
303
  );
226
304
  deepStrictEqual(result, null);
@@ -237,7 +315,7 @@ test({
237
315
 
238
316
  await t.step("redirection to different protocol", async () => {
239
317
  deepStrictEqual(
240
- await lookupWebFinger("acct:johndoe@example.com"),
318
+ await lookupMock("acct:johndoe@example.com"),
241
319
  null,
242
320
  );
243
321
  });
@@ -253,7 +331,7 @@ test({
253
331
 
254
332
  await t.step("redirection to private address", async () => {
255
333
  deepStrictEqual(
256
- await lookupWebFinger("acct:johndoe@example.com"),
334
+ await lookupMock("acct:johndoe@example.com"),
257
335
  null,
258
336
  );
259
337
  });
@@ -280,7 +358,7 @@ test({
280
358
  // Test with maxRedirection: 1 (should fail; mock has 2 redirects)
281
359
  redirectCount = 0;
282
360
  deepStrictEqual(
283
- await lookupWebFinger("acct:johndoe@example.com", {
361
+ await lookupMock("acct:johndoe@example.com", {
284
362
  maxRedirection: 1,
285
363
  }),
286
364
  null,
@@ -290,7 +368,7 @@ test({
290
368
  // redirects, and `maxRedirection: N` follows up to N redirects)
291
369
  redirectCount = 0;
292
370
  deepStrictEqual(
293
- await lookupWebFinger("acct:johndoe@example.com", {
371
+ await lookupMock("acct:johndoe@example.com", {
294
372
  maxRedirection: 2,
295
373
  }),
296
374
  expected,
@@ -299,7 +377,7 @@ test({
299
377
  // Test with maxRedirection: 3 (should succeed)
300
378
  redirectCount = 0;
301
379
  deepStrictEqual(
302
- await lookupWebFinger("acct:johndoe@example.com", {
380
+ await lookupMock("acct:johndoe@example.com", {
303
381
  maxRedirection: 3,
304
382
  }),
305
383
  expected,
@@ -308,7 +386,7 @@ test({
308
386
  // Test with default maxRedirection: 5 (should succeed)
309
387
  redirectCount = 0;
310
388
  deepStrictEqual(
311
- await lookupWebFinger("acct:johndoe@example.com"),
389
+ await lookupMock("acct:johndoe@example.com"),
312
390
  expected,
313
391
  );
314
392
  });
@@ -338,7 +416,7 @@ test({
338
416
  },
339
417
  );
340
418
  deepStrictEqual(
341
- await lookupWebFinger("acct:johndoe@example.com", {
419
+ await lookupMock("acct:johndoe@example.com", {
342
420
  maxRedirection: 1,
343
421
  }),
344
422
  expected,
@@ -363,7 +441,7 @@ test({
363
441
  },
364
442
  );
365
443
  deepStrictEqual(
366
- await lookupWebFinger("acct:johndoe@example.com", {
444
+ await lookupMock("acct:johndoe@example.com", {
367
445
  maxRedirection: 1,
368
446
  }),
369
447
  null,
@@ -380,14 +458,14 @@ test({
380
458
  resolve({ body: expected });
381
459
  }, 1000);
382
460
 
383
- return () => clearTimeout(timeoutId);
461
+ responseTimers.push(timeoutId);
384
462
  }),
385
463
  );
386
464
 
387
465
  await t.step("request cancellation", async () => {
388
466
  // Test cancelling a request immediately using AbortController
389
467
  const controller = new AbortController();
390
- const promise = lookupWebFinger("acct:johndoe@example.com", {
468
+ const promise = lookupMock("acct:johndoe@example.com", {
391
469
  signal: controller.signal,
392
470
  });
393
471
 
@@ -413,7 +491,7 @@ test({
413
491
  resolve({ body: expected });
414
492
  }, 1000);
415
493
 
416
- return () => clearTimeout(timeoutId);
494
+ responseTimers.push(timeoutId);
417
495
  });
418
496
  },
419
497
  );
@@ -421,7 +499,7 @@ test({
421
499
  await t.step("cancellation during redirection", async () => {
422
500
  // Test cancelling a request during redirection process
423
501
  const controller = new AbortController();
424
- const promise = lookupWebFinger("acct:johndoe@example.com", {
502
+ const promise = lookupMock("acct:johndoe@example.com", {
425
503
  signal: controller.signal,
426
504
  });
427
505
 
@@ -439,7 +517,7 @@ test({
439
517
  resolve({ body: expected });
440
518
  }, 500);
441
519
 
442
- return () => clearTimeout(timeoutId);
520
+ responseTimers.push(timeoutId);
443
521
  }),
444
522
  );
445
523
 
@@ -449,7 +527,7 @@ test({
449
527
  controller.abort();
450
528
 
451
529
  // Use a signal that was already aborted before starting the request
452
- const result = await lookupWebFinger("acct:johndoe@example.com", {
530
+ const result = await lookupMock("acct:johndoe@example.com", {
453
531
  signal: controller.signal,
454
532
  });
455
533
  deepStrictEqual(result, null);
@@ -464,12 +542,13 @@ test({
464
542
  await t.step("successful request with signal", async () => {
465
543
  // Test successful request with a normal AbortController signal
466
544
  const controller = new AbortController();
467
- const result = await lookupWebFinger("acct:johndoe@example.com", {
545
+ const result = await lookupMock("acct:johndoe@example.com", {
468
546
  signal: controller.signal,
469
547
  });
470
548
  deepStrictEqual(result, expected);
471
549
  });
472
550
  } finally {
551
+ for (const timer of responseTimers) clearTimeout(timer);
473
552
  fetchMock.removeRoutes();
474
553
  fetchMock.hardReset();
475
554
  }
@@ -496,7 +575,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
496
575
  { body: expected },
497
576
  );
498
577
  const [meterProvider, recorder] = createTestMeterProvider();
499
- const result = await lookupWebFinger("acct:johndoe@example.com", {
578
+ const result = await lookupMock("acct:johndoe@example.com", {
500
579
  meterProvider,
501
580
  });
502
581
  deepStrictEqual(result, expected);
@@ -546,7 +625,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
546
625
  { body: { subject: "https://example.com/foo", links: [] } },
547
626
  );
548
627
  const [meterProvider, recorder] = createTestMeterProvider();
549
- await lookupWebFinger("https://example.com/foo", { meterProvider });
628
+ await lookupMock("https://example.com/foo", { meterProvider });
550
629
  const counters = recorder.getMeasurements("webfinger.lookup");
551
630
  deepStrictEqual(counters.length, 1);
552
631
  deepStrictEqual(
@@ -569,7 +648,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
569
648
  { body: { subject: "https://example.com:8443/foo", links: [] } },
570
649
  );
571
650
  const [meterProvider, recorder] = createTestMeterProvider();
572
- await lookupWebFinger("https://example.com:8443/foo", {
651
+ await lookupMock("https://example.com:8443/foo", {
573
652
  meterProvider,
574
653
  });
575
654
  const counter = recorder.getMeasurement("webfinger.lookup");
@@ -588,7 +667,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
588
667
  { status: 404 },
589
668
  );
590
669
  const [meterProvider, recorder] = createTestMeterProvider();
591
- const result = await lookupWebFinger("acct:johndoe@example.com", {
670
+ const result = await lookupMock("acct:johndoe@example.com", {
592
671
  meterProvider,
593
672
  });
594
673
  deepStrictEqual(result, null);
@@ -623,7 +702,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
623
702
  { status: 410 },
624
703
  );
625
704
  const [meterProvider, recorder] = createTestMeterProvider();
626
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
705
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
627
706
  const counter = recorder.getMeasurement("webfinger.lookup");
628
707
  ok(counter != null);
629
708
  deepStrictEqual(
@@ -642,7 +721,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
642
721
  { status: 500 },
643
722
  );
644
723
  const [meterProvider, recorder] = createTestMeterProvider();
645
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
724
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
646
725
  const counter = recorder.getMeasurement("webfinger.lookup");
647
726
  ok(counter != null);
648
727
  deepStrictEqual(
@@ -662,7 +741,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
662
741
  { body: "not json" },
663
742
  );
664
743
  const [meterProvider, recorder] = createTestMeterProvider();
665
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
744
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
666
745
  const counter = recorder.getMeasurement("webfinger.lookup");
667
746
  ok(counter != null);
668
747
  deepStrictEqual(
@@ -678,7 +757,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
678
757
  async () => {
679
758
  fetchMock.removeRoutes();
680
759
  const [meterProvider, recorder] = createTestMeterProvider();
681
- const result = await lookupWebFinger(
760
+ const result = await lookupMock(
682
761
  "acct:johndoe@fedify-test.internal",
683
762
  { meterProvider },
684
763
  );
@@ -705,7 +784,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
705
784
  "records result=invalid for malformed acct: resources",
706
785
  async () => {
707
786
  const [meterProvider, recorder] = createTestMeterProvider();
708
- const result = await lookupWebFinger("acct:johndoe", { meterProvider });
787
+ const result = await lookupMock("acct:johndoe", { meterProvider });
709
788
  deepStrictEqual(result, null);
710
789
  const counter = recorder.getMeasurement("webfinger.lookup");
711
790
  ok(counter != null);
@@ -742,7 +821,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
742
821
  const [meterProvider, recorder] = createTestMeterProvider();
743
822
  const result = await withTimeout(
744
823
  () =>
745
- lookupWebFinger("acct:johndoe@example.com", {
824
+ lookupMock("acct:johndoe@example.com", {
746
825
  meterProvider,
747
826
  maxRedirection: 3,
748
827
  }),
@@ -775,7 +854,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
775
854
  },
776
855
  );
777
856
  const [meterProvider, recorder] = createTestMeterProvider();
778
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
857
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
779
858
  const counter = recorder.getMeasurement("webfinger.lookup");
780
859
  ok(counter != null);
781
860
  deepStrictEqual(
@@ -798,7 +877,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
798
877
  },
799
878
  );
800
879
  const [meterProvider, recorder] = createTestMeterProvider();
801
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
880
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
802
881
  const counter = recorder.getMeasurement("webfinger.lookup");
803
882
  ok(counter != null);
804
883
  deepStrictEqual(
@@ -825,7 +904,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
825
904
  },
826
905
  );
827
906
  const [meterProvider, recorder] = createTestMeterProvider();
828
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
907
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
829
908
  const counter = recorder.getMeasurement("webfinger.lookup");
830
909
  ok(counter != null);
831
910
  deepStrictEqual(
@@ -852,7 +931,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
852
931
  // attempt to build a host from the URL, fail, and return null.
853
932
  // The metric still records, and its scheme attribute must be
854
933
  // bucketed as `other`.
855
- await lookupWebFinger("ssh://example.com/foo", { meterProvider });
934
+ await lookupMock("ssh://example.com/foo", { meterProvider });
856
935
  const counter = recorder.getMeasurement("webfinger.lookup");
857
936
  ok(counter != null);
858
937
  deepStrictEqual(
@@ -871,7 +950,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
871
950
  { body: expected },
872
951
  );
873
952
  const [_unused, recorder] = createTestMeterProvider();
874
- await lookupWebFinger("acct:johndoe@example.com");
953
+ await lookupMock("acct:johndoe@example.com");
875
954
  deepStrictEqual(
876
955
  recorder.getMeasurements("webfinger.lookup").length,
877
956
  0,
@@ -909,14 +988,14 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
909
988
  ),
910
989
  );
911
990
  deepStrictEqual(
912
- await lookupWebFinger("acct:alice@example.com", {
991
+ await lookupMock("acct:alice@example.com", {
913
992
  allowPrivateAddress: true,
914
993
  }),
915
994
  null,
916
995
  );
917
996
  oversized = false;
918
997
  deepStrictEqual(
919
- await lookupWebFinger("acct:alice@example.com", {
998
+ await lookupMock("acct:alice@example.com", {
920
999
  allowPrivateAddress: true,
921
1000
  }),
922
1001
  { subject: "acct:alice@example.com" },
@@ -927,28 +1006,18 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
927
1006
  });
928
1007
 
929
1008
  test("lookupWebFinger() logs DNS failures as such", async (t) => {
930
- // The validator skips DNS when Deno has no network permission. Checked
931
- // here rather than with top-level await, which the CommonJS build rejects.
932
- if (
933
- "Deno" in globalThis &&
934
- (await Deno.permissions.query({ name: "net" })).state !== "granted"
935
- ) return;
936
1009
  for (const result of ["throws", "empty", "private"] as const) {
937
1010
  await t.step(result, async () => {
938
- // Stubbing works only because vocab-runtime's url.ts uses the default
939
- // node:dns/promises import; see the FIXME there.
940
- const originalLookup = dns.lookup;
941
- dns.lookup = (() =>
1011
+ const lookup = () =>
942
1012
  result === "throws"
943
1013
  ? Promise.reject(new Error("Resolver unavailable"))
944
1014
  : Promise.resolve(
945
1015
  result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
946
- )) as typeof dns.lookup;
1016
+ );
947
1017
  const records: LogRecord[] = [];
948
1018
  await configure({
949
1019
  sinks: {
950
- buffer: (record) =>
951
- records.push(record),
1020
+ buffer: (record) => records.push(record),
952
1021
  },
953
1022
  loggers: [
954
1023
  { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
@@ -958,7 +1027,13 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
958
1027
  });
959
1028
  try {
960
1029
  deepStrictEqual(
961
- await lookupWebFinger("acct:alice@dns-failure.invalid"),
1030
+ await lookupResourceWithDependencies(
1031
+ "acct:alice@dns-failure.invalid",
1032
+ {},
1033
+ {
1034
+ openHop: (url) => openPublicFetch(url, { lookup }),
1035
+ },
1036
+ ),
962
1037
  null,
963
1038
  );
964
1039
  const failures = records.filter((r) =>
@@ -984,7 +1059,6 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
984
1059
  );
985
1060
  } finally {
986
1061
  await reset();
987
- dns.lookup = originalLookup;
988
1062
  }
989
1063
  });
990
1064
  }
@@ -994,11 +1068,10 @@ test("lookupWebFinger() with withDiagnostics: true", {
994
1068
  sanitizeOps: false,
995
1069
  sanitizeResources: false,
996
1070
  }, async (t) => {
997
- // No fetch mock is active yet, so this reaches the real (non-existent)
998
- // host and fails at the network layer, the same way the "connection
999
- // refused" step above does.
1071
+ // Inject the same failed session as the connection-refused transport test,
1072
+ // so the diagnostics path does not depend on real DNS or networking.
1000
1073
  await t.step("network_error", async () => {
1001
- const outcome = await lookupWebFinger(
1074
+ const outcome = await lookupMock(
1002
1075
  "acct:johndoe@fedify-test.internal",
1003
1076
  { withDiagnostics: true },
1004
1077
  );
@@ -1023,7 +1096,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1023
1096
  "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
1024
1097
  { body: expected },
1025
1098
  );
1026
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1099
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1027
1100
  withDiagnostics: true,
1028
1101
  });
1029
1102
  deepStrictEqual(outcome.resource, expected);
@@ -1038,7 +1111,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1038
1111
  "begin:https://example.com/.well-known/webfinger?",
1039
1112
  { status: 404 },
1040
1113
  );
1041
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1114
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1042
1115
  withDiagnostics: true,
1043
1116
  });
1044
1117
  deepStrictEqual(outcome.resource, null);
@@ -1053,7 +1126,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1053
1126
  "begin:https://example.com/.well-known/webfinger?",
1054
1127
  { body: "not json" },
1055
1128
  );
1056
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1129
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1057
1130
  withDiagnostics: true,
1058
1131
  });
1059
1132
  deepStrictEqual(outcome.resource, null);
@@ -1074,7 +1147,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1074
1147
  "begin:https://example.com/.well-known/webfinger?",
1075
1148
  { body: "null" },
1076
1149
  );
1077
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1150
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1078
1151
  withDiagnostics: true,
1079
1152
  });
1080
1153
  deepStrictEqual(outcome.resource, null);
@@ -1094,7 +1167,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1094
1167
  "begin:https://example.com/.well-known/webfinger?",
1095
1168
  { body: "[]" },
1096
1169
  );
1097
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1170
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1098
1171
  withDiagnostics: true,
1099
1172
  });
1100
1173
  deepStrictEqual(outcome.resource, null);
@@ -1110,7 +1183,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
1110
1183
  "begin:https://example.com/.well-known/webfinger?",
1111
1184
  { status: 500 },
1112
1185
  );
1113
- const outcome = await lookupWebFinger("acct:johndoe@example.com", {
1186
+ const outcome = await lookupMock("acct:johndoe@example.com", {
1114
1187
  withDiagnostics: true,
1115
1188
  });
1116
1189
  deepStrictEqual(outcome.resource, null);
@@ -1141,7 +1214,10 @@ test(
1141
1214
  "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
1142
1215
  { body: expected },
1143
1216
  );
1144
- const options: LookupWebFingerOptions = { userAgent: "test-agent" };
1217
+ const options: LookupWebFingerOptions = {
1218
+ userAgent: "test-agent",
1219
+ allowPrivateAddress: true,
1220
+ };
1145
1221
  const result = await lookupWebFinger(
1146
1222
  "acct:johndoe@example.com",
1147
1223
  options,
@@ -1153,3 +1229,416 @@ test(
1153
1229
  }
1154
1230
  },
1155
1231
  );
1232
+
1233
+ interface FakeResponse {
1234
+ readonly status?: number;
1235
+ readonly headers?: Record<string, string>;
1236
+ readonly body?: string;
1237
+ /** The `url` of the response, as a pinned fetch reports it. */
1238
+ readonly url?: string;
1239
+ /** Makes cancelling the body fail. */
1240
+ readonly cancelFails?: boolean;
1241
+ /** Makes cancelling the body never settle. */
1242
+ readonly cancelNeverSettles?: boolean;
1243
+ }
1244
+
1245
+ interface HopRecorder {
1246
+ readonly events: string[];
1247
+ readonly openHop: (url: string) => Promise<PublicFetchSession>;
1248
+ /**
1249
+ * Asserts that every opened session is closed, and that every response
1250
+ * body was consumed or cancelled before its session was closed.
1251
+ */
1252
+ assertInvariants(): void;
1253
+ }
1254
+
1255
+ // A fake server that records the lifecycle of each session and response.
1256
+ // Unlike real sessions, closing a fake session never cancels bodies, so that
1257
+ // the caller's own cleanup is observed:
1258
+ function recordHops(respond: (url: URL) => FakeResponse): HopRecorder {
1259
+ const events: string[] = [];
1260
+ const unsettled: string[] = [];
1261
+ let opens = 0;
1262
+ let closes = 0;
1263
+ let responses = 0;
1264
+ return {
1265
+ events,
1266
+ // deno-lint-ignore require-await
1267
+ async openHop(url) {
1268
+ opens++;
1269
+ events.push(`open ${url}`);
1270
+ const states: { id: number; settled: boolean }[] = [];
1271
+ let closed = false;
1272
+ return {
1273
+ url: new URL(url),
1274
+ // deno-lint-ignore require-await
1275
+ async fetch(input) {
1276
+ if (closed) throw new TypeError("The fetch session is closed.");
1277
+ const target = new URL(input instanceof Request ? input.url : input);
1278
+ const id = ++responses;
1279
+ events.push(`fetch ${target.href} #${id}`);
1280
+ const fake = respond(target);
1281
+ const state = { id, settled: false };
1282
+ states.push(state);
1283
+ let sent = false;
1284
+ const body = new ReadableStream<Uint8Array>({
1285
+ pull(controller) {
1286
+ if (!sent && fake.body != null && fake.body !== "") {
1287
+ sent = true;
1288
+ controller.enqueue(new TextEncoder().encode(fake.body));
1289
+ return;
1290
+ }
1291
+ state.settled = true;
1292
+ events.push(`consumed #${id}`);
1293
+ controller.close();
1294
+ },
1295
+ cancel() {
1296
+ state.settled = true;
1297
+ events.push(`cancel #${id}`);
1298
+ if (fake.cancelFails) throw new Error("Cancellation failed");
1299
+ if (fake.cancelNeverSettles) return new Promise(() => {});
1300
+ },
1301
+ }, { highWaterMark: 0 });
1302
+ const response = new Response(body, {
1303
+ status: fake.status ?? 200,
1304
+ headers: fake.headers,
1305
+ });
1306
+ if (fake.url != null) {
1307
+ Object.defineProperty(response, "url", { value: fake.url });
1308
+ }
1309
+ return response;
1310
+ },
1311
+ // deno-lint-ignore require-await
1312
+ async close() {
1313
+ if (closed) return;
1314
+ closed = true;
1315
+ closes++;
1316
+ for (const state of states) {
1317
+ if (!state.settled) unsettled.push(`#${state.id}`);
1318
+ }
1319
+ events.push(`close ${url}`);
1320
+ },
1321
+ };
1322
+ },
1323
+ assertInvariants() {
1324
+ deepStrictEqual(closes, opens);
1325
+ deepStrictEqual(unsettled, []);
1326
+ },
1327
+ };
1328
+ }
1329
+
1330
+ test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
1331
+ const descriptor: ResourceDescriptor = {
1332
+ subject: "acct:johndoe@example.com",
1333
+ links: [],
1334
+ };
1335
+ const first =
1336
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
1337
+ const second = "https://example.com/.well-known/webfinger2";
1338
+
1339
+ await t.step("redirect", async () => {
1340
+ const recorder = recordHops((url) =>
1341
+ url.pathname === "/.well-known/webfinger"
1342
+ ? {
1343
+ status: 302,
1344
+ headers: { Location: "/.well-known/webfinger2" },
1345
+ body: "redirecting",
1346
+ // A pinned response's url has the IP address, which must not be
1347
+ // used as the base of the Location:
1348
+ url: url.href.replace("example.com", "93.184.215.14"),
1349
+ }
1350
+ : { body: JSON.stringify(descriptor) }
1351
+ );
1352
+ deepStrictEqual(
1353
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1354
+ openHop: recorder.openHop,
1355
+ }),
1356
+ descriptor,
1357
+ );
1358
+ deepStrictEqual(recorder.events, [
1359
+ `open ${first}`,
1360
+ `fetch ${first} #1`,
1361
+ "cancel #1",
1362
+ `close ${first}`,
1363
+ `open ${second}`,
1364
+ `fetch ${second} #2`,
1365
+ "consumed #2",
1366
+ `close ${second}`,
1367
+ ]);
1368
+ recorder.assertInvariants();
1369
+ });
1370
+
1371
+ await t.step("non-2xx response", async () => {
1372
+ const recorder = recordHops(() => ({ status: 404, body: "not found" }));
1373
+ deepStrictEqual(
1374
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1375
+ openHop: recorder.openHop,
1376
+ }),
1377
+ null,
1378
+ );
1379
+ deepStrictEqual(recorder.events, [
1380
+ `open ${first}`,
1381
+ `fetch ${first} #1`,
1382
+ "cancel #1",
1383
+ `close ${first}`,
1384
+ ]);
1385
+ recorder.assertInvariants();
1386
+ });
1387
+
1388
+ await t.step("too many redirections", async () => {
1389
+ const recorder = recordHops(() => ({
1390
+ status: 302,
1391
+ headers: { Location: "/.well-known/webfinger2" },
1392
+ body: "redirecting",
1393
+ }));
1394
+ deepStrictEqual(
1395
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {
1396
+ maxRedirection: 1,
1397
+ }, { openHop: recorder.openHop }),
1398
+ null,
1399
+ );
1400
+ deepStrictEqual(recorder.events, [
1401
+ `open ${first}`,
1402
+ `fetch ${first} #1`,
1403
+ "cancel #1",
1404
+ `close ${first}`,
1405
+ `open ${second}`,
1406
+ `fetch ${second} #2`,
1407
+ "cancel #2",
1408
+ `close ${second}`,
1409
+ ]);
1410
+ recorder.assertInvariants();
1411
+ });
1412
+
1413
+ await t.step("failed redirect body cancellation", async () => {
1414
+ const recorder = recordHops((url) =>
1415
+ url.pathname === "/.well-known/webfinger"
1416
+ ? {
1417
+ status: 302,
1418
+ headers: { Location: "/.well-known/webfinger2" },
1419
+ body: "redirecting",
1420
+ cancelFails: true,
1421
+ }
1422
+ : { body: JSON.stringify(descriptor) }
1423
+ );
1424
+ const records: LogRecord[] = [];
1425
+ await configure({
1426
+ sinks: { buffer: (record) => records.push(record) },
1427
+ loggers: [
1428
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
1429
+ { category: ["logtape", "meta"], sinks: [] },
1430
+ ],
1431
+ reset: true,
1432
+ });
1433
+ try {
1434
+ deepStrictEqual(
1435
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1436
+ openHop: recorder.openHop,
1437
+ }),
1438
+ descriptor,
1439
+ );
1440
+ const failures = records.filter((r) =>
1441
+ r.rawMessage ===
1442
+ "Failed to cancel the response body from {url}: {error}"
1443
+ );
1444
+ deepStrictEqual(
1445
+ failures.map((r) => [r.level, r.properties.url]),
1446
+ [["debug", first]],
1447
+ );
1448
+ const { error } = failures[0].properties;
1449
+ ok(error instanceof Error);
1450
+ deepStrictEqual(error.message, "Cancellation failed");
1451
+ } finally {
1452
+ await reset();
1453
+ }
1454
+ deepStrictEqual(recorder.events, [
1455
+ `open ${first}`,
1456
+ `fetch ${first} #1`,
1457
+ "cancel #1",
1458
+ `close ${first}`,
1459
+ `open ${second}`,
1460
+ `fetch ${second} #2`,
1461
+ "consumed #2",
1462
+ `close ${second}`,
1463
+ ]);
1464
+ recorder.assertInvariants();
1465
+ });
1466
+
1467
+ await t.step("never-settling body cancellation", async () => {
1468
+ const recorder = recordHops((url) =>
1469
+ url.pathname === "/.well-known/webfinger"
1470
+ ? {
1471
+ status: 302,
1472
+ headers: { Location: "/.well-known/webfinger2" },
1473
+ body: "redirecting",
1474
+ cancelNeverSettles: true,
1475
+ }
1476
+ : { status: 404, body: "not found", cancelNeverSettles: true }
1477
+ );
1478
+ deepStrictEqual(
1479
+ await settleWithin(
1480
+ lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1481
+ openHop: recorder.openHop,
1482
+ }),
1483
+ ),
1484
+ null,
1485
+ );
1486
+ deepStrictEqual(recorder.events, [
1487
+ `open ${first}`,
1488
+ `fetch ${first} #1`,
1489
+ "cancel #1",
1490
+ `close ${first}`,
1491
+ `open ${second}`,
1492
+ `fetch ${second} #2`,
1493
+ "cancel #2",
1494
+ `close ${second}`,
1495
+ ]);
1496
+ recorder.assertInvariants();
1497
+ });
1498
+ });
1499
+
1500
+ /**
1501
+ * Rejects if the given promise does not settle within the timeout, so that
1502
+ * a hang fails the test instead of blocking it.
1503
+ */
1504
+ async function settleWithin<T>(promise: Promise<T>, ms = 5000): Promise<T> {
1505
+ let timer: ReturnType<typeof setTimeout> | undefined;
1506
+ try {
1507
+ return await Promise.race([
1508
+ promise,
1509
+ new Promise<never>((_, reject) => {
1510
+ timer = setTimeout(
1511
+ () => reject(new Error(`Did not settle within ${ms} ms.`)),
1512
+ ms,
1513
+ );
1514
+ }),
1515
+ ]);
1516
+ } finally {
1517
+ clearTimeout(timer);
1518
+ }
1519
+ }
1520
+
1521
+ // A body that a real session cannot cancel to completion: fetch-mock returns
1522
+ // a clone (a tee) of a Response instance, and the cancellation of a branch of
1523
+ // a tee settles only after the other branch is cancelled too. Its source's
1524
+ // cancel callback never settles either:
1525
+ function neverCancellableResponse(init: ResponseInit): Response {
1526
+ return new Response(
1527
+ new ReadableStream<Uint8Array>({
1528
+ start(controller) {
1529
+ controller.enqueue(new TextEncoder().encode("never cancelled"));
1530
+ },
1531
+ cancel: () => new Promise(() => {}),
1532
+ }),
1533
+ init,
1534
+ );
1535
+ }
1536
+
1537
+ test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
1538
+ const descriptor: ResourceDescriptor = {
1539
+ subject: "acct:johndoe@example.com",
1540
+ links: [],
1541
+ };
1542
+ fetchMock.mockGlobal();
1543
+ try {
1544
+ await t.step("redirect", async () => {
1545
+ fetchMock.removeRoutes();
1546
+ fetchMock.get(
1547
+ "begin:https://example.com/.well-known/webfinger?",
1548
+ () =>
1549
+ neverCancellableResponse({
1550
+ status: 302,
1551
+ headers: { Location: "/.well-known/webfinger2" },
1552
+ }),
1553
+ );
1554
+ fetchMock.get(
1555
+ "https://example.com/.well-known/webfinger2",
1556
+ () =>
1557
+ new Response(JSON.stringify(descriptor), {
1558
+ headers: { "Content-Type": "application/jrd+json" },
1559
+ }),
1560
+ );
1561
+ deepStrictEqual(
1562
+ await settleWithin(lookupDirect("acct:johndoe@example.com")),
1563
+ descriptor,
1564
+ );
1565
+ });
1566
+
1567
+ await t.step("non-2xx response", async () => {
1568
+ fetchMock.removeRoutes();
1569
+ fetchMock.get(
1570
+ "begin:https://example.com/.well-known/webfinger?",
1571
+ () => neverCancellableResponse({ status: 404 }),
1572
+ );
1573
+ deepStrictEqual(
1574
+ await settleWithin(lookupDirect("acct:johndoe@example.com")),
1575
+ null,
1576
+ );
1577
+ });
1578
+ } finally {
1579
+ fetchMock.hardReset();
1580
+ }
1581
+ });
1582
+
1583
+ nodeTest(
1584
+ "lookupWebFinger() records network_error for rejected hop addresses",
1585
+ async () => {
1586
+ for (
1587
+ const lookup of [
1588
+ () => Promise.reject(new Error("DNS unavailable")),
1589
+ () => Promise.resolve([]),
1590
+ () => Promise.resolve([{ address: "127.0.0.1", family: 4 }]),
1591
+ ]
1592
+ ) {
1593
+ const [meterProvider, recorder] = createTestMeterProvider();
1594
+ const result = await lookupWebFingerWithDependencies(
1595
+ "acct:alice@dns-failure.invalid",
1596
+ { meterProvider },
1597
+ { openHop: (url) => openPublicFetch(url, { lookup }) },
1598
+ );
1599
+ deepStrictEqual(result, null);
1600
+ const counter = recorder.getMeasurement("webfinger.lookup");
1601
+ ok(counter != null);
1602
+ deepStrictEqual(
1603
+ counter.attributes["webfinger.lookup.result"],
1604
+ "network_error",
1605
+ );
1606
+ deepStrictEqual(
1607
+ counter.attributes["activitypub.remote.host"],
1608
+ "dns-failure.invalid",
1609
+ );
1610
+ deepStrictEqual("http.response.status_code" in counter.attributes, false);
1611
+ }
1612
+ },
1613
+ );
1614
+
1615
+ nodeTest(
1616
+ "WebFinger diagnostics classify non-object JSON after closing the hop",
1617
+ async () => {
1618
+ for (const body of ["null", "[]", "42", '"text"', "true"]) {
1619
+ const hops = recordHops(() => ({ body }));
1620
+ const [meterProvider, recorder] = createTestMeterProvider();
1621
+ const outcome = await lookupWebFingerWithDependencies(
1622
+ "acct:alice@example.com",
1623
+ { withDiagnostics: true, meterProvider },
1624
+ { openHop: hops.openHop },
1625
+ );
1626
+ deepStrictEqual(outcome, {
1627
+ resource: null,
1628
+ result: "invalid",
1629
+ statusCode: 200,
1630
+ remoteHost: "example.com",
1631
+ });
1632
+ deepStrictEqual(
1633
+ hops.events.at(-1),
1634
+ "close https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com",
1635
+ );
1636
+ hops.assertInvariants();
1637
+ deepStrictEqual(
1638
+ recorder.getMeasurement("webfinger.lookup")
1639
+ ?.attributes["webfinger.lookup.result"],
1640
+ "invalid",
1641
+ );
1642
+ }
1643
+ },
1644
+ );