@fedify/vocab-runtime 2.4.0-pr.934.40 → 2.4.0
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 +4 -2
- package/dist/{tests/docloader-Xi61Le8w.mjs → contexts-BK1CqBR5.cjs} +164 -269
- package/dist/{tests/docloader-BFedvemb.cjs → contexts-DPuJ4UYL.js} +160 -288
- package/dist/{docloader-DnUMWHaJ.d.cts → docloader-CYwqh5Df.d.cts} +68 -2
- package/dist/{docloader-xRGn1azD.d.ts → docloader-CYwqh5Df.d.ts} +68 -2
- package/dist/internal/jsonld-cache.cjs +70 -1
- package/dist/internal/jsonld-cache.d.cts +35 -2
- package/dist/internal/jsonld-cache.d.ts +35 -2
- package/dist/internal/jsonld-cache.js +67 -2
- package/dist/internal/portable-dereference.cjs +721 -0
- package/dist/internal/portable-dereference.d.cts +349 -0
- package/dist/internal/portable-dereference.d.ts +349 -0
- package/dist/internal/portable-dereference.js +702 -0
- package/dist/internal/signed-representation.cjs +378 -0
- package/dist/internal/signed-representation.d.cts +144 -0
- package/dist/internal/signed-representation.d.ts +144 -0
- package/dist/internal/signed-representation.js +371 -0
- package/dist/jsonld.cjs +2 -2
- package/dist/mod.cjs +715 -4523
- package/dist/mod.d.cts +406 -6
- package/dist/mod.d.ts +406 -7
- package/dist/mod.js +694 -4518
- package/dist/portable-DqtfLy_1.d.cts +160 -0
- package/dist/portable-DtWsu2yU.d.ts +160 -0
- package/dist/tests/body-CYiu9CO-.mjs +130 -0
- package/dist/tests/body-CkEwROsn.cjs +153 -0
- package/dist/tests/body.test.cjs +89 -0
- package/dist/tests/body.test.d.cts +1 -0
- package/dist/tests/body.test.d.mts +1 -0
- package/dist/tests/body.test.mjs +90 -0
- package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
- package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
- package/dist/tests/decimal.test.cjs +8 -7
- package/dist/tests/decimal.test.mjs +8 -6
- package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
- package/dist/tests/digest-COC7xDiQ.mjs +141 -0
- package/dist/tests/digest.test.cjs +102 -0
- package/dist/tests/digest.test.d.cts +1 -0
- package/dist/tests/digest.test.d.mts +1 -0
- package/dist/tests/digest.test.mjs +103 -0
- package/dist/tests/docloader-C3YFl6y4.cjs +396 -0
- package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
- package/dist/tests/docloader.test.cjs +692 -39
- package/dist/tests/docloader.test.mjs +686 -34
- package/dist/tests/internal/multicodec.test.cjs +2 -3
- package/dist/tests/internal/multicodec.test.mjs +2 -2
- package/dist/tests/internal/portable-dereference.test.cjs +172 -0
- package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
- package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
- package/dist/tests/internal/portable-dereference.test.mjs +173 -0
- package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
- package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
- package/dist/tests/jsonld-cache.test.cjs +101 -298
- package/dist/tests/jsonld-cache.test.mjs +94 -289
- package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
- package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
- package/dist/tests/key.test.cjs +6 -5
- package/dist/tests/key.test.mjs +6 -4
- package/dist/tests/langstr.test.cjs +4 -4
- package/dist/tests/langstr.test.mjs +2 -2
- package/dist/tests/link.test.cjs +2 -3
- package/dist/tests/link.test.mjs +2 -2
- package/dist/tests/multibase/multibase.test.cjs +8 -9
- package/dist/tests/multibase/multibase.test.mjs +6 -6
- package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
- package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
- package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
- package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
- package/dist/tests/portable-media-BLKllU56.cjs +153 -0
- package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
- package/dist/tests/portable-media.test.cjs +222 -0
- package/dist/tests/portable-media.test.d.cts +1 -0
- package/dist/tests/portable-media.test.d.mts +1 -0
- package/dist/tests/portable-media.test.mjs +223 -0
- package/dist/tests/portable-workers.test.cjs +36 -0
- package/dist/tests/portable-workers.test.d.cts +2 -0
- package/dist/tests/portable-workers.test.d.mts +2 -0
- package/dist/tests/portable-workers.test.mjs +35 -0
- package/dist/tests/{request-cCPgOxYG.cjs → request-8vPWtV1-.cjs} +10 -4
- package/dist/tests/{request-BOS-hNaf.mjs → request-DRaOaTqD.mjs} +8 -2
- package/dist/tests/request.test.cjs +7 -4
- package/dist/tests/request.test.mjs +5 -2
- package/dist/tests/signed-representation.test.cjs +588 -0
- package/dist/tests/signed-representation.test.d.cts +1 -0
- package/dist/tests/signed-representation.test.d.mts +1 -0
- package/dist/tests/signed-representation.test.mjs +589 -0
- package/dist/tests/temporal.test.cjs +1 -2
- package/dist/tests/temporal.test.mjs +1 -1
- package/dist/tests/url-BNakuZ8k.cjs +998 -0
- package/dist/tests/url-DMxmp7ZG.mjs +859 -0
- package/dist/tests/url.test.cjs +503 -3
- package/dist/tests/url.test.mjs +503 -2
- package/dist/url-DrGTR8yv.cjs +993 -0
- package/dist/url-Dzyp-NsC.js +860 -0
- package/package.json +27 -4
- package/scripts/test-bun.mjs +17 -0
- package/src/body.test.ts +125 -0
- package/src/body.ts +152 -0
- package/src/contexts/cid-v1.json +114 -0
- package/src/contexts/fep-22cd.json +21 -0
- package/src/contexts/fep-ef61.json +10 -0
- package/src/contexts/miscellany.json +17 -0
- package/src/contexts/security-data-integrity-v1.json +0 -4
- package/src/contexts.ts +40 -0
- package/src/digest.test.ts +220 -0
- package/src/digest.ts +229 -0
- package/src/docloader.test.ts +918 -27
- package/src/docloader.ts +322 -105
- package/src/internal/jsonld-cache.ts +97 -1
- package/src/internal/portable-dereference.test.ts +254 -0
- package/src/internal/portable-dereference.ts +1000 -0
- package/src/internal/signed-representation.ts +565 -0
- package/src/jsonld-cache.test.ts +89 -0
- package/src/key.test.ts +9 -0
- package/src/key.ts +11 -1
- package/src/mod.ts +30 -0
- package/src/multibase/multibase.test.ts +5 -5
- package/src/portable-media.test.ts +293 -0
- package/src/portable-media.ts +264 -0
- package/src/portable-workers.test.ts +81 -0
- package/src/portable.ts +180 -0
- package/src/preprocessor.ts +7 -0
- package/src/request.test.ts +10 -1
- package/src/request.ts +9 -1
- package/src/signed-representation.test.ts +338 -0
- package/src/url.test.ts +844 -1
- package/src/url.ts +717 -17
- package/tsdown.config.ts +2 -0
- package/dist/tests/url-BvjYQdxL.cjs +0 -456
- package/dist/tests/url-a2D8NAgh.mjs +0 -378
- package/dist/url-Ck3dGEwH.cjs +0 -457
- package/dist/url-m1YxGNZ0.js +0 -379
- /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
- /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
- /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
- /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
- /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
- /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
- /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
- /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
package/src/docloader.test.ts
CHANGED
|
@@ -1,11 +1,97 @@
|
|
|
1
|
+
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
1
2
|
import fetchMock from "fetch-mock";
|
|
2
|
-
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
3
|
-
import
|
|
3
|
+
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
4
|
+
import dns from "node:dns/promises";
|
|
5
|
+
import { test } from "@fedify/fixture";
|
|
6
|
+
import {
|
|
7
|
+
createServer,
|
|
8
|
+
type IncomingMessage,
|
|
9
|
+
type ServerResponse,
|
|
10
|
+
} from "node:http";
|
|
11
|
+
import { gzipSync } from "node:zlib";
|
|
4
12
|
import preloadedContexts from "./contexts.ts";
|
|
5
|
-
import
|
|
13
|
+
import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
|
|
14
|
+
import {
|
|
15
|
+
getDocumentLoader,
|
|
16
|
+
getRemoteDocument,
|
|
17
|
+
resolveDocumentLoaderTimeout,
|
|
18
|
+
withDocumentLoaderTimeout,
|
|
19
|
+
} from "./docloader.ts";
|
|
6
20
|
import { FetchError } from "./request.ts";
|
|
7
21
|
import { UrlError } from "./url.ts";
|
|
8
22
|
|
|
23
|
+
test("getRemoteDocument() forwards options to alternate documents", async () => {
|
|
24
|
+
const records: LogRecord[] = [];
|
|
25
|
+
await reset();
|
|
26
|
+
await configure({
|
|
27
|
+
sinks: { capture: (record) => records.push(record) },
|
|
28
|
+
loggers: [{
|
|
29
|
+
category: ["fedify"],
|
|
30
|
+
lowestLevel: "debug",
|
|
31
|
+
sinks: ["capture"],
|
|
32
|
+
}],
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
for (const linkHeader of [true, false]) {
|
|
36
|
+
for (const suppressError of [true, false, undefined]) {
|
|
37
|
+
records.length = 0;
|
|
38
|
+
const url = "https://example.com/alternate-source";
|
|
39
|
+
const alternateUrl = "https://example.com/missing-alternate";
|
|
40
|
+
const options = {
|
|
41
|
+
suppressError,
|
|
42
|
+
signal: new AbortController().signal,
|
|
43
|
+
};
|
|
44
|
+
const response = new Response(
|
|
45
|
+
`<link rel="alternate" type="application/activity+json" href="${alternateUrl}">`,
|
|
46
|
+
{
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "text/html",
|
|
49
|
+
...(linkHeader
|
|
50
|
+
? {
|
|
51
|
+
Link:
|
|
52
|
+
`<${alternateUrl}>; rel="alternate"; type="application/activity+json"`,
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
let alternateRequests = 0;
|
|
59
|
+
await rejects(
|
|
60
|
+
() =>
|
|
61
|
+
getRemoteDocument(
|
|
62
|
+
url,
|
|
63
|
+
response,
|
|
64
|
+
async (requestedUrl, forwarded) => {
|
|
65
|
+
alternateRequests++;
|
|
66
|
+
deepStrictEqual(requestedUrl, alternateUrl);
|
|
67
|
+
deepStrictEqual(forwarded, options);
|
|
68
|
+
return await getRemoteDocument(
|
|
69
|
+
requestedUrl,
|
|
70
|
+
new Response(null, { status: 404 }),
|
|
71
|
+
() => {
|
|
72
|
+
throw new Error("Unexpected additional alternate");
|
|
73
|
+
},
|
|
74
|
+
forwarded,
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
options,
|
|
78
|
+
),
|
|
79
|
+
FetchError,
|
|
80
|
+
);
|
|
81
|
+
deepStrictEqual(alternateRequests, 1);
|
|
82
|
+
const failures = records.filter((record) =>
|
|
83
|
+
record.rawMessage ===
|
|
84
|
+
"Failed to fetch document: {status} {url} {headers}"
|
|
85
|
+
);
|
|
86
|
+
deepStrictEqual(failures.length, 1);
|
|
87
|
+
deepStrictEqual(failures[0].level, suppressError ? "warning" : "error");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await reset();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
9
95
|
test("new FetchError()", () => {
|
|
10
96
|
const e = new FetchError("https://example.com/", "An error message.");
|
|
11
97
|
deepStrictEqual(e.name, "FetchError");
|
|
@@ -39,7 +125,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
39
125
|
},
|
|
40
126
|
});
|
|
41
127
|
|
|
42
|
-
await t.
|
|
128
|
+
await t.step("ok", async () => {
|
|
43
129
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
|
|
44
130
|
contextUrl: null,
|
|
45
131
|
documentUrl: "https://example.com/object",
|
|
@@ -96,7 +182,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
96
182
|
},
|
|
97
183
|
});
|
|
98
184
|
|
|
99
|
-
await t.
|
|
185
|
+
await t.step("Link header", async () => {
|
|
100
186
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
|
|
101
187
|
contextUrl: "https://www.w3.org/ns/activitystreams",
|
|
102
188
|
documentUrl: "https://example.com/link-ctx",
|
|
@@ -119,7 +205,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
119
205
|
});
|
|
120
206
|
});
|
|
121
207
|
|
|
122
|
-
await t.
|
|
208
|
+
await t.step("Link header relative url", async () => {
|
|
123
209
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
|
|
124
210
|
contextUrl: "https://www.w3.org/ns/activitystreams",
|
|
125
211
|
documentUrl: "https://example.com/link-ctx",
|
|
@@ -145,7 +231,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
145
231
|
);
|
|
146
232
|
});
|
|
147
233
|
|
|
148
|
-
await t.
|
|
234
|
+
await t.step("wrong Link header syntax", async () => {
|
|
149
235
|
deepStrictEqual(
|
|
150
236
|
await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
|
|
151
237
|
{
|
|
@@ -174,7 +260,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
174
260
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
175
261
|
});
|
|
176
262
|
|
|
177
|
-
await t.
|
|
263
|
+
await t.step("HTML <link>", async () => {
|
|
178
264
|
deepStrictEqual(
|
|
179
265
|
await fetchDocumentLoader("https://example.com/html-link"),
|
|
180
266
|
{
|
|
@@ -203,7 +289,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
203
289
|
headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
|
|
204
290
|
});
|
|
205
291
|
|
|
206
|
-
await t.
|
|
292
|
+
await t.step("XHTML <link>", async () => {
|
|
207
293
|
deepStrictEqual(
|
|
208
294
|
await fetchDocumentLoader("https://example.com/xhtml-link"),
|
|
209
295
|
{
|
|
@@ -234,7 +320,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
234
320
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
235
321
|
});
|
|
236
322
|
|
|
237
|
-
await t.
|
|
323
|
+
await t.step("HTML <a>", async () => {
|
|
238
324
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
|
|
239
325
|
contextUrl: null,
|
|
240
326
|
documentUrl: "https://example.com/object",
|
|
@@ -258,7 +344,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
258
344
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
259
345
|
});
|
|
260
346
|
|
|
261
|
-
await t.
|
|
347
|
+
await t.step("HTML without ActivityPub alternate link", async () => {
|
|
262
348
|
await rejects(
|
|
263
349
|
() => fetchDocumentLoader("https://example.com/html-no-alternate"),
|
|
264
350
|
(error) => {
|
|
@@ -295,7 +381,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
295
381
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
296
382
|
});
|
|
297
383
|
|
|
298
|
-
await t.
|
|
384
|
+
await t.step("wrong Content-Type with JSON body", async () => {
|
|
299
385
|
deepStrictEqual(
|
|
300
386
|
await fetchDocumentLoader("https://example.com/wrong-content-type"),
|
|
301
387
|
{
|
|
@@ -319,7 +405,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
319
405
|
},
|
|
320
406
|
});
|
|
321
407
|
|
|
322
|
-
await t.
|
|
408
|
+
await t.step("HTML Content-Length over limit", async () => {
|
|
323
409
|
await rejects(
|
|
324
410
|
() => fetchDocumentLoader("https://example.com/large-html"),
|
|
325
411
|
(error) => {
|
|
@@ -340,7 +426,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
340
426
|
);
|
|
341
427
|
});
|
|
342
428
|
|
|
343
|
-
await t.
|
|
429
|
+
await t.step("HTML Content-Length over limit cancels body", async () => {
|
|
344
430
|
let canceled = false;
|
|
345
431
|
const response = new Response("<!DOCTYPE html>", {
|
|
346
432
|
headers: {
|
|
@@ -348,12 +434,14 @@ test("getDocumentLoader()", async (t) => {
|
|
|
348
434
|
"Content-Type": "text/html; charset=utf-8",
|
|
349
435
|
},
|
|
350
436
|
});
|
|
437
|
+
// The shared reader in body.ts cancels through a reader rather than the
|
|
438
|
+
// body directly, so stub a real stream instead of a bare `cancel`.
|
|
351
439
|
Object.defineProperty(response, "body", {
|
|
352
|
-
value: {
|
|
353
|
-
cancel
|
|
440
|
+
value: new ReadableStream<Uint8Array>({
|
|
441
|
+
cancel() {
|
|
354
442
|
canceled = true;
|
|
355
443
|
},
|
|
356
|
-
},
|
|
444
|
+
}),
|
|
357
445
|
});
|
|
358
446
|
await rejects(
|
|
359
447
|
() =>
|
|
@@ -371,7 +459,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
371
459
|
|
|
372
460
|
fetchMock.get("https://example.com/404", { status: 404 });
|
|
373
461
|
|
|
374
|
-
await t.
|
|
462
|
+
await t.step("not ok", async () => {
|
|
375
463
|
await rejects(
|
|
376
464
|
() => fetchDocumentLoader("https://example.com/404"),
|
|
377
465
|
FetchError,
|
|
@@ -379,7 +467,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
379
467
|
);
|
|
380
468
|
});
|
|
381
469
|
|
|
382
|
-
await t.
|
|
470
|
+
await t.step("preloaded contexts", async () => {
|
|
383
471
|
for (const [url, document] of Object.entries(preloadedContexts)) {
|
|
384
472
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
385
473
|
contextUrl: null,
|
|
@@ -389,7 +477,84 @@ test("getDocumentLoader()", async (t) => {
|
|
|
389
477
|
}
|
|
390
478
|
});
|
|
391
479
|
|
|
392
|
-
|
|
480
|
+
// Controlled Identifiers v1.0 requires JSON-LD processors to treat this
|
|
481
|
+
// context URL as already resolved. A temporary W3C outage must not prevent
|
|
482
|
+
// an otherwise valid document from being processed.
|
|
483
|
+
// See: https://www.w3.org/TR/cid-1.0/#json-ld-context
|
|
484
|
+
// https://github.com/fedify-dev/fedify/issues/932
|
|
485
|
+
fetchMock.get("https://www.w3.org/ns/cid/v1", { status: 503 });
|
|
486
|
+
await t.step("preloaded CID v1 context", async () => {
|
|
487
|
+
const url = "https://www.w3.org/ns/cid/v1";
|
|
488
|
+
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
489
|
+
contextUrl: null,
|
|
490
|
+
documentUrl: url,
|
|
491
|
+
document: cidV1Context,
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// The <https://w3id.org/fep/ef61> URL redirects to a Codeberg Pages host
|
|
496
|
+
// which suffers recurring outages; while it is unreachable, expanding any
|
|
497
|
+
// document referencing it fails before application handlers run. It has to
|
|
498
|
+
// be resolved from the built-in copy rather than over the network.
|
|
499
|
+
// See: https://github.com/fedify-dev/fedify/issues/982
|
|
500
|
+
await t.step("preloaded FEP-ef61 context", async () => {
|
|
501
|
+
const url = "https://w3id.org/fep/ef61";
|
|
502
|
+
ok(url in preloadedContexts);
|
|
503
|
+
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
504
|
+
contextUrl: null,
|
|
505
|
+
documentUrl: url,
|
|
506
|
+
document: {
|
|
507
|
+
"@context": {
|
|
508
|
+
gateways: {
|
|
509
|
+
"@id": "https://w3id.org/fep/ef61/gateways",
|
|
510
|
+
"@type": "@id",
|
|
511
|
+
"@container": "@list",
|
|
512
|
+
},
|
|
513
|
+
digestMultibase:
|
|
514
|
+
"https://www.w3.org/ns/credentials/v2#digestMultibase",
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
|
|
521
|
+
// See: https://github.com/fedify-dev/fedify/issues/1078
|
|
522
|
+
fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
|
|
523
|
+
await t.step("preloaded FEP-7aa9 context", async () => {
|
|
524
|
+
const url = "https://w3id.org/fep/7aa9";
|
|
525
|
+
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
526
|
+
contextUrl: null,
|
|
527
|
+
documentUrl: url,
|
|
528
|
+
document: {
|
|
529
|
+
"@context": {
|
|
530
|
+
"FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
|
|
531
|
+
"FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
|
|
532
|
+
"FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
|
|
533
|
+
"FeatureAuthorization":
|
|
534
|
+
"https://w3id.org/fep/7aa9#FeatureAuthorization",
|
|
535
|
+
"topic": {
|
|
536
|
+
"@id": "https://w3id.org/fep/7aa9#topic",
|
|
537
|
+
"@type": "@id",
|
|
538
|
+
},
|
|
539
|
+
"featuredObject": {
|
|
540
|
+
"@id": "https://w3id.org/fep/7aa9#featuredObject",
|
|
541
|
+
"@type": "@id",
|
|
542
|
+
},
|
|
543
|
+
"canFeature": {
|
|
544
|
+
"@id": "https://w3id.org/fep/7aa9#canFeature",
|
|
545
|
+
"@type": "@id",
|
|
546
|
+
},
|
|
547
|
+
"featureAuthorization": {
|
|
548
|
+
"@id": "https://w3id.org/fep/7aa9#featureAuthorization",
|
|
549
|
+
"@type": "@id",
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
await t.step("deny non-HTTP/HTTPS", async () => {
|
|
393
558
|
await rejects(
|
|
394
559
|
() => fetchDocumentLoader("ftp://localhost"),
|
|
395
560
|
UrlError,
|
|
@@ -423,7 +588,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
423
588
|
},
|
|
424
589
|
});
|
|
425
590
|
|
|
426
|
-
await t.
|
|
591
|
+
await t.step("allowPrivateAddress: false", async () => {
|
|
427
592
|
await rejects(
|
|
428
593
|
() => fetchDocumentLoader("https://localhost/object"),
|
|
429
594
|
UrlError,
|
|
@@ -440,7 +605,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
440
605
|
|
|
441
606
|
const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
|
|
442
607
|
|
|
443
|
-
await t.
|
|
608
|
+
await t.step("allowPrivateAddress: true", async () => {
|
|
444
609
|
const expected = {
|
|
445
610
|
contextUrl: null,
|
|
446
611
|
documentUrl: "https://localhost/object",
|
|
@@ -477,7 +642,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
477
642
|
};
|
|
478
643
|
});
|
|
479
644
|
|
|
480
|
-
await t.
|
|
645
|
+
await t.step("too many redirects", async () => {
|
|
481
646
|
redirectAttempts = 0;
|
|
482
647
|
await rejects(
|
|
483
648
|
() => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
|
|
@@ -487,7 +652,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
487
652
|
deepStrictEqual(redirectAttempts, 21);
|
|
488
653
|
});
|
|
489
654
|
|
|
490
|
-
await t.
|
|
655
|
+
await t.step("custom max redirection", async () => {
|
|
491
656
|
redirectAttempts = 0;
|
|
492
657
|
const loader = getDocumentLoader({ maxRedirection: 1 });
|
|
493
658
|
await rejects(
|
|
@@ -514,7 +679,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
514
679
|
};
|
|
515
680
|
});
|
|
516
681
|
|
|
517
|
-
await t.
|
|
682
|
+
await t.step("redirect loop", async () => {
|
|
518
683
|
loopAttempts = 0;
|
|
519
684
|
await rejects(
|
|
520
685
|
() => fetchDocumentLoader("https://example.com/redirect-loop-a"),
|
|
@@ -533,7 +698,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
533
698
|
};
|
|
534
699
|
});
|
|
535
700
|
|
|
536
|
-
await t.
|
|
701
|
+
await t.step("redirect loop with relative location", async () => {
|
|
537
702
|
relativeLoopAttempts = 0;
|
|
538
703
|
await rejects(
|
|
539
704
|
() => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
|
|
@@ -553,7 +718,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
553
718
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
554
719
|
});
|
|
555
720
|
|
|
556
|
-
await t.
|
|
721
|
+
await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
|
|
557
722
|
const start = performance.now();
|
|
558
723
|
// The malicious HTML will fail alternate discovery, but the important
|
|
559
724
|
// thing is that it should complete quickly (not hang due to ReDoS).
|
|
@@ -573,3 +738,729 @@ test("getDocumentLoader()", async (t) => {
|
|
|
573
738
|
|
|
574
739
|
fetchMock.hardReset();
|
|
575
740
|
});
|
|
741
|
+
|
|
742
|
+
test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
|
|
743
|
+
const url = "https://example.com/bounded";
|
|
744
|
+
fetchMock.mockGlobal();
|
|
745
|
+
let oversized = true;
|
|
746
|
+
try {
|
|
747
|
+
fetchMock.get(url, () =>
|
|
748
|
+
new Response('{"name":"hello"}', {
|
|
749
|
+
headers: {
|
|
750
|
+
"Content-Type": "application/activity+json",
|
|
751
|
+
...(oversized
|
|
752
|
+
? { "Content-Length": String(16 * 1024 * 1024 + 1) }
|
|
753
|
+
: {}),
|
|
754
|
+
},
|
|
755
|
+
}));
|
|
756
|
+
const loader = getDocumentLoader({
|
|
757
|
+
allowPrivateAddress: true,
|
|
758
|
+
});
|
|
759
|
+
await rejects(loader(url), FetchError);
|
|
760
|
+
oversized = false;
|
|
761
|
+
deepStrictEqual((await loader(url)).document, { name: "hello" });
|
|
762
|
+
oversized = true;
|
|
763
|
+
fetchMock.get(
|
|
764
|
+
`${url}/html`,
|
|
765
|
+
() =>
|
|
766
|
+
new Response(" ".repeat(1024 * 1024 + 1), {
|
|
767
|
+
headers: { "Content-Type": "text/html" },
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
await rejects(
|
|
771
|
+
getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
|
|
772
|
+
FetchError,
|
|
773
|
+
);
|
|
774
|
+
fetchMock.get(`${url}/alternate`, () =>
|
|
775
|
+
new Response("", {
|
|
776
|
+
headers: {
|
|
777
|
+
"Content-Type": "text/html",
|
|
778
|
+
Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
|
|
779
|
+
},
|
|
780
|
+
}));
|
|
781
|
+
await rejects(loader(`${url}/alternate`), FetchError);
|
|
782
|
+
fetchMock.get(`${url}/redirect`, {
|
|
783
|
+
status: 302,
|
|
784
|
+
headers: { Location: url },
|
|
785
|
+
});
|
|
786
|
+
await rejects(loader(`${url}/redirect`), FetchError);
|
|
787
|
+
} finally {
|
|
788
|
+
fetchMock.hardReset();
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
|
|
793
|
+
const document = { name: "a".repeat(16 * 1024 * 1024) };
|
|
794
|
+
const compressed = gzipSync(JSON.stringify(document));
|
|
795
|
+
ok(compressed.byteLength < 64 * 1024);
|
|
796
|
+
const server = createServer((request, response) => {
|
|
797
|
+
const body = request.url === "/small"
|
|
798
|
+
? gzipSync('{"name":"hello"}')
|
|
799
|
+
: compressed;
|
|
800
|
+
response.writeHead(200, {
|
|
801
|
+
"Content-Type": "application/activity+json",
|
|
802
|
+
"Content-Encoding": "gzip",
|
|
803
|
+
"Content-Length": body.byteLength,
|
|
804
|
+
});
|
|
805
|
+
response.end(body);
|
|
806
|
+
});
|
|
807
|
+
await new Promise<void>((resolve, reject) => {
|
|
808
|
+
server.once("error", reject);
|
|
809
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
810
|
+
});
|
|
811
|
+
try {
|
|
812
|
+
const address = server.address();
|
|
813
|
+
ok(address != null && typeof address !== "string");
|
|
814
|
+
const url = `http://127.0.0.1:${address.port}/document`;
|
|
815
|
+
await rejects(
|
|
816
|
+
getDocumentLoader({
|
|
817
|
+
allowPrivateAddress: true,
|
|
818
|
+
})(url),
|
|
819
|
+
FetchError,
|
|
820
|
+
);
|
|
821
|
+
deepStrictEqual(
|
|
822
|
+
(await getDocumentLoader({
|
|
823
|
+
allowPrivateAddress: true,
|
|
824
|
+
})(new URL("/small", url).href)).document,
|
|
825
|
+
{ name: "hello" },
|
|
826
|
+
);
|
|
827
|
+
} finally {
|
|
828
|
+
await new Promise<void>((resolve, reject) => {
|
|
829
|
+
server.close((error) => error == null ? resolve() : reject(error));
|
|
830
|
+
server.closeAllConnections();
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("getRemoteDocument() bounds JSON by default", async () => {
|
|
836
|
+
let canceled = false;
|
|
837
|
+
let pulls = 0;
|
|
838
|
+
const response = new Response(
|
|
839
|
+
new ReadableStream({
|
|
840
|
+
pull(controller) {
|
|
841
|
+
pulls++;
|
|
842
|
+
controller.enqueue(new Uint8Array(64 * 1024).fill(32));
|
|
843
|
+
},
|
|
844
|
+
cancel() {
|
|
845
|
+
canceled = true;
|
|
846
|
+
},
|
|
847
|
+
}, { highWaterMark: 0 }),
|
|
848
|
+
);
|
|
849
|
+
await rejects(
|
|
850
|
+
getRemoteDocument(
|
|
851
|
+
"https://example.com/document",
|
|
852
|
+
response,
|
|
853
|
+
() => {
|
|
854
|
+
throw new Error("Unexpected alternate document");
|
|
855
|
+
},
|
|
856
|
+
),
|
|
857
|
+
FetchError,
|
|
858
|
+
);
|
|
859
|
+
deepStrictEqual(canceled, true);
|
|
860
|
+
deepStrictEqual(pulls, 257);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
864
|
+
// The validator skips DNS when Deno has no network permission. Checked
|
|
865
|
+
// here rather than with top-level await, which the CommonJS build rejects.
|
|
866
|
+
if (
|
|
867
|
+
"Deno" in globalThis &&
|
|
868
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
869
|
+
) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
"This DNS test requires Deno network permission (--allow-net).",
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const loader = getDocumentLoader();
|
|
875
|
+
for (const result of ["throws", "empty", "private"] as const) {
|
|
876
|
+
await t.step(result, async () => {
|
|
877
|
+
// Stubbing works only because url.ts uses the default node:dns/promises
|
|
878
|
+
// import; see the FIXME there.
|
|
879
|
+
const originalLookup = dns.lookup;
|
|
880
|
+
dns.lookup = (() =>
|
|
881
|
+
result === "throws"
|
|
882
|
+
? Promise.reject(new Error("Resolver unavailable"))
|
|
883
|
+
: Promise.resolve(
|
|
884
|
+
result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
|
|
885
|
+
)) as typeof dns.lookup;
|
|
886
|
+
const records: LogRecord[] = [];
|
|
887
|
+
await configure({
|
|
888
|
+
sinks: {
|
|
889
|
+
buffer: (record) =>
|
|
890
|
+
records.push(record),
|
|
891
|
+
},
|
|
892
|
+
loggers: [
|
|
893
|
+
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
894
|
+
{ category: ["logtape", "meta"], sinks: [] },
|
|
895
|
+
],
|
|
896
|
+
reset: true,
|
|
897
|
+
});
|
|
898
|
+
try {
|
|
899
|
+
const url = "https://dns-failure.invalid/object";
|
|
900
|
+
let error: unknown;
|
|
901
|
+
await rejects(() => loader(url), (e) => {
|
|
902
|
+
error = e;
|
|
903
|
+
return true;
|
|
904
|
+
});
|
|
905
|
+
ok(error instanceof UrlError);
|
|
906
|
+
deepStrictEqual(
|
|
907
|
+
error.reason,
|
|
908
|
+
result === "private" ? "disallowed" : "dns",
|
|
909
|
+
);
|
|
910
|
+
deepStrictEqual(
|
|
911
|
+
records.map((r) => [r.level, r.rawMessage, r.properties.url]),
|
|
912
|
+
[
|
|
913
|
+
result === "private"
|
|
914
|
+
? ["error", "Disallowed private URL: {url}", url]
|
|
915
|
+
: ["debug", "DNS lookup failed for {url}", url],
|
|
916
|
+
],
|
|
917
|
+
);
|
|
918
|
+
ok(records[0].properties.error === error);
|
|
919
|
+
} finally {
|
|
920
|
+
await reset();
|
|
921
|
+
dns.lookup = originalLookup;
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("getDocumentLoader() bounds alternate document chains", async (t) => {
|
|
928
|
+
const base = "https://example.com/alternate-chain/";
|
|
929
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
930
|
+
for (const mode of ["header", "html", "mixed"] as const) {
|
|
931
|
+
for (const hops of [20, 21]) {
|
|
932
|
+
await t.step(`${mode}: ${hops} hops`, async () => {
|
|
933
|
+
fetchMock.mockGlobal();
|
|
934
|
+
let requests = 0;
|
|
935
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
936
|
+
requests++;
|
|
937
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
938
|
+
// Keep the unpatched regression bounded too.
|
|
939
|
+
if (index === hops || requests > 30) {
|
|
940
|
+
return Response.json({ done: true });
|
|
941
|
+
}
|
|
942
|
+
const next = `${base}${index + 1}`;
|
|
943
|
+
if (mode === "mixed" && index % 2 === 1) {
|
|
944
|
+
return Response.redirect(next, 302);
|
|
945
|
+
}
|
|
946
|
+
return alternate(next, mode === "html");
|
|
947
|
+
});
|
|
948
|
+
try {
|
|
949
|
+
if (hops === 20) {
|
|
950
|
+
deepStrictEqual((await loader(`${base}0`)).document, {
|
|
951
|
+
done: true,
|
|
952
|
+
});
|
|
953
|
+
} else {
|
|
954
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
955
|
+
ok(error instanceof FetchError);
|
|
956
|
+
ok(error.message.includes("Too many redirections (21)"));
|
|
957
|
+
return true;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
deepStrictEqual(requests, 21);
|
|
961
|
+
} finally {
|
|
962
|
+
fetchMock.hardReset();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
for (
|
|
969
|
+
const mode of [
|
|
970
|
+
"header",
|
|
971
|
+
"html",
|
|
972
|
+
"alternate-redirect",
|
|
973
|
+
"redirect-alternate",
|
|
974
|
+
"redirect-intermediate",
|
|
975
|
+
]
|
|
976
|
+
) {
|
|
977
|
+
await t.step(`${mode}: cycle`, async () => {
|
|
978
|
+
fetchMock.mockGlobal();
|
|
979
|
+
let requests = 0;
|
|
980
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
981
|
+
requests++;
|
|
982
|
+
if (requests > 30) return Response.json({ stopped: true });
|
|
983
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
984
|
+
const next = `${base}${
|
|
985
|
+
mode === "redirect-intermediate"
|
|
986
|
+
? (index === 0 ? 1 : index === 1 ? 2 : 1)
|
|
987
|
+
: 1 - index
|
|
988
|
+
}`;
|
|
989
|
+
if (
|
|
990
|
+
(mode === "alternate-redirect" && index === 1) ||
|
|
991
|
+
((mode === "redirect-alternate" ||
|
|
992
|
+
mode === "redirect-intermediate") && index === 0)
|
|
993
|
+
) {
|
|
994
|
+
return Response.redirect(next, 302);
|
|
995
|
+
}
|
|
996
|
+
return alternate(next, mode === "html");
|
|
997
|
+
});
|
|
998
|
+
try {
|
|
999
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
1000
|
+
ok(error instanceof FetchError);
|
|
1001
|
+
ok(error.message.includes("Redirect loop detected:"));
|
|
1002
|
+
return true;
|
|
1003
|
+
});
|
|
1004
|
+
deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
|
|
1005
|
+
} finally {
|
|
1006
|
+
fetchMock.hardReset();
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
await t.step(
|
|
1012
|
+
"alternate followed by 20 redirects shares the limit",
|
|
1013
|
+
async () => {
|
|
1014
|
+
fetchMock.mockGlobal();
|
|
1015
|
+
let requests = 0;
|
|
1016
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
1017
|
+
requests++;
|
|
1018
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
1019
|
+
if (index === 21) return Response.json({ done: true });
|
|
1020
|
+
return index === 0
|
|
1021
|
+
? alternate(`${base}1`, false)
|
|
1022
|
+
: Response.redirect(`${base}${index + 1}`, 302);
|
|
1023
|
+
});
|
|
1024
|
+
try {
|
|
1025
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
1026
|
+
ok(error instanceof FetchError);
|
|
1027
|
+
ok(error.message.includes("Too many redirections (21)"));
|
|
1028
|
+
return true;
|
|
1029
|
+
});
|
|
1030
|
+
deepStrictEqual(requests, 21);
|
|
1031
|
+
} finally {
|
|
1032
|
+
fetchMock.hardReset();
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
await t.step(
|
|
1038
|
+
"relative alternate after redirect and isolated calls",
|
|
1039
|
+
async () => {
|
|
1040
|
+
fetchMock.mockGlobal();
|
|
1041
|
+
fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
|
|
1042
|
+
fetchMock.get(`${base}html`, alternate("./document", true));
|
|
1043
|
+
fetchMock.get(`${base}document`, Response.json({ done: true }));
|
|
1044
|
+
try {
|
|
1045
|
+
for (let i = 0; i < 2; i++) {
|
|
1046
|
+
const results = await Promise.all([
|
|
1047
|
+
loader(`${base}start`),
|
|
1048
|
+
loader(`${base}start`),
|
|
1049
|
+
]);
|
|
1050
|
+
for (const result of results) {
|
|
1051
|
+
deepStrictEqual(result.document, { done: true });
|
|
1052
|
+
deepStrictEqual(result.documentUrl, `${base}document`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
} finally {
|
|
1056
|
+
fetchMock.hardReset();
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
function alternate(next: string, html: boolean): Response {
|
|
1062
|
+
return html
|
|
1063
|
+
? new Response(
|
|
1064
|
+
`<link rel="alternate" type="application/activity+json" href="${next}">`,
|
|
1065
|
+
{
|
|
1066
|
+
headers: { "Content-Type": "text/html" },
|
|
1067
|
+
},
|
|
1068
|
+
)
|
|
1069
|
+
: new Response("not JSON", {
|
|
1070
|
+
headers: {
|
|
1071
|
+
"Content-Type": "text/plain",
|
|
1072
|
+
Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
|
|
1079
|
+
const base = "https://example.com/alternate-abort/";
|
|
1080
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
1081
|
+
for (const html of [false, true]) {
|
|
1082
|
+
await t.step(html ? "HTML" : "Link header", async () => {
|
|
1083
|
+
fetchMock.mockGlobal();
|
|
1084
|
+
const controller = new AbortController();
|
|
1085
|
+
let requests = 0;
|
|
1086
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
1087
|
+
requests++;
|
|
1088
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
1089
|
+
if (index === 2) return Response.json({ done: true });
|
|
1090
|
+
if (index === 1) controller.abort();
|
|
1091
|
+
const next = `${base}${index + 1}`;
|
|
1092
|
+
return new Response(
|
|
1093
|
+
html
|
|
1094
|
+
? `<link rel="alternate" type="application/activity+json" href="${next}">`
|
|
1095
|
+
: "not JSON",
|
|
1096
|
+
{
|
|
1097
|
+
headers: html ? { "Content-Type": "text/html" } : {
|
|
1098
|
+
"Content-Type": "text/plain",
|
|
1099
|
+
Link:
|
|
1100
|
+
`<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
);
|
|
1104
|
+
});
|
|
1105
|
+
try {
|
|
1106
|
+
await rejects(loader(`${base}0`, { signal: controller.signal }), {
|
|
1107
|
+
name: "AbortError",
|
|
1108
|
+
});
|
|
1109
|
+
deepStrictEqual(requests, 2);
|
|
1110
|
+
} finally {
|
|
1111
|
+
fetchMock.hardReset();
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
test("getDocumentLoader() rejects cancellation before fetching", async () => {
|
|
1118
|
+
fetchMock.mockGlobal();
|
|
1119
|
+
let requests = 0;
|
|
1120
|
+
const url = "https://example.com/pre-aborted-alternate";
|
|
1121
|
+
fetchMock.get(url, () => {
|
|
1122
|
+
requests++;
|
|
1123
|
+
return Response.json({ done: true });
|
|
1124
|
+
});
|
|
1125
|
+
try {
|
|
1126
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
1127
|
+
const controller = new AbortController();
|
|
1128
|
+
controller.abort();
|
|
1129
|
+
await rejects(loader(url, { signal: controller.signal }), {
|
|
1130
|
+
name: "AbortError",
|
|
1131
|
+
});
|
|
1132
|
+
deepStrictEqual(requests, 0);
|
|
1133
|
+
} finally {
|
|
1134
|
+
fetchMock.hardReset();
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
async function withServer<T>(
|
|
1139
|
+
handler: (request: IncomingMessage, response: ServerResponse) => void,
|
|
1140
|
+
callback: (baseUrl: string) => Promise<T>,
|
|
1141
|
+
): Promise<T> {
|
|
1142
|
+
const server = createServer(handler);
|
|
1143
|
+
await new Promise<void>((resolve, reject) => {
|
|
1144
|
+
server.once("error", reject);
|
|
1145
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
1146
|
+
});
|
|
1147
|
+
try {
|
|
1148
|
+
const address = server.address();
|
|
1149
|
+
ok(address != null && typeof address !== "string");
|
|
1150
|
+
return await callback(`http://127.0.0.1:${address.port}`);
|
|
1151
|
+
} finally {
|
|
1152
|
+
await new Promise<void>((resolve, reject) => {
|
|
1153
|
+
server.close((error) => error == null ? resolve() : reject(error));
|
|
1154
|
+
server.closeAllConnections();
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function isTimeoutFetchError(error: unknown, url: string): boolean {
|
|
1160
|
+
ok(error instanceof FetchError, String(error));
|
|
1161
|
+
deepStrictEqual(error.url.href, url);
|
|
1162
|
+
deepStrictEqual(error.response, undefined);
|
|
1163
|
+
ok(error.cause instanceof DOMException);
|
|
1164
|
+
deepStrictEqual(error.cause.name, "TimeoutError");
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
test("resolveDocumentLoaderTimeout() validates timeouts", () => {
|
|
1169
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(undefined), 10_000);
|
|
1170
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(null), null);
|
|
1171
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(1500), 1500);
|
|
1172
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(0.5), 1);
|
|
1173
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(2_147_483_647), 2_147_483_647);
|
|
1174
|
+
for (const invalid of [0, -1, NaN, Infinity, 2_147_483_648]) {
|
|
1175
|
+
throws(() => resolveDocumentLoaderTimeout(invalid), RangeError);
|
|
1176
|
+
}
|
|
1177
|
+
throws(() => getDocumentLoader({ timeout: 0 }), RangeError);
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
test("getDocumentLoader() times out a request without a response", async () => {
|
|
1181
|
+
await withServer(() => {
|
|
1182
|
+
// Never respond.
|
|
1183
|
+
}, async (baseUrl) => {
|
|
1184
|
+
const url = `${baseUrl}/hang`;
|
|
1185
|
+
const loader = getDocumentLoader({
|
|
1186
|
+
allowPrivateAddress: true,
|
|
1187
|
+
timeout: 100,
|
|
1188
|
+
});
|
|
1189
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test("getDocumentLoader() times out a stalled response body", async () => {
|
|
1194
|
+
for (const status of [200, 404]) {
|
|
1195
|
+
await withServer((_, response) => {
|
|
1196
|
+
response.writeHead(status, {
|
|
1197
|
+
"Content-Type": "application/activity+json",
|
|
1198
|
+
});
|
|
1199
|
+
response.write('{"id":');
|
|
1200
|
+
// Never finish the body.
|
|
1201
|
+
}, async (baseUrl) => {
|
|
1202
|
+
const url = `${baseUrl}/stalled`;
|
|
1203
|
+
const loader = getDocumentLoader({
|
|
1204
|
+
allowPrivateAddress: true,
|
|
1205
|
+
timeout: 200,
|
|
1206
|
+
});
|
|
1207
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test("getDocumentLoader() shares the timeout across redirects", async () => {
|
|
1213
|
+
let requests = 0;
|
|
1214
|
+
await withServer((request, response) => {
|
|
1215
|
+
requests++;
|
|
1216
|
+
const index = Number(request.url?.split("/").at(-1));
|
|
1217
|
+
setTimeout(() => {
|
|
1218
|
+
if (index >= 15) {
|
|
1219
|
+
response.writeHead(200, {
|
|
1220
|
+
"Content-Type": "application/activity+json",
|
|
1221
|
+
});
|
|
1222
|
+
response.end('{"done":true}');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
response.writeHead(302, { Location: `/redirect/${index + 1}` });
|
|
1226
|
+
response.end();
|
|
1227
|
+
}, 50);
|
|
1228
|
+
}, async (baseUrl) => {
|
|
1229
|
+
const url = `${baseUrl}/redirect/0`;
|
|
1230
|
+
// Each hop is well within the timeout, but the whole chain is not:
|
|
1231
|
+
const loader = getDocumentLoader({
|
|
1232
|
+
allowPrivateAddress: true,
|
|
1233
|
+
timeout: 300,
|
|
1234
|
+
});
|
|
1235
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1236
|
+
ok(requests < 16, `requests: ${requests}`);
|
|
1237
|
+
// Without a timeout, the chain completes:
|
|
1238
|
+
const unbounded = getDocumentLoader({
|
|
1239
|
+
allowPrivateAddress: true,
|
|
1240
|
+
timeout: null,
|
|
1241
|
+
});
|
|
1242
|
+
deepStrictEqual((await unbounded(url)).document, { done: true });
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("getDocumentLoader() shares the timeout across alternates", async (t) => {
|
|
1247
|
+
for (const html of [false, true]) {
|
|
1248
|
+
await t.step(html ? "HTML" : "Link header", async () => {
|
|
1249
|
+
await withServer((request, response) => {
|
|
1250
|
+
const index = Number(request.url?.split("/").at(-1));
|
|
1251
|
+
setTimeout(() => {
|
|
1252
|
+
if (index >= 15) {
|
|
1253
|
+
response.writeHead(200, {
|
|
1254
|
+
"Content-Type": "application/activity+json",
|
|
1255
|
+
});
|
|
1256
|
+
response.end('{"done":true}');
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const next = `/alternate/${index + 1}`;
|
|
1260
|
+
response.writeHead(
|
|
1261
|
+
200,
|
|
1262
|
+
html ? { "Content-Type": "text/html" } : {
|
|
1263
|
+
"Content-Type": "text/plain",
|
|
1264
|
+
Link:
|
|
1265
|
+
`<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
1266
|
+
},
|
|
1267
|
+
);
|
|
1268
|
+
response.end(
|
|
1269
|
+
html
|
|
1270
|
+
? `<link rel="alternate" type="application/activity+json" href="${next}">`
|
|
1271
|
+
: "not JSON",
|
|
1272
|
+
);
|
|
1273
|
+
}, 50);
|
|
1274
|
+
}, async (baseUrl) => {
|
|
1275
|
+
const url = `${baseUrl}/alternate/0`;
|
|
1276
|
+
const loader = getDocumentLoader({
|
|
1277
|
+
allowPrivateAddress: true,
|
|
1278
|
+
timeout: 300,
|
|
1279
|
+
});
|
|
1280
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("getDocumentLoader() lets the caller's signal abort a request", async () => {
|
|
1287
|
+
await withServer(() => {
|
|
1288
|
+
// Never respond.
|
|
1289
|
+
}, async (baseUrl) => {
|
|
1290
|
+
const url = `${baseUrl}/hang`;
|
|
1291
|
+
for (const timeout of [undefined, 60_000, null]) {
|
|
1292
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true, timeout });
|
|
1293
|
+
const controller = new AbortController();
|
|
1294
|
+
const reason = new Error("Canceled by the caller");
|
|
1295
|
+
setTimeout(() => controller.abort(reason), 50);
|
|
1296
|
+
await rejects(loader(url, { signal: controller.signal }), (e) => {
|
|
1297
|
+
deepStrictEqual(e, reason);
|
|
1298
|
+
return true;
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
// The caller's signal wins even if the timeout fires too:
|
|
1302
|
+
const loader = getDocumentLoader({
|
|
1303
|
+
allowPrivateAddress: true,
|
|
1304
|
+
timeout: 50,
|
|
1305
|
+
});
|
|
1306
|
+
const controller = new AbortController();
|
|
1307
|
+
const reason = new Error("Canceled by the caller");
|
|
1308
|
+
controller.abort(reason);
|
|
1309
|
+
await rejects(loader(url, { signal: controller.signal }), (e) => {
|
|
1310
|
+
deepStrictEqual(e, reason);
|
|
1311
|
+
return true;
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async () => {
|
|
1317
|
+
// The validator skips DNS when Deno has no network permission.
|
|
1318
|
+
if (
|
|
1319
|
+
"Deno" in globalThis &&
|
|
1320
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
1321
|
+
) {
|
|
1322
|
+
throw new Error(
|
|
1323
|
+
"This DNS test requires Deno network permission (--allow-net).",
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
fetchMock.mockGlobal();
|
|
1327
|
+
let requests = 0;
|
|
1328
|
+
const url = "https://slow-dns.example/object";
|
|
1329
|
+
fetchMock.get(url, () => {
|
|
1330
|
+
requests++;
|
|
1331
|
+
return Response.json({ id: url });
|
|
1332
|
+
});
|
|
1333
|
+
// Stubbing works only because url.ts uses the default node:dns/promises
|
|
1334
|
+
// import; see the FIXME there.
|
|
1335
|
+
const originalLookup = dns.lookup;
|
|
1336
|
+
let resolveLookup: () => void = () => {};
|
|
1337
|
+
const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
|
|
1338
|
+
dns.lookup = (() =>
|
|
1339
|
+
new Promise((resolve) =>
|
|
1340
|
+
setTimeout(() => {
|
|
1341
|
+
resolve([{ address: "93.184.215.14", family: 4 }]);
|
|
1342
|
+
resolveLookup();
|
|
1343
|
+
}, 200)
|
|
1344
|
+
)) as typeof dns.lookup;
|
|
1345
|
+
try {
|
|
1346
|
+
const loader = getDocumentLoader({ timeout: 50 });
|
|
1347
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1348
|
+
// A late DNS answer must not start the request anymore:
|
|
1349
|
+
await lookedUp;
|
|
1350
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1351
|
+
deepStrictEqual(requests, 0);
|
|
1352
|
+
} finally {
|
|
1353
|
+
dns.lookup = originalLookup;
|
|
1354
|
+
fetchMock.hardReset();
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
test("withDocumentLoaderTimeout() cleans up after each call", async () => {
|
|
1359
|
+
const signals: AbortSignal[] = [];
|
|
1360
|
+
const loader = withDocumentLoaderTimeout((url, options) => {
|
|
1361
|
+
signals.push(options!.signal!);
|
|
1362
|
+
return Promise.resolve({
|
|
1363
|
+
contextUrl: null,
|
|
1364
|
+
document: {},
|
|
1365
|
+
documentUrl: url,
|
|
1366
|
+
});
|
|
1367
|
+
}, 50);
|
|
1368
|
+
const results = await Promise.all([
|
|
1369
|
+
loader("https://example.com/a"),
|
|
1370
|
+
loader("https://example.com/b"),
|
|
1371
|
+
]);
|
|
1372
|
+
deepStrictEqual(results.map((r) => r.documentUrl), [
|
|
1373
|
+
"https://example.com/a",
|
|
1374
|
+
"https://example.com/b",
|
|
1375
|
+
]);
|
|
1376
|
+
ok(signals[0] !== signals[1]);
|
|
1377
|
+
// The timers are cleared, so the signals are never aborted:
|
|
1378
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1379
|
+
ok(signals.every((signal) => !signal.aborted));
|
|
1380
|
+
// A loader that is given no timeout is returned as is:
|
|
1381
|
+
const inner = () => Promise.reject(new Error("unused"));
|
|
1382
|
+
ok(withDocumentLoaderTimeout(inner, null) === inner);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
test("getRemoteDocument() keeps error bodies byte for byte", async () => {
|
|
1386
|
+
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x4e, 0x6f, 0xff, 0xfe]);
|
|
1387
|
+
const url = "https://example.com/error";
|
|
1388
|
+
let error: unknown;
|
|
1389
|
+
await rejects(
|
|
1390
|
+
getRemoteDocument(
|
|
1391
|
+
url,
|
|
1392
|
+
new Response(bytes, { status: 404 }),
|
|
1393
|
+
() => Promise.reject(new Error("unused")),
|
|
1394
|
+
),
|
|
1395
|
+
(e) => {
|
|
1396
|
+
error = e;
|
|
1397
|
+
return true;
|
|
1398
|
+
},
|
|
1399
|
+
);
|
|
1400
|
+
ok(error instanceof FetchError);
|
|
1401
|
+
ok(error.response != null);
|
|
1402
|
+
deepStrictEqual(error.response.status, 404);
|
|
1403
|
+
deepStrictEqual(new Uint8Array(await error.response.arrayBuffer()), bytes);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("getRemoteDocument() keeps metadata of bodiless or large errors", async () => {
|
|
1407
|
+
const url = "https://example.com/error";
|
|
1408
|
+
for (
|
|
1409
|
+
const response of [
|
|
1410
|
+
new Response(null, { status: 304, headers: { ETag: '"a"' } }),
|
|
1411
|
+
new Response(new Uint8Array(1024 * 1024 + 1), {
|
|
1412
|
+
status: 500,
|
|
1413
|
+
headers: { ETag: '"a"' },
|
|
1414
|
+
}),
|
|
1415
|
+
]
|
|
1416
|
+
) {
|
|
1417
|
+
await rejects(
|
|
1418
|
+
getRemoteDocument(
|
|
1419
|
+
url,
|
|
1420
|
+
response,
|
|
1421
|
+
() => Promise.reject(new Error("unused")),
|
|
1422
|
+
),
|
|
1423
|
+
(e) => {
|
|
1424
|
+
ok(e instanceof FetchError);
|
|
1425
|
+
ok(e.response != null);
|
|
1426
|
+
deepStrictEqual(e.response.status, response.status);
|
|
1427
|
+
deepStrictEqual(e.response.headers.get("ETag"), '"a"');
|
|
1428
|
+
deepStrictEqual(e.response.body, null);
|
|
1429
|
+
return true;
|
|
1430
|
+
},
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
test("getDocumentLoader() reports statuses that Response cannot hold", async () => {
|
|
1436
|
+
await withServer((_, response) => {
|
|
1437
|
+
response.writeHead(999);
|
|
1438
|
+
response.end("Request denied");
|
|
1439
|
+
}, async (baseUrl) => {
|
|
1440
|
+
const url = `${baseUrl}/denied`;
|
|
1441
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
1442
|
+
let error: unknown;
|
|
1443
|
+
await rejects(loader(url), (e) => {
|
|
1444
|
+
error = e;
|
|
1445
|
+
return true;
|
|
1446
|
+
});
|
|
1447
|
+
ok(error instanceof FetchError, String(error));
|
|
1448
|
+
deepStrictEqual(error.response?.status, 999);
|
|
1449
|
+
deepStrictEqual(await error.response.text(), "Request denied");
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
test("getDocumentLoader() times out a stalled body of such a status", async () => {
|
|
1454
|
+
await withServer((_, response) => {
|
|
1455
|
+
response.writeHead(999);
|
|
1456
|
+
response.write("Request");
|
|
1457
|
+
// Never finish the body.
|
|
1458
|
+
}, async (baseUrl) => {
|
|
1459
|
+
const url = `${baseUrl}/stalled-denied`;
|
|
1460
|
+
const loader = getDocumentLoader({
|
|
1461
|
+
allowPrivateAddress: true,
|
|
1462
|
+
timeout: 200,
|
|
1463
|
+
});
|
|
1464
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1465
|
+
});
|
|
1466
|
+
});
|