@fedify/webfinger 2.3.14 → 2.3.15

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,112 @@ 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 } from "./lookup.ts";
8
+ import {
9
+ type LookupWebFingerOptions,
10
+ lookupWebFingerWithDependencies,
11
+ } from "./lookup.ts";
12
+ import {
13
+ type LookupWebFingerDependencies,
14
+ lookupWebFingerInternal,
15
+ } from "./lookup-impl.ts";
16
+ import {
17
+ openDirectFetch,
18
+ openPublicFetch,
19
+ type PublicFetchSession,
20
+ } from "@fedify/vocab-runtime/internal";
21
+ import type { LookupAddress } from "node:dns";
22
+ import { test as nodeTest } from "node:test";
23
+
24
+ // Inject only the transport boundary, so the public tracing and metrics
25
+ // wrapper remains under test while fetch-mock sees the original hostname.
26
+ function lookupMock(
27
+ resource: URL | string,
28
+ options: LookupWebFingerOptions = {},
29
+ ): Promise<ResourceDescriptor | null> {
30
+ if (resource.toString().includes("fedify-test.internal")) {
31
+ return lookupWebFingerWithDependencies(resource, options, {
32
+ openHop: (url) =>
33
+ Promise.resolve({
34
+ url: new URL(url),
35
+ fetch: () => Promise.reject(new TypeError("Connection refused")),
36
+ close: () => Promise.resolve(),
37
+ }),
38
+ });
39
+ }
40
+ return lookupWebFingerWithDependencies(resource, options, {
41
+ openHop: options.allowPrivateAddress === true
42
+ ? openDirectFetch
43
+ : openExampleDirectly,
44
+ });
45
+ }
46
+
47
+ function lookupDirect(
48
+ resource: URL | string,
49
+ options?: LookupWebFingerOptions,
50
+ ): Promise<ResourceDescriptor | null> {
51
+ return lookupWebFingerWithDependencies(resource, options, {
52
+ openHop: openDirectFetch,
53
+ });
54
+ }
55
+
56
+ function lookupResolving(
57
+ addresses: readonly LookupAddress[],
58
+ ): (hostname: string) => Promise<readonly LookupAddress[]> {
59
+ return () => Promise.resolve(addresses);
60
+ }
61
+
62
+ function openExampleDirectly(url: string): Promise<PublicFetchSession> {
63
+ return new URL(url).hostname === "example.com"
64
+ ? openDirectFetch(url)
65
+ : openPublicFetch(url, {
66
+ lookup: lookupResolving([{ address: "93.184.215.14", family: 4 }]),
67
+ });
68
+ }
69
+
70
+ async function lookupResourceWithDependencies(
71
+ resource: URL | string,
72
+ options: LookupWebFingerOptions,
73
+ deps: LookupWebFingerDependencies,
74
+ ): Promise<ResourceDescriptor | null> {
75
+ return (await lookupWebFingerInternal(resource, options, deps)).resource;
76
+ }
10
77
 
11
78
  test({
12
79
  name: "lookupWebFinger()",
13
80
  sanitizeOps: false,
14
81
  sanitizeResources: false,
15
82
  async fn(t) {
83
+ const responseTimers: ReturnType<typeof setTimeout>[] = [];
16
84
  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);
85
+ deepStrictEqual(await lookupMock("acct:johndoe"), null);
86
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe")), null);
87
+ deepStrictEqual(await lookupMock("acct:johndoe@"), null);
88
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe@")), null);
21
89
  // Per RFC 7565, the acct: authority is bare `host`: no path,
22
90
  // query, or fragment is allowed. Reject such inputs rather than
23
91
  // forwarding them to a remote WebFinger lookup.
24
92
  deepStrictEqual(
25
- await lookupWebFinger("acct:johndoe@example.com/exploit"),
93
+ await lookupMock("acct:johndoe@example.com/exploit"),
26
94
  null,
27
95
  );
28
96
  deepStrictEqual(
29
- await lookupWebFinger("acct:johndoe@example.com?x=1"),
97
+ await lookupMock("acct:johndoe@example.com?x=1"),
30
98
  null,
31
99
  );
32
100
  deepStrictEqual(
33
- await lookupWebFinger("acct:johndoe@example.com#frag"),
101
+ await lookupMock("acct:johndoe@example.com#frag"),
34
102
  null,
35
103
  );
36
104
  });
37
105
 
