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