@fedify/webfinger 2.4.5-dev.2462 → 2.4.5

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.
@@ -1,10 +1,11 @@
1
1
  import { createTestMeterProvider, test } from "@fedify/fixture";
2
- import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
2
+ import { FetchError, UrlError, getUserAgent } from "@fedify/vocab-runtime";
3
3
  import { configure, getLogger, reset } from "@logtape/logtape";
4
4
  import { withTimeout } from "es-toolkit";
5
5
  import { deepStrictEqual, ok } from "node:assert/strict";
6
- import dns from "node:dns/promises";
7
6
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
7
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
8
+ import { test as test$1 } from "node:test";
8
9
  //#region \0rolldown/runtime.js
9
10
  var __create = Object.create;
10
11
  var __defProp = Object.defineProperty;
@@ -1235,7 +1236,7 @@ var esm_default = new class FetchMock {
1235
1236
  //#endregion
1236
1237
  //#region deno.json
1237
1238
  var name = "@fedify/webfinger";
1238
- var version = "2.4.5-dev.2462+46a3f2c5";
1239
+ var version = "2.4.5";
1239
1240
  //#endregion
1240
1241
  //#region src/body.ts
1241
1242
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1311,13 +1312,223 @@ async function readBoundedText(message, maxBytes, url) {
1311
1312
  }
1312
1313
  }
1313
1314
  //#endregion
1314
- //#region src/lookup.ts
1315
+ //#region src/lookup-impl.ts
1315
1316
  const logger = getLogger([
1316
1317
  "fedify",
1317
1318
  "webfinger",
1318
1319
  "lookup"
1319
1320
  ]);
1320
1321
  const DEFAULT_MAX_REDIRECTION = 5;