38
106
  await t.step("connection refused", async () => {
39
107
  deepStrictEqual(
40
- await lookupWebFinger("acct:johndoe@fedify-test.internal"),
108
+ await lookupMock("acct:johndoe@fedify-test.internal"),
41
109
  null,
42
110
  );
43
111
  deepStrictEqual(
44
- await lookupWebFinger("https://fedify-test.internal/foo"),
112
+ await lookupMock("https://fedify-test.internal/foo"),
45
113
  null,
46
114
  );
47
115
  });
@@ -59,10 +127,10 @@ test({
59
127
 
60
128
  await t.step("not found", async () => {
61
129
  deepStrictEqual(
62
- await lookupWebFinger("acct:johndoe@example.com"),
130
+ await lookupMock("acct:johndoe@example.com"),
63
131
  null,
64
132
  );
65
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
133
+ deepStrictEqual(await lookupMock("https://example.com/foo"), null);
66
134
  });
67
135
 
68
136
  const expected: ResourceDescriptor = {
@@ -77,7 +145,7 @@ test({
77
145
 
78
146
  await t.step("acct", async () => {
79
147
  deepStrictEqual(
80
- await lookupWebFinger("acct:johndoe@example.com"),
148
+ await lookupMock("acct:johndoe@example.com"),
81
149
  expected,
82
150
  );
83
151
  });
@@ -94,7 +162,7 @@ test({
94
162
 
95
163
  await t.step("https", async () => {
96
164
  deepStrictEqual(
97
- await lookupWebFinger("https://example.com/foo"),
165
+ await lookupMock("https://example.com/foo"),
98
166
  expected2,
99
167
  );
100
168
  });
@@ -115,7 +183,7 @@ test({
115
183
  // host extraction (after the last `@`) applies to `mailto:` just
116
184
  // like `acct:`.
117
185
  deepStrictEqual(
118
- await lookupWebFinger("mailto:juliet@example.com"),
186
+ await lookupMock("mailto:juliet@example.com"),
119
187
  mailtoExpected,
120
188
  );
121
189
  });
@@ -137,7 +205,7 @@ test({
137
205
  // must accept them and forward the full resource URI to the
138
206
  // WebFinger endpoint.
139
207
  deepStrictEqual(
140
- await lookupWebFinger("mailto:juliet@example.com?subject=Hi"),
208
+ await lookupMock("mailto:juliet@example.com?subject=Hi"),
141
209
  mailtoQueryExpected,
142
210
  );
143
211
  });
@@ -150,7 +218,7 @@ test({
150
218
 
151
219
  await t.step("invalid response", async () => {
152
220
  deepStrictEqual(
153
- await lookupWebFinger("acct:johndoe@example.com"),
221
+ await lookupMock("acct:johndoe@example.com"),
154
222
  null,
155
223
  );
156
224
  });
@@ -171,9 +239,9 @@ test({
171
239
  );
172
240
 
173
241
  await t.step("private address", async () => {
174
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
242
+ deepStrictEqual(await lookupMock("acct:test@localhost"), null);
175
243
  deepStrictEqual(
176
- await lookupWebFinger("acct:test@localhost", {
244
+ await lookupMock("acct:test@localhost", {
177
245
  allowPrivateAddress: true,
178
246
  }),
179
247
  {
@@ -204,7 +272,7 @@ test({
204
272
 
205
273
  await t.step("redirection", async () => {
206
274
  deepStrictEqual(
207
- await lookupWebFinger("acct:johndoe@example.com"),
275
+ await lookupMock("acct:johndoe@example.com"),
208
276
  expected,
209
277
  );
210
278
  });
@@ -220,7 +288,7 @@ test({
220
288
 
221
289
  await t.step("infinite redirection", async () => {
222
290
  const result = await withTimeout(
223
- () => lookupWebFinger("acct:johndoe@example.com"),
291
+ () => lookupMock("acct:johndoe@example.com"),
224
292
  2000,
225
293
  );
226
294
  deepStrictEqual(result, null);
@@ -237,7 +305,7 @@ test({
237
305
 
238
306
  await t.step("redirection to different protocol", async () => {
239
307
  deepStrictEqual(
240
- await lookupWebFinger("acct:johndoe@example.com"),
308
+ await lookupMock("acct:johndoe@example.com"),
241
309
  null,
242
310
  );
243
311
  });
@@ -253,7 +321,7 @@ test({
253
321
 
254
322
  await t.step("redirection to private address", async () => {
255
323
  deepStrictEqual(
256
- await lookupWebFinger("acct:johndoe@example.com"),
324
+ await lookupMock("acct:johndoe@example.com"),
257
325
  null,
258
326
  );
259
327
  });
@@ -280,7 +348,7 @@ test({
280
348
  // Test with maxRedirection: 1 (should fail; mock has 2 redirects)
281
349
  redirectCount = 0;
282
350
  deepStrictEqual(
283
- await lookupWebFinger("acct:johndoe@example.com", {
351
+ await lookupMock("acct:johndoe@example.com", {
284
352
  maxRedirection: 1,
285
353
  }),
286
354
  null,
@@ -290,7 +358,7 @@ test({
290
358
  // redirects, and `maxRedirection: N` follows up to N redirects)
291
359
  redirectCount = 0;
292
360
  deepStrictEqual(
293
- await lookupWebFinger("acct:johndoe@example.com", {
361
+ await lookupMock("acct:johndoe@example.com", {
294
362
  maxRedirection: 2,
295
363
  }),
296
364
  expected,
@@ -299,7 +367,7 @@ test({
299
367
  // Test with maxRedirection: 3 (should succeed)
300
368
  redirectCount = 0;
301
369
  deepStrictEqual(
302
- await lookupWebFinger("acct:johndoe@example.com", {
370
+ await lookupMock("acct:johndoe@example.com", {
303
371
  maxRedirection: 3,
304
372
  }),
305
373
  expected,
@@ -308,7 +376,7 @@ test({
308
376
  // Test with default maxRedirection: 5 (should succeed)
309
377
  redirectCount = 0;
310
378
  deepStrictEqual(
311
- await lookupWebFinger("acct:johndoe@example.com"),
379
+ await lookupMock("acct:johndoe@example.com"),
312
380
  expected,
313
381
  );
314
382
  });
@@ -338,7 +406,7 @@ test({
338
406
  },
339
407
  );
340
408
  deepStrictEqual(
341
- await lookupWebFinger("acct:johndoe@example.com", {
409
+ await lookupMock("acct:johndoe@example.com", {
342
410
  maxRedirection: 1,
343
411
  }),
344
412
  expected,
@@ -363,7 +431,7 @@ test({
363
431
  },
364
432
  );
365
433
  deepStrictEqual(
366
- await lookupWebFinger("acct:johndoe@example.com", {
434
+ await lookupMock("acct:johndoe@example.com", {
367
435
  maxRedirection: 1,
368
436
  }),
369
437
  null,
@@ -380,14 +448,14 @@ test({
380
448
  resolve({ body: expected });
381
449
  }, 1000);
382
450
 
383
- return () => clearTimeout(timeoutId);
451
+ responseTimers.push(timeoutId);
384
452
  }),
385
453
  );
386
454
 
387
455
  await t.step("request cancellation", async () => {
388
456
  // Test cancelling a request immediately using AbortController
389
457
  const controller = new AbortController();
390
- const promise = lookupWebFinger("acct:johndoe@example.com", {
458
+ const promise = lookupMock("acct:johndoe@example.com", {
391
459
  signal: controller.signal,
392
460
  });
393
461
 
@@ -413,7 +481,7 @@ test({
413
481
  resolve({ body: expected });
414
482
  }, 1000);
415
483
 
416
- return () => clearTimeout(timeoutId);
484
+ responseTimers.push(timeoutId);
417
485
  });
418
486
  },
419
487
  );
@@ -421,7 +489,7 @@ test({
421
489
  await t.step("cancellation during redirection", async () => {
422
490
  // Test cancelling a request during redirection process
423
491
  const controller = new AbortController();
424
- const promise = lookupWebFinger("acct:johndoe@example.com", {
492
+ const promise = lookupMock("acct:johndoe@example.com", {
425
493
  signal: controller.signal,
426
494
  });
427
495
 
@@ -439,7 +507,7 @@ test({
439
507
  resolve({ body: expected });
440
508
  }, 500);
441
509
 
442
- return () => clearTimeout(timeoutId);
510
+ responseTimers.push(timeoutId);
443
511
  }),
444
512
  );
445
513
 
@@ -449,7 +517,7 @@ test({
449
517
  controller.abort();
450
518
 
451
519
  // Use a signal that was already aborted before starting the request
452
- const result = await lookupWebFinger("acct:johndoe@example.com", {
520
+ const result = await lookupMock("acct:johndoe@example.com", {
453
521
  signal: controller.signal,
454
522
  });
455
523
  deepStrictEqual(result, null);
@@ -464,12 +532,13 @@ test({
464
532
  await t.step("successful request with signal", async () => {
465
533
  // Test successful request with a normal AbortController signal
466
534
  const controller = new AbortController();
467
- const result = await lookupWebFinger("acct:johndoe@example.com", {
535
+ const result = await lookupMock("acct:johndoe@example.com", {
468
536
  signal: controller.signal,
469
537
  });
470
538
  deepStrictEqual(result, expected);
471
539
  });
472
540
  } finally {
541
+ for (const timer of responseTimers) clearTimeout(timer);
473
542
  fetchMock.removeRoutes();
474
543
  fetchMock.hardReset();
475
544
  }
@@ -496,7 +565,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
496
565
  { body: expected },
497
566
  );
498
567
  const [meterProvider, recorder] = createTestMeterProvider();
499
- const result = await lookupWebFinger("acct:johndoe@example.com", {
568
+ const result = await lookupMock("acct:johndoe@example.com", {
500
569
  meterProvider,
501
570
  });
502
571
  deepStrictEqual(result, expected);
@@ -546,7 +615,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
546
615
  { body: { subject: "https://example.com/foo", links: [] } },
547
616
  );
548
617
  const [meterProvider, recorder] = createTestMeterProvider();
549
- await lookupWebFinger("https://example.com/foo", { meterProvider });
618
+ await lookupMock("https://example.com/foo", { meterProvider });
550
619
  const counters = recorder.getMeasurements("webfinger.lookup");
551
620
  deepStrictEqual(counters.length, 1);
552
621
  deepStrictEqual(
@@ -569,7 +638,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
569
638
  { body: { subject: "https://example.com:8443/foo", links: [] } },
570
639
  );
571
640
  const [meterProvider, recorder] = createTestMeterProvider();
572
- await lookupWebFinger("https://example.com:8443/foo", {
641
+ await lookupMock("https://example.com:8443/foo", {
573
642
  meterProvider,
574
643
  });
575
644
  const counter = recorder.getMeasurement("webfinger.lookup");
@@ -588,7 +657,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
588
657
  { status: 404 },
589
658
  );
590
659
  const [meterProvider, recorder] = createTestMeterProvider();
591
- const result = await lookupWebFinger("acct:johndoe@example.com", {
660
+ const result = await lookupMock("acct:johndoe@example.com", {
592
661
  meterProvider,
593
662
  });
594
663
  deepStrictEqual(result, null);
@@ -623,7 +692,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
623
692
  { status: 410 },
624
693
  );
625
694
  const [meterProvider, recorder] = createTestMeterProvider();
626
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
695
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
627
696
  const counter = recorder.getMeasurement("webfinger.lookup");
628
697
  ok(counter != null);
629
698
  deepStrictEqual(
@@ -642,7 +711,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
642
711
  { status: 500 },
643
712
  );
644
713
  const [meterProvider, recorder] = createTestMeterProvider();
645
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
714
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
646
715
  const counter = recorder.getMeasurement("webfinger.lookup");
647
716
  ok(counter != null);
648
717
  deepStrictEqual(
@@ -662,7 +731,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
662
731
  { body: "not json" },
663
732
  );
664
733
  const [meterProvider, recorder] = createTestMeterProvider();
665
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
734
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
666
735
  const counter = recorder.getMeasurement("webfinger.lookup");
667
736
  ok(counter != null);
668
737
  deepStrictEqual(
@@ -678,7 +747,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
678
747
  async () => {
679
748
  fetchMock.removeRoutes();
680
749
  const [meterProvider, recorder] = createTestMeterProvider();
681
- const result = await lookupWebFinger(
750
+ const result = await lookupMock(
682
751
  "acct:johndoe@fedify-test.internal",
683
752
  { meterProvider },
684
753
  );
@@ -705,7 +774,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
705
774
  "records result=invalid for malformed acct: resources",
706
775
  async () => {
707
776
  const [meterProvider, recorder] = createTestMeterProvider();
708
- const result = await lookupWebFinger("acct:johndoe", { meterProvider });
777
+ const result = await lookupMock("acct:johndoe", { meterProvider });
709
778
  deepStrictEqual(result, null);
710
779
  const counter = recorder.getMeasurement("webfinger.lookup");
711
780
  ok(counter != null);
@@ -742,7 +811,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
742
811
  const [meterProvider, recorder] = createTestMeterProvider();
743
812
  const result = await withTimeout(
744
813
  () =>
745
- lookupWebFinger("acct:johndoe@example.com", {
814
+ lookupMock("acct:johndoe@example.com", {
746
815
  meterProvider,
747
816
  maxRedirection: 3,
748
817
  }),
@@ -775,7 +844,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
775
844
  },
776
845
  );
777
846
  const [meterProvider, recorder] = createTestMeterProvider();
778
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
847
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
779
848
  const counter = recorder.getMeasurement("webfinger.lookup");
780
849
  ok(counter != null);
781
850
  deepStrictEqual(
@@ -798,7 +867,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
798
867
  },
799
868
  );
800
869
  const [meterProvider, recorder] = createTestMeterProvider();
801
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
870
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
802
871
  const counter = recorder.getMeasurement("webfinger.lookup");
803
872
  ok(counter != null);
804
873
  deepStrictEqual(
@@ -825,7 +894,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
825
894
  },
826
895
  );
827
896
  const [meterProvider, recorder] = createTestMeterProvider();
828
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
897
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
829
898
  const counter = recorder.getMeasurement("webfinger.lookup");
830
899
  ok(counter != null);
831
900
  deepStrictEqual(
@@ -852,7 +921,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
852
921
  // attempt to build a host from the URL, fail, and return null.
853
922
  // The metric still records, and its scheme attribute must be
854
923
  // bucketed as `other`.
855
- await lookupWebFinger("ssh://example.com/foo", { meterProvider });
924
+ await lookupMock("ssh://example.com/foo", { meterProvider });
856
925
  const counter = recorder.getMeasurement("webfinger.lookup");
857
926
  ok(counter != null);
858
927
  deepStrictEqual(
@@ -871,7 +940,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
871
940
  { body: expected },
872
941
  );
873
942
  const [_unused, recorder] = createTestMeterProvider();
874
- await lookupWebFinger("acct:johndoe@example.com");
943
+ await lookupMock("acct:johndoe@example.com");
875
944
  deepStrictEqual(
876
945
  recorder.getMeasurements("webfinger.lookup").length,
877
946
  0,
@@ -909,14 +978,14 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
909
978
  ),
910
979
  );
911
980
  deepStrictEqual(
912
- await lookupWebFinger("acct:alice@example.com", {
981
+ await lookupMock("acct:alice@example.com", {
913
982
  allowPrivateAddress: true,
914
983
  }),
915
984
  null,
916
985
  );
917
986
  oversized = false;
918
987
  deepStrictEqual(
919
- await lookupWebFinger("acct:alice@example.com", {
988
+ await lookupMock("acct:alice@example.com", {
920
989
  allowPrivateAddress: true,
921
990
  }),
922
991
  { subject: "acct:alice@example.com" },
@@ -927,28 +996,18 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
927
996
  });
928
997
 
929
998
  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
999
  for (const result of ["throws", "empty", "private"] as const) {
937
1000
  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 = (() =>
1001
+ const lookup = () =>
942
1002
  result === "throws"
943
1003
  ? Promise.reject(new Error("Resolver unavailable"))
944
1004
  : Promise.resolve(
945
1005
  result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
946
- )) as typeof dns.lookup;
1006
+ );
947
1007
  const records: LogRecord[] = [];
948
1008
  await configure({
949
1009
  sinks: {
950
- buffer: (record) =>
951
- records.push(record),
1010
+ buffer: (record) => records.push(record),
952
1011
  },
953
1012
  loggers: [
954
1013
  { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
@@ -958,7 +1017,13 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
958
1017
  });
959
1018
  try {
960
1019
  deepStrictEqual(
961
- await lookupWebFinger("acct:alice@dns-failure.invalid"),
1020
+ await lookupResourceWithDependencies(
1021
+ "acct:alice@dns-failure.invalid",
1022
+ {},
1023
+ {
1024
+ openHop: (url) => openPublicFetch(url, { lookup }),
1025
+ },
1026
+ ),
962
1027
  null,
963
1028
  );
964
1029
  const failures = records.filter((r) =>
@@ -984,8 +1049,389 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
984
1049
  );
985
1050
  } finally {
986
1051
  await reset();
987
- dns.lookup = originalLookup;
988
1052
  }
989
1053
  });
990
1054
  }
991
1055
  });
1056
+
1057
+ interface FakeResponse {
1058
+ readonly status?: number;
1059
+ readonly headers?: Record<string, string>;
1060
+ readonly body?: string;
1061
+ /** The `url` of the response, as a pinned fetch reports it. */
1062
+ readonly url?: string;
1063
+ /** Makes cancelling the body fail. */
1064
+ readonly cancelFails?: boolean;
1065
+ /** Makes cancelling the body never settle. */
1066
+ readonly cancelNeverSettles?: boolean;
1067
+ }
1068
+
1069
+ interface HopRecorder {
1070
+ readonly events: string[];
1071
+ readonly openHop: (url: string) => Promise<PublicFetchSession>;
1072
+ /**
1073
+ * Asserts that every opened session is closed, and that every response
1074
+ * body was consumed or cancelled before its session was closed.
1075
+ */
1076
+ assertInvariants(): void;
1077
+ }
1078
+
1079
+ // A fake server that records the lifecycle of each session and response.
1080
+ // Unlike real sessions, closing a fake session never cancels bodies, so that
1081
+ // the caller's own cleanup is observed:
1082
+ function recordHops(respond: (url: URL) => FakeResponse): HopRecorder {
1083
+ const events: string[] = [];
1084
+ const unsettled: string[] = [];
1085
+ let opens = 0;
1086
+ let closes = 0;
1087
+ let responses = 0;
1088
+ return {
1089
+ events,
1090
+ // deno-lint-ignore require-await
1091
+ async openHop(url) {
1092
+ opens++;
1093
+ events.push(`open ${url}`);
1094
+ const states: { id: number; settled: boolean }[] = [];
1095
+ let closed = false;
1096
+ return {
1097
+ url: new URL(url),
1098
+ // deno-lint-ignore require-await
1099
+ async fetch(input) {
1100
+ if (closed) throw new TypeError("The fetch session is closed.");
1101
+ const target = new URL(input instanceof Request ? input.url : input);
1102
+ const id = ++responses;
1103
+ events.push(`fetch ${target.href} #${id}`);
1104
+ const fake = respond(target);
1105
+ const state = { id, settled: false };
1106
+ states.push(state);
1107
+ let sent = false;
1108
+ const body = new ReadableStream<Uint8Array>({
1109
+ pull(controller) {
1110
+ if (!sent && fake.body != null && fake.body !== "") {
1111
+ sent = true;
1112
+ controller.enqueue(new TextEncoder().encode(fake.body));
1113
+ return;
1114
+ }
1115
+ state.settled = true;
1116
+ events.push(`consumed #${id}`);
1117
+ controller.close();
1118
+ },
1119
+ cancel() {
1120
+ state.settled = true;
1121
+ events.push(`cancel #${id}`);
1122
+ if (fake.cancelFails) throw new Error("Cancellation failed");
1123
+ if (fake.cancelNeverSettles) return new Promise(() => {});
1124
+ },
1125
+ }, { highWaterMark: 0 });
1126
+ const response = new Response(body, {
1127
+ status: fake.status ?? 200,
1128
+ headers: fake.headers,
1129
+ });
1130
+ if (fake.url != null) {
1131
+ Object.defineProperty(response, "url", { value: fake.url });
1132
+ }
1133
+ return response;
1134
+ },
1135
+ // deno-lint-ignore require-await
1136
+ async close() {
1137
+ if (closed) return;
1138
+ closed = true;
1139
+ closes++;
1140
+ for (const state of states) {
1141
+ if (!state.settled) unsettled.push(`#${state.id}`);
1142
+ }
1143
+ events.push(`close ${url}`);
1144
+ },
1145
+ };
1146
+ },
1147
+ assertInvariants() {
1148
+ deepStrictEqual(closes, opens);
1149
+ deepStrictEqual(unsettled, []);
1150
+ },
1151
+ };
1152
+ }
1153
+
1154
+ test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
1155
+ const descriptor: ResourceDescriptor = {
1156
+ subject: "acct:johndoe@example.com",
1157
+ links: [],
1158
+ };
1159
+ const first =
1160
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
1161
+ const second = "https://example.com/.well-known/webfinger2";
1162
+
1163
+ await t.step("redirect", async () => {
1164
+ const recorder = recordHops((url) =>
1165
+ url.pathname === "/.well-known/webfinger"
1166
+ ? {
1167
+ status: 302,
1168
+ headers: { Location: "/.well-known/webfinger2" },
1169
+ body: "redirecting",
1170
+ // A pinned response's url has the IP address, which must not be
1171
+ // used as the base of the Location:
1172
+ url: url.href.replace("example.com", "93.184.215.14"),
1173
+ }
1174
+ : { body: JSON.stringify(descriptor) }
1175
+ );
1176
+ deepStrictEqual(
1177
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1178
+ openHop: recorder.openHop,
1179
+ }),
1180
+ descriptor,
1181
+ );
1182
+ deepStrictEqual(recorder.events, [
1183
+ `open ${first}`,
1184
+ `fetch ${first} #1`,
1185
+ "cancel #1",
1186
+ `close ${first}`,
1187
+ `open ${second}`,
1188
+ `fetch ${second} #2`,
1189
+ "consumed #2",
1190
+ `close ${second}`,
1191
+ ]);
1192
+ recorder.assertInvariants();
1193
+ });
1194
+
1195
+ await t.step("non-2xx response", async () => {
1196
+ const recorder = recordHops(() => ({ status: 404, body: "not found" }));
1197
+ deepStrictEqual(
1198
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1199
+ openHop: recorder.openHop,
1200
+ }),
1201
+ null,
1202
+ );
1203
+ deepStrictEqual(recorder.events, [
1204
+ `open ${first}`,
1205
+ `fetch ${first} #1`,
1206
+ "cancel #1",
1207
+ `close ${first}`,
1208
+ ]);
1209
+ recorder.assertInvariants();
1210
+ });
1211
+
1212
+ await t.step("too many redirections", async () => {
1213
+ const recorder = recordHops(() => ({
1214
+ status: 302,
1215
+ headers: { Location: "/.well-known/webfinger2" },
1216
+ body: "redirecting",
1217
+ }));
1218
+ deepStrictEqual(
1219
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {
1220
+ maxRedirection: 1,
1221
+ }, { openHop: recorder.openHop }),
1222
+ null,
1223
+ );
1224
+ deepStrictEqual(recorder.events, [
1225
+ `open ${first}`,
1226
+ `fetch ${first} #1`,
1227
+ "cancel #1",
1228
+ `close ${first}`,
1229
+ `open ${second}`,
1230
+ `fetch ${second} #2`,
1231
+ "cancel #2",
1232
+ `close ${second}`,
1233
+ ]);
1234
+ recorder.assertInvariants();
1235
+ });
1236
+
1237
+ await t.step("failed redirect body cancellation", async () => {
1238
+ const recorder = recordHops((url) =>
1239
+ url.pathname === "/.well-known/webfinger"
1240
+ ? {
1241
+ status: 302,
1242
+ headers: { Location: "/.well-known/webfinger2" },
1243
+ body: "redirecting",
1244
+ cancelFails: true,
1245
+ }
1246
+ : { body: JSON.stringify(descriptor) }
1247
+ );
1248
+ const records: LogRecord[] = [];
1249
+ await configure({
1250
+ sinks: { buffer: (record) => records.push(record) },
1251
+ loggers: [
1252
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
1253
+ { category: ["logtape", "meta"], sinks: [] },
1254
+ ],
1255
+ reset: true,
1256
+ });
1257
+ try {
1258
+ deepStrictEqual(
1259
+ await lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1260
+ openHop: recorder.openHop,
1261
+ }),
1262
+ descriptor,
1263
+ );
1264
+ const failures = records.filter((r) =>
1265
+ r.rawMessage ===
1266
+ "Failed to cancel the response body from {url}: {error}"
1267
+ );
1268
+ deepStrictEqual(
1269
+ failures.map((r) => [r.level, r.properties.url]),
1270
+ [["debug", first]],
1271
+ );
1272
+ const { error } = failures[0].properties;
1273
+ ok(error instanceof Error);
1274
+ deepStrictEqual(error.message, "Cancellation failed");
1275
+ } finally {
1276
+ await reset();
1277
+ }
1278
+ deepStrictEqual(recorder.events, [
1279
+ `open ${first}`,
1280
+ `fetch ${first} #1`,
1281
+ "cancel #1",
1282
+ `close ${first}`,
1283
+ `open ${second}`,
1284
+ `fetch ${second} #2`,
1285
+ "consumed #2",
1286
+ `close ${second}`,
1287
+ ]);
1288
+ recorder.assertInvariants();
1289
+ });
1290
+
1291
+ await t.step("never-settling body cancellation", async () => {
1292
+ const recorder = recordHops((url) =>
1293
+ url.pathname === "/.well-known/webfinger"
1294
+ ? {
1295
+ status: 302,
1296
+ headers: { Location: "/.well-known/webfinger2" },
1297
+ body: "redirecting",
1298
+ cancelNeverSettles: true,
1299
+ }
1300
+ : { status: 404, body: "not found", cancelNeverSettles: true }
1301
+ );
1302
+ deepStrictEqual(
1303
+ await settleWithin(
1304
+ lookupResourceWithDependencies("acct:johndoe@example.com", {}, {
1305
+ openHop: recorder.openHop,
1306
+ }),
1307
+ ),
1308
+ null,
1309
+ );
1310
+ deepStrictEqual(recorder.events, [
1311
+ `open ${first}`,
1312
+ `fetch ${first} #1`,
1313
+ "cancel #1",
1314
+ `close ${first}`,
1315
+ `open ${second}`,
1316
+ `fetch ${second} #2`,
1317
+ "cancel #2",
1318
+ `close ${second}`,
1319
+ ]);
1320
+ recorder.assertInvariants();
1321
+ });
1322
+ });
1323
+
1324
+ /**
1325
+ * Rejects if the given promise does not settle within the timeout, so that
1326
+ * a hang fails the test instead of blocking it.
1327
+ */
1328
+ async function settleWithin<T>(promise: Promise<T>, ms = 5000): Promise<T> {
1329
+ let timer: ReturnType<typeof setTimeout> | undefined;
1330
+ try {
1331
+ return await Promise.race([
1332
+ promise,
1333
+ new Promise<never>((_, reject) => {
1334
+ timer = setTimeout(
1335
+ () => reject(new Error(`Did not settle within ${ms} ms.`)),
1336
+ ms,
1337
+ );
1338
+ }),
1339
+ ]);
1340
+ } finally {
1341
+ clearTimeout(timer);
1342
+ }
1343
+ }
1344
+
1345
+ // A body that a real session cannot cancel to completion: fetch-mock returns
1346
+ // a clone (a tee) of a Response instance, and the cancellation of a branch of
1347
+ // a tee settles only after the other branch is cancelled too. Its source's
1348
+ // cancel callback never settles either:
1349
+ function neverCancellableResponse(init: ResponseInit): Response {
1350
+ return new Response(
1351
+ new ReadableStream<Uint8Array>({
1352
+ start(controller) {
1353
+ controller.enqueue(new TextEncoder().encode("never cancelled"));
1354
+ },
1355
+ cancel: () => new Promise(() => {}),
1356
+ }),
1357
+ init,
1358
+ );
1359
+ }
1360
+
1361
+ test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
1362
+ const descriptor: ResourceDescriptor = {
1363
+ subject: "acct:johndoe@example.com",
1364
+ links: [],
1365
+ };
1366
+ fetchMock.mockGlobal();
1367
+ try {
1368
+ await t.step("redirect", async () => {
1369
+ fetchMock.removeRoutes();
1370
+ fetchMock.get(
1371
+ "begin:https://example.com/.well-known/webfinger?",
1372
+ () =>
1373
+ neverCancellableResponse({
1374
+ status: 302,
1375
+ headers: { Location: "/.well-known/webfinger2" },
1376
+ }),
1377
+ );
1378
+ fetchMock.get(
1379
+ "https://example.com/.well-known/webfinger2",
1380
+ () =>
1381
+ new Response(JSON.stringify(descriptor), {
1382
+ headers: { "Content-Type": "application/jrd+json" },
1383
+ }),
1384
+ );
1385
+ deepStrictEqual(
1386
+ await settleWithin(lookupDirect("acct:johndoe@example.com")),
1387
+ descriptor,
1388
+ );
1389
+ });
1390
+
1391
+ await t.step("non-2xx response", async () => {
1392
+ fetchMock.removeRoutes();
1393
+ fetchMock.get(
1394
+ "begin:https://example.com/.well-known/webfinger?",
1395
+ () => neverCancellableResponse({ status: 404 }),
1396
+ );
1397
+ deepStrictEqual(
1398
+ await settleWithin(lookupDirect("acct:johndoe@example.com")),
1399
+ null,
1400
+ );
1401
+ });
1402
+ } finally {
1403
+ fetchMock.hardReset();
1404
+ }
1405
+ });
1406
+
1407
+ nodeTest(
1408
+ "lookupWebFinger() records network_error for rejected hop addresses",
1409
+ async () => {
1410
+ for (
1411
+ const lookup of [
1412
+ () => Promise.reject(new Error("DNS unavailable")),
1413
+ () => Promise.resolve([]),
1414
+ () => Promise.resolve([{ address: "127.0.0.1", family: 4 }]),
1415
+ ]
1416
+ ) {
1417
+ const [meterProvider, recorder] = createTestMeterProvider();
1418
+ const result = await lookupWebFingerWithDependencies(
1419
+ "acct:alice@dns-failure.invalid",
1420
+ { meterProvider },
1421
+ { openHop: (url) => openPublicFetch(url, { lookup }) },
1422
+ );
1423
+ deepStrictEqual(result, null);
1424
+ const counter = recorder.getMeasurement("webfinger.lookup");
1425
+ ok(counter != null);
1426
+ deepStrictEqual(
1427
+ counter.attributes["webfinger.lookup.result"],
1428
+ "network_error",
1429
+ );
1430
+ deepStrictEqual(
1431
+ counter.attributes["activitypub.remote.host"],
1432
+ "dns-failure.invalid",
1433
+ );
1434
+ deepStrictEqual("http.response.status_code" in counter.attributes, false);
1435
+ }
1436
+ },
1437
+ );