@fedify/webfinger 2.3.14 → 2.3.15

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