1322
+ /**
1323
+ * Looks up a WebFinger resource, opening a fetch session per hop. This is
1324
+ * the implementation of {@link lookupWebFinger}, which is not exported from
1325
+ * the package entry point.
1326
+ * @param resource The resource URL to look up.
1327
+ * @param options Extra options for looking up the resource.
1328
+ * @param deps The dependencies.
1329
+ * @returns The resource descriptor and terminal lookup classification.
1330
+ * @internal
1331
+ */
1332
+ async function lookupWebFingerInternal(resource, options = {}, deps = {}) {
1333
+ if (typeof resource === "string") resource = new URL(resource);
1334
+ let protocol = "https:";
1335
+ let server;
1336
+ if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
1337
+ const atPos = resource.pathname.lastIndexOf("@");
1338
+ if (atPos < 0) return {
1339
+ resource: null,
1340
+ result: "invalid"
1341
+ };
1342
+ server = resource.pathname.substring(atPos + 1);
1343
+ if (server === "" || /[/?#]/.test(server)) return {
1344
+ resource: null,
1345
+ result: "invalid"
1346
+ };
1347
+ if (resource.protocol === "acct:" && (resource.search !== "" || resource.hash !== "")) return {
1348
+ resource: null,
1349
+ result: "invalid"
1350
+ };
1351
+ } else {
1352
+ protocol = resource.protocol;
1353
+ server = resource.host;
1354
+ }
1355
+ let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
1356
+ url.searchParams.set("resource", resource.href);
1357
+ const openHop = deps.openHop ?? ((hopUrl) => options.allowPrivateAddress === true ? openDirectFetch(hopUrl) : openPublicFetch(hopUrl, { signal: options.signal }));
1358
+ let redirected = 0;
1359
+ while (true) {
1360
+ logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
1361
+ const remoteHost = url.host;
1362
+ const session = await openSession(url, openHop, options.signal);
1363
+ if (session == null) return {
1364
+ resource: null,
1365
+ result: "network_error",
1366
+ remoteHost
1367
+ };
1368
+ let hop;
1369
+ try {
1370
+ hop = await fetchHop(session, url, options);
1371
+ } finally {
1372
+ await session.close();
1373
+ }
1374
+ if ("outcome" in hop) return hop.outcome;
1375
+ redirected++;
1376
+ const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
1377
+ if (redirected > maxRedirection) {
1378
+ logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
1379
+ return {
1380
+ resource: null,
1381
+ result: "invalid",
1382
+ statusCode: hop.statusCode,
1383
+ remoteHost
1384
+ };
1385
+ }
1386
+ let redirectedUrl;
1387
+ try {
1388
+ redirectedUrl = new URL(hop.redirect, url);
1389
+ } catch (error) {
1390
+ logger.error("Invalid Location header while following WebFinger redirect: {error}", {
1391
+ url: url.href,
1392
+ error
1393
+ });
1394
+ return {
1395
+ resource: null,
1396
+ result: "invalid",
1397
+ statusCode: hop.statusCode,
1398
+ remoteHost
1399
+ };
1400
+ }
1401
+ if (redirectedUrl.protocol !== url.protocol) {
1402
+ logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
1403
+ protocol: url.protocol,
1404
+ redirectedProtocol: redirectedUrl.protocol
1405
+ });
1406
+ return {
1407
+ resource: null,
1408
+ result: "invalid",
1409
+ statusCode: hop.statusCode,
1410
+ remoteHost
1411
+ };
1412
+ }
1413
+ url = redirectedUrl;
1414
+ }
1415
+ }
1416
+ /**
1417
+ * Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
1418
+ * or an aborted lookup yields `null` instead of an error.
1419
+ */
1420
+ async function openSession(url, openHop, signal) {
1421
+ try {
1422
+ return await openHop(url.href);
1423
+ } catch (e) {
1424
+ if (e instanceof UrlError) {
1425
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1426
+ url: url.href,
1427
+ error: e
1428
+ });
1429
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1430
+ return null;
1431
+ }
1432
+ if (signal?.aborted) {
1433
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1434
+ url: url.href,
1435
+ error: e
1436
+ });
1437
+ return null;
1438
+ }
1439
+ throw e;
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Fetches a hop with the given session, and consumes the response body or
1444
+ * starts cancelling it before returning. The caller closes the session.
1445
+ */
1446
+ async function fetchHop(session, url, options) {
1447
+ let response;
1448
+ try {
1449
+ response = await session.fetch(url, {
1450
+ headers: {
1451
+ Accept: "application/jrd+json",
1452
+ "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
1453
+ },
1454
+ redirect: "manual",
1455
+ signal: options.signal
1456
+ });
1457
+ } catch (error) {
1458
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1459
+ url: url.href,
1460
+ error
1461
+ });
1462
+ return { outcome: {
1463
+ resource: null,
1464
+ result: "network_error",
1465
+ remoteHost: url.host
1466
+ } };
1467
+ }
1468
+ if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
1469
+ cancelBody(response, url);
1470
+ return {
1471
+ redirect: response.headers.get("Location"),
1472
+ statusCode: response.status
1473
+ };
1474
+ }
1475
+ if (!response.ok) {
1476
+ logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
1477
+ url: url.href,
1478
+ status: response.status,
1479
+ statusText: response.statusText
1480
+ });
1481
+ cancelBody(response, url);
1482
+ return { outcome: {
1483
+ resource: null,
1484
+ result: response.status === 404 || response.status === 410 ? "not_found" : "error",
1485
+ statusCode: response.status,
1486
+ remoteHost: url.host
1487
+ } };
1488
+ }
1489
+ try {
1490
+ return { outcome: {
1491
+ resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1492
+ result: "found",
1493
+ statusCode: response.status,
1494
+ remoteHost: url.host
1495
+ } };
1496
+ } catch (e) {
1497
+ if (e instanceof BodyTooLargeError) return { outcome: {
1498
+ resource: null,
1499
+ result: "invalid",
1500
+ statusCode: response.status,
1501
+ remoteHost: url.host
1502
+ } };
1503
+ if (e instanceof SyntaxError) {
1504
+ logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1505
+ return { outcome: {
1506
+ resource: null,
1507
+ result: "invalid",
1508
+ statusCode: response.status,
1509
+ remoteHost: url.host
1510
+ } };
1511
+ }
1512
+ throw e;
1513
+ }
1514
+ }
1515
+ /**
1516
+ * Starts cancelling a response body that is not read, without waiting for it
1517
+ * to settle. The cancellation of a branch of a tee (e.g., a body cloned by
1518
+ * a fetch mock) settles only after the other branch is cancelled too, per
1519
+ * the Streams standard, so awaiting it could block forever. A failure is
1520
+ * only logged.
1521
+ */
1522
+ function cancelBody(response, url) {
1523
+ response.body?.cancel().catch((error) => {
1524
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
1525
+ url: url.href,
1526
+ error
1527
+ });
1528
+ });
1529
+ }
1530
+ //#endregion
1531
+ //#region src/lookup.ts
1321
1532
  const WEBFINGER_HISTOGRAM_BUCKETS = [
1322
1533
  5,
1323
1534
  10,
@@ -1371,13 +1582,11 @@ function getMetricResourceScheme(scheme) {
1371
1582
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
1372
1583
  }
1373
1584
  /**
1374
- * Looks up a WebFinger resource.
1375
- * @param resource The resource URL to look up.
1376
- * @param options Extra options for looking up the resource.
1377
- * @returns The resource descriptor, or `null` if not found.
1378
- * @since 0.2.0
1585
+ * The lookup implementation with injectable hop sessions for tests.
1586
+ * Not exported from the package entry point.
1587
+ * @internal
1379
1588
  */
1380
- async function lookupWebFinger(resource, options = {}) {
1589
+ async function lookupWebFingerWithDependencies(resource, options = {}, deps = {}) {
1381
1590
  const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
1382
1591
  const scheme = getResourceScheme(resource);
1383
1592
  return await tracer.startActiveSpan("webfinger.lookup", {
@@ -1394,7 +1603,7 @@ async function lookupWebFinger(resource, options = {}) {
1394
1603
  result: "error"
1395
1604
  };
1396
1605
  try {
1397
- outcome = await lookupWebFingerInternal(resource, options);
1606
+ outcome = await lookupWebFingerInternal(resource, options, deps);
1398
1607
  span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1399
1608
  return outcome.resource;
1400
1609
  } catch (error) {
@@ -1420,181 +1629,56 @@ function recordWebFingerLookup(meterProvider, durationMs, scheme, outcome) {
1420
1629
  instruments.lookup.add(1, attributes);
1421
1630
  instruments.lookupDuration.record(durationMs, attributes);
1422
1631
  }
1423
- async function lookupWebFingerInternal(resource, options = {}) {
1424
- if (typeof resource === "string") resource = new URL(resource);
1425
- let protocol = "https:";
1426
- let server;
1427
- if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
1428
- const atPos = resource.pathname.lastIndexOf("@");
1429
- if (atPos < 0) return {
1430
- resource: null,
1431
- result: "invalid"
1432
- };
1433
- server = resource.pathname.substring(atPos + 1);
1434
- if (server === "" || /[/?#]/.test(server)) return {
1435
- resource: null,
1436
- result: "invalid"
1437
- };
1438
- if (resource.protocol === "acct:" && (resource.search !== "" || resource.hash !== "")) return {
1439
- resource: null,
1440
- result: "invalid"
1441
- };
1442
- } else {
1443
- protocol = resource.protocol;
1444
- server = resource.host;
1445
- }
1446
- let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
1447
- url.searchParams.set("resource", resource.href);
1448
- let redirected = 0;
1449
- while (true) {
1450
- const remoteHost = url.host;
1451
- logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
1452
- let response;
1453
- if (options.allowPrivateAddress !== true) try {
1454
- await validatePublicUrl(url.href);
1455
- } catch (e) {
1456
- if (e instanceof UrlError) {
1457
- if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1458
- url: url.href,
1459
- error: e
1460
- });
1461
- else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1462
- return {
1463
- resource: null,
1464
- result: "network_error",
1465
- remoteHost
1466
- };
1467
- }
1468
- throw e;
1469
- }
1470
- try {
1471
- response = await fetch(url, {
1472
- headers: {
1473
- Accept: "application/jrd+json",
1474
- "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
1475
- },
1476
- redirect: "manual",
1477
- signal: options.signal
1478
- });
1479
- } catch (error) {
1480
- logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1481
- url: url.href,
1482
- error
1483
- });
1484
- return {
1485
- resource: null,
1486
- result: "network_error",
1487
- remoteHost
1488
- };
1489
- }
1490
- if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
1491
- redirected++;
1492
- const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
1493
- if (redirected > maxRedirection) {
1494
- logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
1495
- return {
1496
- resource: null,
1497
- result: "invalid",
1498
- statusCode: response.status,
1499
- remoteHost
1500
- };
1501
- }
1502
- let redirectedUrl;
1503
- try {
1504
- redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
1505
- } catch (e) {
1506
- logger.error("Invalid Location header while following WebFinger redirect: {error}", {
1507
- url: url.href,
1508
- error: e
1509
- });
1510
- return {
1511
- resource: null,
1512
- result: "invalid",
1513
- statusCode: response.status,
1514
- remoteHost
1515
- };
1516
- }
1517
- if (redirectedUrl.protocol !== url.protocol) {
1518
- logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
1519
- protocol: url.protocol,
1520
- redirectedProtocol: redirectedUrl.protocol
1521
- });
1522
- return {
1523
- resource: null,
1524
- result: "invalid",
1525
- statusCode: response.status,
1526
- remoteHost
1527
- };
1528
- }
1529
- url = redirectedUrl;
1530
- continue;
1531
- }
1532
- if (!response.ok) {
1533
- logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
1534
- url: url.href,
1535
- status: response.status,
1536
- statusText: response.statusText
1537
- });
1538
- return {
1539
- resource: null,
1540
- result: response.status === 404 || response.status === 410 ? "not_found" : "error",
1541
- statusCode: response.status,
1542
- remoteHost
1543
- };
1544
- }
1545
- try {
1546
- return {
1547
- resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1548
- result: "found",
1549
- statusCode: response.status,
1550
- remoteHost
1551
- };
1552
- } catch (e) {
1553
- if (e instanceof BodyTooLargeError) return {
1554
- resource: null,
1555
- result: "invalid",
1556
- statusCode: response.status,
1557
- remoteHost
1558
- };
1559
- if (e instanceof SyntaxError) {
1560
- logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1561
- return {
1562
- resource: null,
1563
- result: "invalid",
1564
- statusCode: response.status,
1565
- remoteHost
1566
- };
1567
- }
1568
- throw e;
1569
- }
1570
- }
1571
- }
1572
1632
  //#endregion
