@fedify/webfinger 2.0.35-dev.2480 → 2.0.35
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/deno.json +1 -1
- package/dist/lookup.test.cjs +434 -106
- package/dist/lookup.test.js +435 -106
- package/dist/mod.cjs +143 -84
- package/dist/mod.d.ts +1 -1
- package/dist/mod.js +144 -85
- package/package.json +2 -2
- package/src/lookup-impl.ts +234 -0
- package/src/lookup.test.ts +463 -33
- package/src/lookup.ts +2 -145
package/src/lookup.test.ts
CHANGED
|
@@ -1,12 +1,47 @@
|
|
|
1
1
|
import { test } from "@fedify/fixture";
|
|
2
2
|
import { UrlError } from "@fedify/vocab-runtime";
|
|
3
|
+
import {
|
|
4
|
+
openDirectFetch,
|
|
5
|
+
openPublicFetch,
|
|
6
|
+
type PublicFetchSession,
|
|
7
|
+
} from "@fedify/vocab-runtime/internal";
|
|
3
8
|
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
4
9
|
import { withTimeout } from "es-toolkit";
|
|
5
10
|
import fetchMock from "fetch-mock";
|
|
6
11
|
import { deepStrictEqual, ok } from "node:assert/strict";
|
|
7
|
-
import
|
|
12
|
+
import type { LookupAddress } from "node:dns";
|
|
8
13
|
import type { ResourceDescriptor } from "./jrd.ts";
|
|
9
|
-
import { lookupWebFinger } from "./lookup.ts";
|
|
14
|
+
import { lookupWebFinger, type LookupWebFingerOptions } from "./lookup.ts";
|
|
15
|
+
import { lookupWebFingerInternal } from "./lookup-impl.ts";
|
|
16
|
+
|
|
17
|
+
// fetch-mock routes match hostnames, but on runtimes that pin connections
|
|
18
|
+
// the request URL has the resolved IP address instead. Tests of the response
|
|
19
|
+
// handling therefore skip address validation, and tests of the address policy
|
|
20
|
+
// inject the DNS lookup:
|
|
21
|
+
function lookupDirect(
|
|
22
|
+
resource: URL | string,
|
|
23
|
+
options?: LookupWebFingerOptions,
|
|
24
|
+
): Promise<ResourceDescriptor | null> {
|
|
25
|
+
return lookupWebFingerInternal(resource, options, {
|
|
26
|
+
openHop: openDirectFetch,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lookupResolving(
|
|
31
|
+
addresses: readonly LookupAddress[],
|
|
32
|
+
): (hostname: string) => Promise<readonly LookupAddress[]> {
|
|
33
|
+
return () => Promise.resolve(addresses);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Lets example.com through without validation, and validates every other
|
|
37
|
+
// host with a lookup that resolves to a public address:
|
|
38
|
+
function openExampleDirectly(url: string): Promise<PublicFetchSession> {
|
|
39
|
+
return new URL(url).hostname === "example.com"
|
|
40
|
+
? openDirectFetch(url)
|
|
41
|
+
: openPublicFetch(url, {
|
|
42
|
+
lookup: lookupResolving([{ address: "93.184.215.14", family: 4 }]),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
10
45
|
|
|
11
46
|
test({
|
|
12
47
|
name: "lookupWebFinger()",
|
|
@@ -31,6 +66,47 @@ test({
|
|
|
31
66
|
);
|
|
32
67
|
});
|
|
33
68
|
|
|
69
|
+
await t.step(
|
|
70
|
+
"fedify-test.internal resolving to a private address",
|
|
71
|
+
async () => {
|
|
72
|
+
fetchMock.mockGlobal();
|
|
73
|
+
fetchMock.catch(500);
|
|
74
|
+
try {
|
|
75
|
+
const hosts: string[] = [];
|
|
76
|
+
const openHop = (url: string) =>
|
|
77
|
+
openPublicFetch(url, {
|
|
78
|
+
lookup: (hostname) => {
|
|
79
|
+
hosts.push(hostname);
|
|
80
|
+
return Promise.resolve([{ address: "10.0.0.1", family: 4 }]);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
deepStrictEqual(
|
|
84
|
+
await lookupWebFingerInternal(
|
|
85
|
+
"acct:johndoe@fedify-test.internal",
|
|
86
|
+
{},
|
|
87
|
+
{ openHop },
|
|
88
|
+
),
|
|
89
|
+
null,
|
|
90
|
+
);
|
|
91
|
+
deepStrictEqual(
|
|
92
|
+
await lookupWebFingerInternal(
|
|
93
|
+
"https://fedify-test.internal/foo",
|
|
94
|
+
{},
|
|
95
|
+
{ openHop },
|
|
96
|
+
),
|
|
97
|
+
null,
|
|
98
|
+
);
|
|
99
|
+
deepStrictEqual(hosts, [
|
|
100
|
+
"fedify-test.internal",
|
|
101
|
+
"fedify-test.internal",
|
|
102
|
+
]);
|
|
103
|
+
deepStrictEqual(fetchMock.callHistory.calls().length, 0);
|
|
104
|
+
} finally {
|
|
105
|
+
fetchMock.hardReset();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
34
110
|
fetchMock.spyGlobal();
|
|
35
111
|
fetchMock.get(
|
|
36
112
|
"begin:https://example.com/.well-known/webfinger?",
|
|
@@ -38,8 +114,8 @@ test({
|
|
|
38
114
|
);
|
|
39
115
|
|
|
40
116
|
await t.step("not found", async () => {
|
|
41
|
-
deepStrictEqual(await
|
|
42
|
-
deepStrictEqual(await
|
|
117
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
118
|
+
deepStrictEqual(await lookupDirect("https://example.com/foo"), null);
|
|
43
119
|
});
|
|
44
120
|
|
|
45
121
|
const expected: ResourceDescriptor = {
|
|
@@ -54,7 +130,7 @@ test({
|
|
|
54
130
|
|
|
55
131
|
await t.step("acct", async () => {
|
|
56
132
|
deepStrictEqual(
|
|
57
|
-
await
|
|
133
|
+
await lookupDirect("acct:johndoe@example.com"),
|
|
58
134
|
expected,
|
|
59
135
|
);
|
|
60
136
|
});
|
|
@@ -71,7 +147,7 @@ test({
|
|
|
71
147
|
|
|
72
148
|
await t.step("https", async () => {
|
|
73
149
|
deepStrictEqual(
|
|
74
|
-
await
|
|
150
|
+
await lookupDirect("https://example.com/foo"),
|
|
75
151
|
expected2,
|
|
76
152
|
);
|
|
77
153
|
});
|
|
@@ -83,7 +159,7 @@ test({
|
|
|
83
159
|
);
|
|
84
160
|
|
|
85
161
|
await t.step("invalid response", async () => {
|
|
86
|
-
deepStrictEqual(await
|
|
162
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
87
163
|
});
|
|
88
164
|
|
|
89
165
|
fetchMock.removeRoutes();
|
|
@@ -135,7 +211,7 @@ test({
|
|
|
135
211
|
|
|
136
212
|
await t.step("redirection", async () => {
|
|
137
213
|
deepStrictEqual(
|
|
138
|
-
await
|
|
214
|
+
await lookupDirect("acct:johndoe@example.com"),
|
|
139
215
|
expected,
|
|
140
216
|
);
|
|
141
217
|
});
|
|
@@ -151,7 +227,7 @@ test({
|
|
|
151
227
|
|
|
152
228
|
await t.step("infinite redirection", async () => {
|
|
153
229
|
const result = await withTimeout(
|
|
154
|
-
() =>
|
|
230
|
+
() => lookupDirect("acct:johndoe@example.com"),
|
|
155
231
|
2000,
|
|
156
232
|
);
|
|
157
233
|
deepStrictEqual(result, null);
|
|
@@ -167,7 +243,7 @@ test({
|
|
|
167
243
|
);
|
|
168
244
|
|
|
169
245
|
await t.step("redirection to different protocol", async () => {
|
|
170
|
-
deepStrictEqual(await
|
|
246
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
171
247
|
});
|
|
172
248
|
|
|
173
249
|
fetchMock.removeRoutes();
|
|
@@ -180,7 +256,12 @@ test({
|
|
|
180
256
|
);
|
|
181
257
|
|
|
182
258
|
await t.step("redirection to private address", async () => {
|
|
183
|
-
deepStrictEqual(
|
|
259
|
+
deepStrictEqual(
|
|
260
|
+
await lookupWebFingerInternal("acct:johndoe@example.com", {}, {
|
|
261
|
+
openHop: openExampleDirectly,
|
|
262
|
+
}),
|
|
263
|
+
null,
|
|
264
|
+
);
|
|
184
265
|
});
|
|
185
266
|
|
|
186
267
|
fetchMock.removeRoutes();
|
|
@@ -205,7 +286,7 @@ test({
|
|
|
205
286
|
// Test with maxRedirection: 2 (should fail)
|
|
206
287
|
redirectCount = 0;
|
|
207
288
|
deepStrictEqual(
|
|
208
|
-
await
|
|
289
|
+
await lookupDirect("acct:johndoe@example.com", {
|
|
209
290
|
maxRedirection: 2,
|
|
210
291
|
}),
|
|
211
292
|
null,
|
|
@@ -214,7 +295,7 @@ test({
|
|
|
214
295
|
// Test with maxRedirection: 3 (should succeed)
|
|
215
296
|
redirectCount = 0;
|
|
216
297
|
deepStrictEqual(
|
|
217
|
-
await
|
|
298
|
+
await lookupDirect("acct:johndoe@example.com", {
|
|
218
299
|
maxRedirection: 3,
|
|
219
300
|
}),
|
|
220
301
|
expected,
|
|
@@ -223,7 +304,7 @@ test({
|
|
|
223
304
|
// Test with default maxRedirection: 5 (should succeed)
|
|
224
305
|
redirectCount = 0;
|
|
225
306
|
deepStrictEqual(
|
|
226
|
-
await
|
|
307
|
+
await lookupDirect("acct:johndoe@example.com"),
|
|
227
308
|
expected,
|
|
228
309
|
);
|
|
229
310
|
});
|
|
@@ -244,7 +325,7 @@ test({
|
|
|
244
325
|
await t.step("request cancellation", async () => {
|
|
245
326
|
// Test cancelling a request immediately using AbortController
|
|
246
327
|
const controller = new AbortController();
|
|
247
|
-
const promise =
|
|
328
|
+
const promise = lookupDirect("acct:johndoe@example.com", {
|
|
248
329
|
signal: controller.signal,
|
|
249
330
|
});
|
|
250
331
|
|
|
@@ -278,7 +359,7 @@ test({
|
|
|
278
359
|
await t.step("cancellation during redirection", async () => {
|
|
279
360
|
// Test cancelling a request during redirection process
|
|
280
361
|
const controller = new AbortController();
|
|
281
|
-
const promise =
|
|
362
|
+
const promise = lookupDirect("acct:johndoe@example.com", {
|
|
282
363
|
signal: controller.signal,
|
|
283
364
|
});
|
|
284
365
|
|
|
@@ -306,10 +387,18 @@ test({
|
|
|
306
387
|
controller.abort();
|
|
307
388
|
|
|
308
389
|
// Use a signal that was already aborted before starting the request
|
|
309
|
-
const result = await
|
|
390
|
+
const result = await lookupDirect("acct:johndoe@example.com", {
|
|
310
391
|
signal: controller.signal,
|
|
311
392
|
});
|
|
312
393
|
deepStrictEqual(result, null);
|
|
394
|
+
|
|
395
|
+
// Opening a validated session is aborted before the DNS lookup:
|
|
396
|
+
deepStrictEqual(
|
|
397
|
+
await lookupWebFinger("acct:johndoe@example.com", {
|
|
398
|
+
signal: controller.signal,
|
|
399
|
+
}),
|
|
400
|
+
null,
|
|
401
|
+
);
|
|
313
402
|
});
|
|
314
403
|
|
|
315
404
|
fetchMock.removeRoutes();
|
|
@@ -321,7 +410,7 @@ test({
|
|
|
321
410
|
await t.step("successful request with signal", async () => {
|
|
322
411
|
// Test successful request with a normal AbortController signal
|
|
323
412
|
const controller = new AbortController();
|
|
324
|
-
const result = await
|
|
413
|
+
const result = await lookupDirect("acct:johndoe@example.com", {
|
|
325
414
|
signal: controller.signal,
|
|
326
415
|
});
|
|
327
416
|
deepStrictEqual(result, expected);
|
|
@@ -370,28 +459,18 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
|
370
459
|
});
|
|
371
460
|
|
|
372
461
|
test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
373
|
-
// The validator skips DNS when Deno has no network permission. Checked
|
|
374
|
-
// here rather than with top-level await, which the CommonJS build rejects.
|
|
375
|
-
if (
|
|
376
|
-
"Deno" in globalThis &&
|
|
377
|
-
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
378
|
-
) return;
|
|
379
462
|
for (const result of ["throws", "empty", "private"] as const) {
|
|
380
463
|
await t.step(result, async () => {
|
|
381
|
-
|
|
382
|
-
// node:dns/promises import; see the FIXME there.
|
|
383
|
-
const originalLookup = dns.lookup;
|
|
384
|
-
dns.lookup = (() =>
|
|
464
|
+
const lookup = () =>
|
|
385
465
|
result === "throws"
|
|
386
466
|
? Promise.reject(new Error("Resolver unavailable"))
|
|
387
467
|
: Promise.resolve(
|
|
388
468
|
result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
|
|
389
|
-
)
|
|
469
|
+
);
|
|
390
470
|
const records: LogRecord[] = [];
|
|
391
471
|
await configure({
|
|
392
472
|
sinks: {
|
|
393
|
-
buffer: (record) =>
|
|
394
|
-
records.push(record),
|
|
473
|
+
buffer: (record) => records.push(record),
|
|
395
474
|
},
|
|
396
475
|
loggers: [
|
|
397
476
|
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
@@ -401,7 +480,9 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
|
401
480
|
});
|
|
402
481
|
try {
|
|
403
482
|
deepStrictEqual(
|
|
404
|
-
await
|
|
483
|
+
await lookupWebFingerInternal("acct:alice@dns-failure.invalid", {}, {
|
|
484
|
+
openHop: (url) => openPublicFetch(url, { lookup }),
|
|
485
|
+
}),
|
|
405
486
|
null,
|
|
406
487
|
);
|
|
407
488
|
const failures = records.filter((r) =>
|
|
@@ -427,8 +508,357 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
|
427
508
|
);
|
|
428
509
|
} finally {
|
|
429
510
|
await reset();
|
|
430
|
-
dns.lookup = originalLookup;
|
|
431
511
|
}
|
|
432
512
|
});
|
|
433
513
|
}
|
|
434
514
|
});
|
|
515
|
+
|
|
516
|
+
interface FakeResponse {
|
|
517
|
+
readonly status?: number;
|
|
518
|
+
readonly headers?: Record<string, string>;
|
|
519
|
+
readonly body?: string;
|
|
520
|
+
/** The `url` of the response, as a pinned fetch reports it. */
|
|
521
|
+
readonly url?: string;
|
|
522
|
+
/** Makes cancelling the body fail. */
|
|
523
|
+
readonly cancelFails?: boolean;
|
|
524
|
+
/** Makes cancelling the body never settle. */
|
|
525
|
+
readonly cancelNeverSettles?: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
interface HopRecorder {
|
|
529
|
+
readonly events: string[];
|
|
530
|
+
readonly openHop: (url: string) => Promise<PublicFetchSession>;
|
|
531
|
+
/**
|
|
532
|
+
* Asserts that every opened session is closed, and that every response
|
|
533
|
+
* body was consumed or cancelled before its session was closed.
|
|
534
|
+
*/
|
|
535
|
+
assertInvariants(): void;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// A fake server that records the lifecycle of each session and response.
|
|
539
|
+
// Unlike real sessions, closing a fake session never cancels bodies, so that
|
|
540
|
+
// the caller's own cleanup is observed:
|
|
541
|
+
function recordHops(respond: (url: URL) => FakeResponse): HopRecorder {
|
|
542
|
+
const events: string[] = [];
|
|
543
|
+
const unsettled: string[] = [];
|
|
544
|
+
let opens = 0;
|
|
545
|
+
let closes = 0;
|
|
546
|
+
let responses = 0;
|
|
547
|
+
return {
|
|
548
|
+
events,
|
|
549
|
+
// deno-lint-ignore require-await
|
|
550
|
+
async openHop(url) {
|
|
551
|
+
opens++;
|
|
552
|
+
events.push(`open ${url}`);
|
|
553
|
+
const states: { id: number; settled: boolean }[] = [];
|
|
554
|
+
let closed = false;
|
|
555
|
+
return {
|
|
556
|
+
url: new URL(url),
|
|
557
|
+
// deno-lint-ignore require-await
|
|
558
|
+
async fetch(input) {
|
|
559
|
+
if (closed) throw new TypeError("The fetch session is closed.");
|
|
560
|
+
const target = new URL(input instanceof Request ? input.url : input);
|
|
561
|
+
const id = ++responses;
|
|
562
|
+
events.push(`fetch ${target.href} #${id}`);
|
|
563
|
+
const fake = respond(target);
|
|
564
|
+
const state = { id, settled: false };
|
|
565
|
+
states.push(state);
|
|
566
|
+
let sent = false;
|
|
567
|
+
const body = new ReadableStream<Uint8Array>({
|
|
568
|
+
pull(controller) {
|
|
569
|
+
if (!sent && fake.body != null && fake.body !== "") {
|
|
570
|
+
sent = true;
|
|
571
|
+
controller.enqueue(new TextEncoder().encode(fake.body));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
state.settled = true;
|
|
575
|
+
events.push(`consumed #${id}`);
|
|
576
|
+
controller.close();
|
|
577
|
+
},
|
|
578
|
+
cancel() {
|
|
579
|
+
state.settled = true;
|
|
580
|
+
events.push(`cancel #${id}`);
|
|
581
|
+
if (fake.cancelFails) throw new Error("Cancellation failed");
|
|
582
|
+
if (fake.cancelNeverSettles) return new Promise(() => {});
|
|
583
|
+
},
|
|
584
|
+
}, { highWaterMark: 0 });
|
|
585
|
+
const response = new Response(body, {
|
|
586
|
+
status: fake.status ?? 200,
|
|
587
|
+
headers: fake.headers,
|
|
588
|
+
});
|
|
589
|
+
if (fake.url != null) {
|
|
590
|
+
Object.defineProperty(response, "url", { value: fake.url });
|
|
591
|
+
}
|
|
592
|
+
return response;
|
|
593
|
+
},
|
|
594
|
+
// deno-lint-ignore require-await
|
|
595
|
+
async close() {
|
|
596
|
+
if (closed) return;
|
|
597
|
+
closed = true;
|
|
598
|
+
closes++;
|
|
599
|
+
for (const state of states) {
|
|
600
|
+
if (!state.settled) unsettled.push(`#${state.id}`);
|
|
601
|
+
}
|
|
602
|
+
events.push(`close ${url}`);
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
},
|
|
606
|
+
assertInvariants() {
|
|
607
|
+
deepStrictEqual(closes, opens);
|
|
608
|
+
deepStrictEqual(unsettled, []);
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
|
|
614
|
+
const descriptor: ResourceDescriptor = {
|
|
615
|
+
subject: "acct:johndoe@example.com",
|
|
616
|
+
links: [],
|
|
617
|
+
};
|
|
618
|
+
const first =
|
|
619
|
+
"https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
|
|
620
|
+
const second = "https://example.com/.well-known/webfinger2";
|
|
621
|
+
|
|
622
|
+
await t.step("redirect", async () => {
|
|
623
|
+
const recorder = recordHops((url) =>
|
|
624
|
+
url.pathname === "/.well-known/webfinger"
|
|
625
|
+
? {
|
|
626
|
+
status: 302,
|
|
627
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
628
|
+
body: "redirecting",
|
|
629
|
+
// A pinned response's url has the IP address, which must not be
|
|
630
|
+
// used as the base of the Location:
|
|
631
|
+
url: url.href.replace("example.com", "93.184.215.14"),
|
|
632
|
+
}
|
|
633
|
+
: { body: JSON.stringify(descriptor) }
|
|
634
|
+
);
|
|
635
|
+
deepStrictEqual(
|
|
636
|
+
await lookupWebFingerInternal("acct:johndoe@example.com", {}, {
|
|
637
|
+
openHop: recorder.openHop,
|
|
638
|
+
}),
|
|
639
|
+
descriptor,
|
|
640
|
+
);
|
|
641
|
+
deepStrictEqual(recorder.events, [
|
|
642
|
+
`open ${first}`,
|
|
643
|
+
`fetch ${first} #1`,
|
|
644
|
+
"cancel #1",
|
|
645
|
+
`close ${first}`,
|
|
646
|
+
`open ${second}`,
|
|
647
|
+
`fetch ${second} #2`,
|
|
648
|
+
"consumed #2",
|
|
649
|
+
`close ${second}`,
|
|
650
|
+
]);
|
|
651
|
+
recorder.assertInvariants();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
await t.step("non-2xx response", async () => {
|
|
655
|
+
const recorder = recordHops(() => ({ status: 404, body: "not found" }));
|
|
656
|
+
deepStrictEqual(
|
|
657
|
+
await lookupWebFingerInternal("acct:johndoe@example.com", {}, {
|
|
658
|
+
openHop: recorder.openHop,
|
|
659
|
+
}),
|
|
660
|
+
null,
|
|
661
|
+
);
|
|
662
|
+
deepStrictEqual(recorder.events, [
|
|
663
|
+
`open ${first}`,
|
|
664
|
+
`fetch ${first} #1`,
|
|
665
|
+
"cancel #1",
|
|
666
|
+
`close ${first}`,
|
|
667
|
+
]);
|
|
668
|
+
recorder.assertInvariants();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await t.step("too many redirections", async () => {
|
|
672
|
+
const recorder = recordHops(() => ({
|
|
673
|
+
status: 302,
|
|
674
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
675
|
+
body: "redirecting",
|
|
676
|
+
}));
|
|
677
|
+
deepStrictEqual(
|
|
678
|
+
await lookupWebFingerInternal("acct:johndoe@example.com", {
|
|
679
|
+
maxRedirection: 2,
|
|
680
|
+
}, { openHop: recorder.openHop }),
|
|
681
|
+
null,
|
|
682
|
+
);
|
|
683
|
+
deepStrictEqual(recorder.events, [
|
|
684
|
+
`open ${first}`,
|
|
685
|
+
`fetch ${first} #1`,
|
|
686
|
+
"cancel #1",
|
|
687
|
+
`close ${first}`,
|
|
688
|
+
`open ${second}`,
|
|
689
|
+
`fetch ${second} #2`,
|
|
690
|
+
"cancel #2",
|
|
691
|
+
`close ${second}`,
|
|
692
|
+
]);
|
|
693
|
+
recorder.assertInvariants();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await t.step("failed redirect body cancellation", async () => {
|
|
697
|
+
const recorder = recordHops((url) =>
|
|
698
|
+
url.pathname === "/.well-known/webfinger"
|
|
699
|
+
? {
|
|
700
|
+
status: 302,
|
|
701
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
702
|
+
body: "redirecting",
|
|
703
|
+
cancelFails: true,
|
|
704
|
+
}
|
|
705
|
+
: { body: JSON.stringify(descriptor) }
|
|
706
|
+
);
|
|
707
|
+
const records: LogRecord[] = [];
|
|
708
|
+
await configure({
|
|
709
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
710
|
+
loggers: [
|
|
711
|
+
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
712
|
+
{ category: ["logtape", "meta"], sinks: [] },
|
|
713
|
+
],
|
|
714
|
+
reset: true,
|
|
715
|
+
});
|
|
716
|
+
try {
|
|
717
|
+
deepStrictEqual(
|
|
718
|
+
await lookupWebFingerInternal("acct:johndoe@example.com", {}, {
|
|
719
|
+
openHop: recorder.openHop,
|
|
720
|
+
}),
|
|
721
|
+
descriptor,
|
|
722
|
+
);
|
|
723
|
+
const failures = records.filter((r) =>
|
|
724
|
+
r.rawMessage ===
|
|
725
|
+
"Failed to cancel the response body from {url}: {error}"
|
|
726
|
+
);
|
|
727
|
+
deepStrictEqual(
|
|
728
|
+
failures.map((r) => [r.level, r.properties.url]),
|
|
729
|
+
[["debug", first]],
|
|
730
|
+
);
|
|
731
|
+
const { error } = failures[0].properties;
|
|
732
|
+
ok(error instanceof Error);
|
|
733
|
+
deepStrictEqual(error.message, "Cancellation failed");
|
|
734
|
+
} finally {
|
|
735
|
+
await reset();
|
|
736
|
+
}
|
|
737
|
+
deepStrictEqual(recorder.events, [
|
|
738
|
+
`open ${first}`,
|
|
739
|
+
`fetch ${first} #1`,
|
|
740
|
+
"cancel #1",
|
|
741
|
+
`close ${first}`,
|
|
742
|
+
`open ${second}`,
|
|
743
|
+
`fetch ${second} #2`,
|
|
744
|
+
"consumed #2",
|
|
745
|
+
`close ${second}`,
|
|
746
|
+
]);
|
|
747
|
+
recorder.assertInvariants();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await t.step("never-settling body cancellation", async () => {
|
|
751
|
+
const recorder = recordHops((url) =>
|
|
752
|
+
url.pathname === "/.well-known/webfinger"
|
|
753
|
+
? {
|
|
754
|
+
status: 302,
|
|
755
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
756
|
+
body: "redirecting",
|
|
757
|
+
cancelNeverSettles: true,
|
|
758
|
+
}
|
|
759
|
+
: { status: 404, body: "not found", cancelNeverSettles: true }
|
|
760
|
+
);
|
|
761
|
+
deepStrictEqual(
|
|
762
|
+
await settleWithin(
|
|
763
|
+
lookupWebFingerInternal("acct:johndoe@example.com", {}, {
|
|
764
|
+
openHop: recorder.openHop,
|
|
765
|
+
}),
|
|
766
|
+
),
|
|
767
|
+
null,
|
|
768
|
+
);
|
|
769
|
+
deepStrictEqual(recorder.events, [
|
|
770
|
+
`open ${first}`,
|
|
771
|
+
`fetch ${first} #1`,
|
|
772
|
+
"cancel #1",
|
|
773
|
+
`close ${first}`,
|
|
774
|
+
`open ${second}`,
|
|
775
|
+
`fetch ${second} #2`,
|
|
776
|
+
"cancel #2",
|
|
777
|
+
`close ${second}`,
|
|
778
|
+
]);
|
|
779
|
+
recorder.assertInvariants();
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Rejects if the given promise does not settle within the timeout, so that
|
|
785
|
+
* a hang fails the test instead of blocking it.
|
|
786
|
+
*/
|
|
787
|
+
async function settleWithin<T>(promise: Promise<T>, ms = 5000): Promise<T> {
|
|
788
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
789
|
+
try {
|
|
790
|
+
return await Promise.race([
|
|
791
|
+
promise,
|
|
792
|
+
new Promise<never>((_, reject) => {
|
|
793
|
+
timer = setTimeout(
|
|
794
|
+
() => reject(new Error(`Did not settle within ${ms} ms.`)),
|
|
795
|
+
ms,
|
|
796
|
+
);
|
|
797
|
+
}),
|
|
798
|
+
]);
|
|
799
|
+
} finally {
|
|
800
|
+
clearTimeout(timer);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// A body that a real session cannot cancel to completion: fetch-mock returns
|
|
805
|
+
// a clone (a tee) of a Response instance, and the cancellation of a branch of
|
|
806
|
+
// a tee settles only after the other branch is cancelled too. Its source's
|
|
807
|
+
// cancel callback never settles either:
|
|
808
|
+
function neverCancellableResponse(init: ResponseInit): Response {
|
|
809
|
+
return new Response(
|
|
810
|
+
new ReadableStream<Uint8Array>({
|
|
811
|
+
start(controller) {
|
|
812
|
+
controller.enqueue(new TextEncoder().encode("never cancelled"));
|
|
813
|
+
},
|
|
814
|
+
cancel: () => new Promise(() => {}),
|
|
815
|
+
}),
|
|
816
|
+
init,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
|
|
821
|
+
const descriptor: ResourceDescriptor = {
|
|
822
|
+
subject: "acct:johndoe@example.com",
|
|
823
|
+
links: [],
|
|
824
|
+
};
|
|
825
|
+
fetchMock.mockGlobal();
|
|
826
|
+
try {
|
|
827
|
+
await t.step("redirect", async () => {
|
|
828
|
+
fetchMock.removeRoutes();
|
|
829
|
+
fetchMock.get(
|
|
830
|
+
"begin:https://example.com/.well-known/webfinger?",
|
|
831
|
+
() =>
|
|
832
|
+
neverCancellableResponse({
|
|
833
|
+
status: 302,
|
|
834
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
835
|
+
}),
|
|
836
|
+
);
|
|
837
|
+
fetchMock.get(
|
|
838
|
+
"https://example.com/.well-known/webfinger2",
|
|
839
|
+
() =>
|
|
840
|
+
new Response(JSON.stringify(descriptor), {
|
|
841
|
+
headers: { "Content-Type": "application/jrd+json" },
|
|
842
|
+
}),
|
|
843
|
+
);
|
|
844
|
+
deepStrictEqual(
|
|
845
|
+
await settleWithin(lookupDirect("acct:johndoe@example.com")),
|
|
846
|
+
descriptor,
|
|
847
|
+
);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
await t.step("non-2xx response", async () => {
|
|
851
|
+
fetchMock.removeRoutes();
|
|
852
|
+
fetchMock.get(
|
|
853
|
+
"begin:https://example.com/.well-known/webfinger?",
|
|
854
|
+
() => neverCancellableResponse({ status: 404 }),
|
|
855
|
+
);
|
|
856
|
+
deepStrictEqual(
|
|
857
|
+
await settleWithin(lookupDirect("acct:johndoe@example.com")),
|
|
858
|
+
null,
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
} finally {
|
|
862
|
+
fetchMock.hardReset();
|
|
863
|
+
}
|
|
864
|
+
});
|