1573
1633
  //#region src/lookup.test.ts
1634
+ function lookupMock(resource, options = {}) {
1635
+ if (resource.toString().includes("fedify-test.internal")) return lookupWebFingerWithDependencies(resource, options, { openHop: (url) => Promise.resolve({
1636
+ url: new URL(url),
1637
+ fetch: () => Promise.reject(/* @__PURE__ */ new TypeError("Connection refused")),
1638
+ close: () => Promise.resolve()
1639
+ }) });
1640
+ return lookupWebFingerWithDependencies(resource, options, { openHop: options.allowPrivateAddress === true ? openDirectFetch : openExampleDirectly });
1641
+ }
1642
+ function lookupDirect(resource, options) {
1643
+ return lookupWebFingerWithDependencies(resource, options, { openHop: openDirectFetch });
1644
+ }
1645
+ function lookupResolving(addresses) {
1646
+ return () => Promise.resolve(addresses);
1647
+ }
1648
+ function openExampleDirectly(url) {
1649
+ return new URL(url).hostname === "example.com" ? openDirectFetch(url) : openPublicFetch(url, { lookup: lookupResolving([{
1650
+ address: "93.184.215.14",
1651
+ family: 4
1652
+ }]) });
1653
+ }
1654
+ async function lookupResourceWithDependencies(resource, options, deps) {
1655
+ return (await lookupWebFingerInternal(resource, options, deps)).resource;
1656
+ }
1574
1657
  test({
1575
1658
  name: "lookupWebFinger()",
1576
1659
  sanitizeOps: false,
1577
1660
  sanitizeResources: false,
1578
1661
  async fn(t) {
1662
+ const responseTimers = [];
1579
1663
  await t.step("invalid resource", async () => {
1580
- deepStrictEqual(await lookupWebFinger("acct:johndoe"), null);
1581
- deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe")), null);
1582
- deepStrictEqual(await lookupWebFinger("acct:johndoe@"), null);
1583
- deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe@")), null);
1584
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com/exploit"), null);
1585
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com?x=1"), null);
1586
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com#frag"), null);
1664
+ deepStrictEqual(await lookupMock("acct:johndoe"), null);
1665
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe")), null);
1666
+ deepStrictEqual(await lookupMock("acct:johndoe@"), null);
1667
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe@")), null);
1668
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com/exploit"), null);
1669
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com?x=1"), null);
1670
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com#frag"), null);
1587
1671
  });
1588
1672
  await t.step("connection refused", async () => {
1589
- deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal"), null);
1590
- deepStrictEqual(await lookupWebFinger("https://fedify-test.internal/foo"), null);
1673
+ deepStrictEqual(await lookupMock("acct:johndoe@fedify-test.internal"), null);
1674
+ deepStrictEqual(await lookupMock("https://fedify-test.internal/foo"), null);
1591
1675
  });
1592
1676
  esm_default.spyGlobal();
1593
1677
  try {
1594
1678
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1595
1679
  await t.step("not found", async () => {
1596
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1597
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
1680
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1681
+ deepStrictEqual(await lookupMock("https://example.com/foo"), null);
1598
1682
  });
1599
1683
  const expected = {
1600
1684
  subject: "acct:johndoe@example.com",
@@ -1603,7 +1687,7 @@ test({
1603
1687
  esm_default.removeRoutes();
1604
1688
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1605
1689
  await t.step("acct", async () => {
1606
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1690
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1607
1691
  });
1608
1692
  const expected2 = {
1609
1693
  subject: "https://example.com/foo",
@@ -1612,7 +1696,7 @@ test({
1612
1696
  esm_default.removeRoutes();
1613
1697
  esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
1614
1698
  await t.step("https", async () => {
1615
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), expected2);
1699
+ deepStrictEqual(await lookupMock("https://example.com/foo"), expected2);
1616
1700
  });
1617
1701
  const mailtoExpected = {
1618
1702
  subject: "mailto:juliet@example.com",
@@ -1621,7 +1705,7 @@ test({
1621
1705
  esm_default.removeRoutes();
1622
1706
  esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com", { body: mailtoExpected });
1623
1707
  await t.step("mailto", async () => {
1624
- deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com"), mailtoExpected);
1708
+ deepStrictEqual(await lookupMock("mailto:juliet@example.com"), mailtoExpected);
1625
1709
  });
1626
1710
  const mailtoQueryExpected = {
1627
1711
  subject: "mailto:juliet@example.com?subject=Hi",
@@ -1630,12 +1714,12 @@ test({
1630
1714
  esm_default.removeRoutes();
1631
1715
  esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com%3Fsubject%3DHi", { body: mailtoQueryExpected });
1632
1716
  await t.step("mailto with hfields", async () => {
1633
- deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com?subject=Hi"), mailtoQueryExpected);
1717
+ deepStrictEqual(await lookupMock("mailto:juliet@example.com?subject=Hi"), mailtoQueryExpected);
1634
1718
  });
1635
1719
  esm_default.removeRoutes();
1636
1720
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1637
1721
  await t.step("invalid response", async () => {
1638
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1722
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1639
1723
  });
1640
1724
  esm_default.removeRoutes();
1641
1725
  esm_default.get("begin:https://localhost/.well-known/webfinger?", {
@@ -1647,8 +1731,8 @@ test({
1647
1731
  }]
1648
1732
  });
1649
1733
  await t.step("private address", async () => {
1650
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
1651
- deepStrictEqual(await lookupWebFinger("acct:test@localhost", { allowPrivateAddress: true }), {
1734
+ deepStrictEqual(await lookupMock("acct:test@localhost"), null);
1735
+ deepStrictEqual(await lookupMock("acct:test@localhost", { allowPrivateAddress: true }), {
1652
1736
  subject: "acct:test@localhost",
1653
1737
  links: [{
1654
1738
  rel: "self",
@@ -1664,7 +1748,7 @@ test({
1664
1748
  });
1665
1749
  esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
1666
1750
  await t.step("redirection", async () => {
1667
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1751
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1668
1752
  });
1669
1753
  esm_default.removeRoutes();
1670
1754
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1672,7 +1756,7 @@ test({
1672
1756
  headers: { Location: "/.well-known/webfinger" }
1673
1757
  });
1674
1758
  await t.step("infinite redirection", async () => {
1675
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com"), 2e3), null);
1759
+ deepStrictEqual(await withTimeout(() => lookupMock("acct:johndoe@example.com"), 2e3), null);
1676
1760
  });
1677
1761
  esm_default.removeRoutes();
1678
1762
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1680,7 +1764,7 @@ test({
1680
1764
  headers: { Location: "ftp://example.com/" }
1681
1765
  });
1682
1766
  await t.step("redirection to different protocol", async () => {
1683
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1767
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1684
1768
  });
1685
1769
  esm_default.removeRoutes();
1686
1770
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1688,7 +1772,7 @@ test({
1688
1772
  headers: { Location: "https://localhost/" }
1689
1773
  });
1690
1774
  await t.step("redirection to private address", async () => {
1691
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1775
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1692
1776
  });
1693
1777
  esm_default.removeRoutes();
1694
1778
  let redirectCount = 0;
@@ -1702,13 +1786,13 @@ test({
1702
1786
  });
1703
1787
  await t.step("custom maxRedirection", async () => {
1704
1788
  redirectCount = 0;
1705
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1789
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1706
1790
  redirectCount = 0;
1707
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 2 }), expected);
1791
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 2 }), expected);
1708
1792
  redirectCount = 0;
1709
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1793
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1710
1794
  redirectCount = 0;
1711
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1795
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1712
1796
  });
1713
1797
  await t.step("maxRedirection: 1 follows exactly one redirect", async () => {
1714
1798
  esm_default.removeRoutes();
@@ -1720,7 +1804,7 @@ test({
1720
1804
  headers: { Location: "/.well-known/webfinger?after=1" }
1721
1805
  } : { body: expected };
1722
1806
  });
1723
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), expected);
1807
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), expected);
1724
1808
  esm_default.removeRoutes();
1725
1809
  count = 0;
1726
1810
  esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
@@ -1730,18 +1814,18 @@ test({
1730
1814
  headers: { Location: `/.well-known/webfinger?after=${count}` }
1731
1815
  } : { body: expected };
1732
1816
  });
1733
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1817
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1734
1818
  });
1735
1819
  esm_default.removeRoutes();
1736
1820
  esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1737
1821
  const timeoutId = setTimeout(() => {
1738
1822
  resolve({ body: expected });
1739
1823
  }, 1e3);
1740
- return () => clearTimeout(timeoutId);
1824
+ responseTimers.push(timeoutId);
1741
1825
  }));
1742
1826
  await t.step("request cancellation", async () => {
1743
1827
  const controller = new AbortController();
1744
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1828
+ const promise = lookupMock("acct:johndoe@example.com", { signal: controller.signal });
1745
1829
  controller.abort();
1746
1830
  deepStrictEqual(await promise, null);
1747
1831
  });
@@ -1757,12 +1841,12 @@ test({
1757
1841
  const timeoutId = setTimeout(() => {
1758
1842
  resolve({ body: expected });
1759
1843
  }, 1e3);
1760
- return () => clearTimeout(timeoutId);
1844
+ responseTimers.push(timeoutId);
1761
1845
  });
1762
1846
  });
1763
1847
  await t.step("cancellation during redirection", async () => {
1764
1848
  const controller = new AbortController();
1765
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1849
+ const promise = lookupMock("acct:johndoe@example.com", { signal: controller.signal });
1766
1850
  setTimeout(() => controller.abort(), 100);
1767
1851
  deepStrictEqual(await promise, null);
1768
1852
  });
@@ -1771,19 +1855,20 @@ test({
1771
1855
  const timeoutId = setTimeout(() => {
1772
1856
  resolve({ body: expected });
1773
1857
  }, 500);
1774
- return () => clearTimeout(timeoutId);
1858
+ responseTimers.push(timeoutId);
1775
1859
  }));
1776
1860
  await t.step("cancellation with immediate abort", async () => {
1777
1861
  const controller = new AbortController();
1778
1862
  controller.abort();
1779
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
1863
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { signal: controller.signal }), null);
1780
1864
  });
1781
1865
  esm_default.removeRoutes();
1782
1866
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
1783
1867
  await t.step("successful request with signal", async () => {
1784
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1868
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1785
1869
  });
1786
1870
  } finally {
1871
+ for (const timer of responseTimers) clearTimeout(timer);
1787
1872
  esm_default.removeRoutes();
1788
1873
  esm_default.hardReset();
1789
1874
  }
@@ -1803,7 +1888,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1803
1888
  esm_default.removeRoutes();
1804
1889
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1805
1890
  const [meterProvider, recorder] = createTestMeterProvider();
1806
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), expected);
1891
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { meterProvider }), expected);
1807
1892
  const counters = recorder.getMeasurements("webfinger.lookup");
1808
1893
  deepStrictEqual(counters.length, 1);
1809
1894
  deepStrictEqual(counters[0].type, "counter");
@@ -1826,7 +1911,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1826
1911
  links: []
1827
1912
  } });
1828
1913
  const [meterProvider, recorder] = createTestMeterProvider();
1829
- await lookupWebFinger("https://example.com/foo", { meterProvider });
1914
+ await lookupMock("https://example.com/foo", { meterProvider });
1830
1915
  const counters = recorder.getMeasurements("webfinger.lookup");
1831
1916
  deepStrictEqual(counters.length, 1);
1832
1917
  deepStrictEqual(counters[0].attributes["webfinger.resource.scheme"], "https");
@@ -1839,7 +1924,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1839
1924
  links: []
1840
1925
  } });
1841
1926
  const [meterProvider, recorder] = createTestMeterProvider();
1842
- await lookupWebFinger("https://example.com:8443/foo", { meterProvider });
1927
+ await lookupMock("https://example.com:8443/foo", { meterProvider });
1843
1928
  const counter = recorder.getMeasurement("webfinger.lookup");
1844
1929
  ok(counter != null);
1845
1930
  deepStrictEqual(counter.attributes["activitypub.remote.host"], "example.com:8443");
@@ -1848,7 +1933,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1848
1933
  esm_default.removeRoutes();
1849
1934
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1850
1935
  const [meterProvider, recorder] = createTestMeterProvider();
1851
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), null);
1936
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { meterProvider }), null);
1852
1937
  const counters = recorder.getMeasurements("webfinger.lookup");
1853
1938
  deepStrictEqual(counters.length, 1);
1854
1939
  deepStrictEqual(counters[0].attributes["webfinger.lookup.result"], "not_found");
@@ -1862,7 +1947,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1862
1947
  esm_default.removeRoutes();
1863
1948
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 410 });
1864
1949
  const [meterProvider, recorder] = createTestMeterProvider();
1865
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1950
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1866
1951
  const counter = recorder.getMeasurement("webfinger.lookup");
1867
1952
  ok(counter != null);
1868
1953
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "not_found");
@@ -1872,7 +1957,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1872
1957
  esm_default.removeRoutes();
1873
1958
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
1874
1959
  const [meterProvider, recorder] = createTestMeterProvider();
1875
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1960
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1876
1961
  const counter = recorder.getMeasurement("webfinger.lookup");
1877
1962
  ok(counter != null);
1878
1963
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "error");
@@ -1882,7 +1967,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1882
1967
  esm_default.removeRoutes();
1883
1968
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1884
1969
  const [meterProvider, recorder] = createTestMeterProvider();
1885
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1970
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1886
1971
  const counter = recorder.getMeasurement("webfinger.lookup");
1887
1972
  ok(counter != null);
1888
1973
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1891,7 +1976,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1891
1976
  await t.step("records result=network_error when fetch never reaches the remote", async () => {
1892
1977
  esm_default.removeRoutes();
1893
1978
  const [meterProvider, recorder] = createTestMeterProvider();
1894
- deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal", { meterProvider }), null);
1979
+ deepStrictEqual(await lookupMock("acct:johndoe@fedify-test.internal", { meterProvider }), null);
1895
1980
  const counter = recorder.getMeasurement("webfinger.lookup");
1896
1981
  ok(counter != null);
1897
1982
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
@@ -1900,7 +1985,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1900
1985
  });
1901
1986
  await t.step("records result=invalid for malformed acct: resources", async () => {
1902
1987
  const [meterProvider, recorder] = createTestMeterProvider();
1903
- deepStrictEqual(await lookupWebFinger("acct:johndoe", { meterProvider }), null);
1988
+ deepStrictEqual(await lookupMock("acct:johndoe", { meterProvider }), null);
1904
1989
  const counter = recorder.getMeasurement("webfinger.lookup");
1905
1990
  ok(counter != null);
1906
1991
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1914,7 +1999,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1914
1999
  headers: { Location: "/.well-known/webfinger" }
1915
2000
  });
1916
2001
  const [meterProvider, recorder] = createTestMeterProvider();
1917
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com", {
2002
+ deepStrictEqual(await withTimeout(() => lookupMock("acct:johndoe@example.com", {
1918
2003
  meterProvider,
1919
2004
  maxRedirection: 3
1920
2005
  }), 2e3), null);
@@ -1931,7 +2016,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1931
2016
  headers: { Location: "ftp://example.com/" }
1932
2017
  });
1933
2018
  const [meterProvider, recorder] = createTestMeterProvider();
1934
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2019
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1935
2020
  const counter = recorder.getMeasurement("webfinger.lookup");
1936
2021
  ok(counter != null);
1937
2022
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1944,7 +2029,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1944
2029
  headers: { Location: "https://localhost/" }
1945
2030
  });
1946
2031
  const [meterProvider, recorder] = createTestMeterProvider();
1947
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2032
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1948
2033
  const counter = recorder.getMeasurement("webfinger.lookup");
1949
2034
  ok(counter != null);
1950
2035
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
@@ -1957,7 +2042,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1957
2042
  headers: { Location: "http://[bad" }
1958
2043
  });
1959
2044
  const [meterProvider, recorder] = createTestMeterProvider();
1960
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2045
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1961
2046
  const counter = recorder.getMeasurement("webfinger.lookup");
1962
2047
  ok(counter != null);
1963
2048
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1967,7 +2052,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1967
2052
  await t.step("buckets unknown resource schemes as 'other' to keep metric cardinality bounded", async () => {
1968
2053
  esm_default.removeRoutes();
1969
2054
  const [meterProvider, recorder] = createTestMeterProvider();
1970
- await lookupWebFinger("ssh://example.com/foo", { meterProvider });
2055
+ await lookupMock("ssh://example.com/foo", { meterProvider });
1971
2056
  const counter = recorder.getMeasurement("webfinger.lookup");
1972
2057
  ok(counter != null);
1973
2058
  deepStrictEqual(counter.attributes["webfinger.resource.scheme"], "other");
@@ -1976,7 +2061,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1976
2061
  esm_default.removeRoutes();
1977
2062
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1978
2063
  const [_unused, recorder] = createTestMeterProvider();
1979
- await lookupWebFinger("acct:johndoe@example.com");
2064
+ await lookupMock("acct:johndoe@example.com");
1980
2065
  deepStrictEqual(recorder.getMeasurements("webfinger.lookup").length, 0);
1981
2066
  deepStrictEqual(recorder.getMeasurements("webfinger.lookup.duration").length, 0);
1982
2067
  });
@@ -1993,25 +2078,23 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
1993
2078
  "Content-Type": "application/jrd+json",
1994
2079
  "Content-Length": "1"
1995
2080
  } }));
1996
- deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
2081
+ deepStrictEqual(await lookupMock("acct:alice@example.com", { allowPrivateAddress: true }), null);
1997
2082
  oversized = false;
1998
- deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
2083
+ deepStrictEqual(await lookupMock("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1999
2084
  } finally {
2000
2085
  esm_default.hardReset();
2001
2086
  }
2002
2087
  });
2003
2088
  test("lookupWebFinger() logs DNS failures as such", async (t) => {
2004
- if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
2005
2089
  for (const result of [
2006
2090
  "throws",
2007
2091
  "empty",
2008
2092
  "private"
2009
2093
  ]) await t.step(result, async () => {
2010
- const originalLookup = dns.lookup;
2011
- dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
2094
+ const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
2012
2095
  address: "127.0.0.1",
2013
2096
  family: 4
2014
- }]));
2097
+ }]);
2015
2098
  const records = [];
2016
2099
  await configure({
2017
2100
  sinks: { buffer: (record) => records.push(record) },
@@ -2026,7 +2109,7 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
2026
2109
  reset: true
2027
2110
  });
2028
2111
  try {
2029
- deepStrictEqual(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
2112
+ deepStrictEqual(await lookupResourceWithDependencies("acct:alice@dns-failure.invalid", {}, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
2030
2113
  const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
2031
2114
  deepStrictEqual(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
2032
2115
  const { error } = failures[0].properties;
@@ -2034,9 +2117,267 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
2034
2117
  deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
2035
2118
  } finally {
2036
2119
  await reset();
2037
- dns.lookup = originalLookup;
2038
2120
  }
2039
2121
  });
2040
2122
  });
2123
+ function recordHops(respond) {
2124
+ const events = [];
2125
+ const unsettled = [];
2126
+ let opens = 0;
2127
+ let closes = 0;
2128
+ let responses = 0;
2129
+ return {
2130
+ events,
2131
+ async openHop(url) {
2132
+ opens++;
2133
+ events.push(`open ${url}`);
2134
+ const states = [];
2135
+ let closed = false;
2136
+ return {
2137
+ url: new URL(url),
2138
+ async fetch(input) {
2139
+ if (closed) throw new TypeError("The fetch session is closed.");
2140
+ const target = new URL(input instanceof Request ? input.url : input);
2141
+ const id = ++responses;
2142
+ events.push(`fetch ${target.href} #${id}`);
2143
+ const fake = respond(target);
2144
+ const state = {
2145
+ id,
2146
+ settled: false
2147
+ };
2148
+ states.push(state);
2149
+ let sent = false;
2150
+ const body = new ReadableStream({
2151
+ pull(controller) {
2152
+ if (!sent && fake.body != null && fake.body !== "") {
2153
+ sent = true;
2154
+ controller.enqueue(new TextEncoder().encode(fake.body));
2155
+ return;
2156
+ }
2157
+ state.settled = true;
2158
+ events.push(`consumed #${id}`);
2159
+ controller.close();
2160
+ },
2161
+ cancel() {
2162
+ state.settled = true;
2163
+ events.push(`cancel #${id}`);
2164
+ if (fake.cancelFails) throw new Error("Cancellation failed");
2165
+ if (fake.cancelNeverSettles) return new Promise(() => {});
2166
+ }
2167
+ }, { highWaterMark: 0 });
2168
+ const response = new Response(body, {
2169
+ status: fake.status ?? 200,
2170
+ headers: fake.headers
2171
+ });
2172
+ if (fake.url != null) Object.defineProperty(response, "url", { value: fake.url });
2173
+ return response;
2174
+ },
2175
+ async close() {
2176
+ if (closed) return;
2177
+ closed = true;
2178
+ closes++;
2179
+ for (const state of states) if (!state.settled) unsettled.push(`#${state.id}`);
2180
+ events.push(`close ${url}`);
2181
+ }
2182
+ };
2183
+ },
2184
+ assertInvariants() {
2185
+ deepStrictEqual(closes, opens);
2186
+ deepStrictEqual(unsettled, []);
2187
+ }
2188
+ };
2189
+ }
2190
+ test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
2191
+ const descriptor = {
2192
+ subject: "acct:johndoe@example.com",
2193
+ links: []
2194
+ };
2195
+ const first = "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
2196
+ const second = "https://example.com/.well-known/webfinger2";
2197
+ await t.step("redirect", async () => {
2198
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2199
+ status: 302,
2200
+ headers: { Location: "/.well-known/webfinger2" },
2201
+ body: "redirecting",
2202
+ url: url.href.replace("example.com", "93.184.215.14")
2203
+ } : { body: JSON.stringify(descriptor) });
2204
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
2205
+ deepStrictEqual(recorder.events, [
2206
+ `open ${first}`,
2207
+ `fetch ${first} #1`,
2208
+ "cancel #1",
2209
+ `close ${first}`,
2210
+ `open ${second}`,
2211
+ `fetch ${second} #2`,
2212
+ "consumed #2",
2213
+ `close ${second}`
2214
+ ]);
2215
+ recorder.assertInvariants();
2216
+ });
2217
+ await t.step("non-2xx response", async () => {
2218
+ const recorder = recordHops(() => ({
2219
+ status: 404,
2220
+ body: "not found"
2221
+ }));
2222
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), null);
2223
+ deepStrictEqual(recorder.events, [
2224
+ `open ${first}`,
2225
+ `fetch ${first} #1`,
2226
+ "cancel #1",
2227
+ `close ${first}`
2228
+ ]);
2229
+ recorder.assertInvariants();
2230
+ });
2231
+ await t.step("too many redirections", async () => {
2232
+ const recorder = recordHops(() => ({
2233
+ status: 302,
2234
+ headers: { Location: "/.well-known/webfinger2" },
2235
+ body: "redirecting"
2236
+ }));
2237
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", { maxRedirection: 1 }, { openHop: recorder.openHop }), null);
2238
+ deepStrictEqual(recorder.events, [
2239
+ `open ${first}`,
2240
+ `fetch ${first} #1`,
2241
+ "cancel #1",
2242
+ `close ${first}`,
2243
+ `open ${second}`,
2244
+ `fetch ${second} #2`,
2245
+ "cancel #2",
2246
+ `close ${second}`
2247
+ ]);
2248
+ recorder.assertInvariants();
2249
+ });
2250
+ await t.step("failed redirect body cancellation", async () => {
2251
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2252
+ status: 302,
2253
+ headers: { Location: "/.well-known/webfinger2" },
2254
+ body: "redirecting",
2255
+ cancelFails: true
2256
+ } : { body: JSON.stringify(descriptor) });
2257
+ const records = [];
2258
+ await configure({
2259
+ sinks: { buffer: (record) => records.push(record) },
2260
+ loggers: [{
2261
+ category: "fedify",
2262
+ sinks: ["buffer"],
2263
+ lowestLevel: "debug"
2264
+ }, {
2265
+ category: ["logtape", "meta"],
2266
+ sinks: []
2267
+ }],
2268
+ reset: true
2269
+ });
2270
+ try {
2271
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
2272
+ const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
2273
+ deepStrictEqual(failures.map((r) => [r.level, r.properties.url]), [["debug", first]]);
2274
+ const { error } = failures[0].properties;
2275
+ ok(error instanceof Error);
2276
+ deepStrictEqual(error.message, "Cancellation failed");
2277
+ } finally {
2278
+ await reset();
2279
+ }
2280
+ deepStrictEqual(recorder.events, [
2281
+ `open ${first}`,
2282
+ `fetch ${first} #1`,
2283
+ "cancel #1",
2284
+ `close ${first}`,
2285
+ `open ${second}`,
2286
+ `fetch ${second} #2`,
2287
+ "consumed #2",
2288
+ `close ${second}`
2289
+ ]);
2290
+ recorder.assertInvariants();
2291
+ });
2292
+ await t.step("never-settling body cancellation", async () => {
2293
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2294
+ status: 302,
2295
+ headers: { Location: "/.well-known/webfinger2" },
2296
+ body: "redirecting",
2297
+ cancelNeverSettles: true
2298
+ } : {
2299
+ status: 404,
2300
+ body: "not found",
2301
+ cancelNeverSettles: true
2302
+ });
2303
+ deepStrictEqual(await settleWithin(lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop })), null);
2304
+ deepStrictEqual(recorder.events, [
2305
+ `open ${first}`,
2306
+ `fetch ${first} #1`,
2307
+ "cancel #1",
2308
+ `close ${first}`,
2309
+ `open ${second}`,
2310
+ `fetch ${second} #2`,
2311
+ "cancel #2",
2312
+ `close ${second}`
2313
+ ]);
2314
+ recorder.assertInvariants();
2315
+ });
2316
+ });
2317
+ /**
2318
+ * Rejects if the given promise does not settle within the timeout, so that
2319
+ * a hang fails the test instead of blocking it.
2320
+ */
2321
+ async function settleWithin(promise, ms = 5e3) {
2322
+ let timer;
2323
+ try {
2324
+ return await Promise.race([promise, new Promise((_, reject) => {
2325
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Did not settle within ${ms} ms.`)), ms);
2326
+ })]);
2327
+ } finally {
2328
+ clearTimeout(timer);
2329
+ }
2330
+ }
2331
+ function neverCancellableResponse(init) {
2332
+ return new Response(new ReadableStream({
2333
+ start(controller) {
2334
+ controller.enqueue(new TextEncoder().encode("never cancelled"));
2335
+ },
2336
+ cancel: () => new Promise(() => {})
2337
+ }), init);
2338
+ }
2339
+ test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
2340
+ const descriptor = {
2341
+ subject: "acct:johndoe@example.com",
2342
+ links: []
2343
+ };
2344
+ esm_default.mockGlobal();
2345
+ try {
2346
+ await t.step("redirect", async () => {
2347
+ esm_default.removeRoutes();
2348
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({
2349
+ status: 302,
2350
+ headers: { Location: "/.well-known/webfinger2" }
2351
+ }));
2352
+ esm_default.get("https://example.com/.well-known/webfinger2", () => new Response(JSON.stringify(descriptor), { headers: { "Content-Type": "application/jrd+json" } }));
2353
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), descriptor);
2354
+ });
2355
+ await t.step("non-2xx response", async () => {
2356
+ esm_default.removeRoutes();
2357
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({ status: 404 }));
2358
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), null);
2359
+ });
2360
+ } finally {
2361
+ esm_default.hardReset();
2362
+ }
2363
+ });
2364
+ test$1("lookupWebFinger() records network_error for rejected hop addresses", async () => {
2365
+ for (const lookup of [
2366
+ () => Promise.reject(/* @__PURE__ */ new Error("DNS unavailable")),
2367
+ () => Promise.resolve([]),
2368
+ () => Promise.resolve([{
2369
+ address: "127.0.0.1",
2370
+ family: 4
2371
+ }])
2372
+ ]) {
2373
+ const [meterProvider, recorder] = createTestMeterProvider();
2374
+ deepStrictEqual(await lookupWebFingerWithDependencies("acct:alice@dns-failure.invalid", { meterProvider }, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
2375
+ const counter = recorder.getMeasurement("webfinger.lookup");
2376
+ ok(counter != null);
2377
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
2378
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "dns-failure.invalid");
2379
+ deepStrictEqual("http.response.status_code" in counter.attributes, false);
2380
+ }
2381
+ });
2041
2382
  //#endregion
2042
2383
  export {};