@fedify/webfinger 2.5.0-dev.2448 → 2.5.0-dev.2492

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.5.0-dev.2448+756933d0";
1239
+ var version = "2.5.0-dev.2492+4648c04e";
1239
1240
  //#endregion
1240
1241
  //#region src/body.ts
1241
1242
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1311,13 +1312,230 @@ 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
+ const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1491
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { outcome: {
1492
+ resource: null,
1493
+ result: "invalid",
1494
+ statusCode: response.status,
1495
+ remoteHost: url.host
1496
+ } };
1497
+ return { outcome: {
1498
+ resource: parsed,
1499
+ result: "found",
1500
+ statusCode: response.status,
1501
+ remoteHost: url.host
1502
+ } };
1503
+ } catch (e) {
1504
+ if (e instanceof BodyTooLargeError) return { outcome: {
1505
+ resource: null,
1506
+ result: "invalid",
1507
+ statusCode: response.status,
1508
+ remoteHost: url.host
1509
+ } };
1510
+ if (e instanceof SyntaxError) {
1511
+ logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1512
+ return { outcome: {
1513
+ resource: null,
1514
+ result: "invalid",
1515
+ statusCode: response.status,
1516
+ remoteHost: url.host
1517
+ } };
1518
+ }
1519
+ throw e;
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Starts cancelling a response body that is not read, without waiting for it
1524
+ * to settle. The cancellation of a branch of a tee (e.g., a body cloned by
1525
+ * a fetch mock) settles only after the other branch is cancelled too, per
1526
+ * the Streams standard, so awaiting it could block forever. A failure is
1527
+ * only logged.
1528
+ */
1529
+ function cancelBody(response, url) {
1530
+ response.body?.cancel().catch((error) => {
1531
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
1532
+ url: url.href,
1533
+ error
1534
+ });
1535
+ });
1536
+ }
1537
+ //#endregion
1538
+ //#region src/lookup.ts
1321
1539
  const WEBFINGER_HISTOGRAM_BUCKETS = [
1322
1540
  5,
1323
1541
  10,
@@ -1371,6 +1589,9 @@ function getMetricResourceScheme(scheme) {
1371
1589
  return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
1372
1590
  }
1373
1591
  async function lookupWebFinger(resource, options = {}) {
1592
+ return await lookupWebFingerWithDependencies(resource, options);
1593
+ }
1594
+ async function lookupWebFingerWithDependencies(resource, options = {}, deps = {}) {
1374
1595
  const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
1375
1596
  const scheme = getResourceScheme(resource);
1376
1597
  return await tracer.startActiveSpan("webfinger.lookup", {
@@ -1387,7 +1608,7 @@ async function lookupWebFinger(resource, options = {}) {
1387
1608
  result: "error"
1388
1609
  };
1389
1610
  try {
1390
- outcome = await lookupWebFingerInternal(resource, options);
1611
+ outcome = await lookupWebFingerInternal(resource, options, deps);
1391
1612
  span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1392
1613
  return options.withDiagnostics ? outcome : outcome.resource;
1393
1614
  } catch (error) {
@@ -1413,188 +1634,56 @@ function recordWebFingerLookup(meterProvider, durationMs, scheme, outcome) {
1413
1634
  instruments.lookup.add(1, attributes);
1414
1635
  instruments.lookupDuration.record(durationMs, attributes);
1415
1636
  }
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
- const parsed = JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1540
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
1541
- resource: null,
1542
- result: "invalid",
1543
- statusCode: response.status,
1544
- remoteHost
1545
- };
1546
- return {
1547
- resource: parsed,
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
1637
  //#endregion
1573
1638
  //#region src/lookup.test.ts
1639
+ function lookupMock(resource, options = {}) {
1640
+ if (resource.toString().includes("fedify-test.internal")) return lookupWebFingerWithDependencies(resource, options, { openHop: (url) => Promise.resolve({
1641
+ url: new URL(url),
1642
+ fetch: () => Promise.reject(/* @__PURE__ */ new TypeError("Connection refused")),
1643
+ close: () => Promise.resolve()
1644
+ }) });
1645
+ return lookupWebFingerWithDependencies(resource, options, { openHop: options.allowPrivateAddress === true ? openDirectFetch : openExampleDirectly });
1646
+ }
1647
+ function lookupDirect(resource, options) {
1648
+ return lookupWebFingerWithDependencies(resource, options, { openHop: openDirectFetch });
1649
+ }
1650
+ function lookupResolving(addresses) {
1651
+ return () => Promise.resolve(addresses);
1652
+ }
1653
+ function openExampleDirectly(url) {
1654
+ return new URL(url).hostname === "example.com" ? openDirectFetch(url) : openPublicFetch(url, { lookup: lookupResolving([{
1655
+ address: "93.184.215.14",
1656
+ family: 4
1657
+ }]) });
1658
+ }
1659
+ async function lookupResourceWithDependencies(resource, options, deps) {
1660
+ return (await lookupWebFingerInternal(resource, options, deps)).resource;
1661
+ }
1574
1662
  test({
1575
1663
  name: "lookupWebFinger()",
1576
1664
  sanitizeOps: false,
1577
1665
  sanitizeResources: false,
1578
1666
  async fn(t) {
1667
+ const responseTimers = [];
1579
1668
  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);
1669
+ deepStrictEqual(await lookupMock("acct:johndoe"), null);
1670
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe")), null);
1671
+ deepStrictEqual(await lookupMock("acct:johndoe@"), null);
1672
+ deepStrictEqual(await lookupMock(new URL("acct:johndoe@")), null);
1673
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com/exploit"), null);
1674
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com?x=1"), null);
1675
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com#frag"), null);
1587
1676
  });
1588
1677
  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);
1678
+ deepStrictEqual(await lookupMock("acct:johndoe@fedify-test.internal"), null);
1679
+ deepStrictEqual(await lookupMock("https://fedify-test.internal/foo"), null);
1591
1680
  });
1592
1681
  esm_default.spyGlobal();
1593
1682
  try {
1594
1683
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1595
1684
  await t.step("not found", async () => {
1596
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1597
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
1685
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1686
+ deepStrictEqual(await lookupMock("https://example.com/foo"), null);
1598
1687
  });
1599
1688
  const expected = {
1600
1689
  subject: "acct:johndoe@example.com",
@@ -1603,7 +1692,7 @@ test({
1603
1692
  esm_default.removeRoutes();
1604
1693
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1605
1694
  await t.step("acct", async () => {
1606
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1695
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1607
1696
  });
1608
1697
  const expected2 = {
1609
1698
  subject: "https://example.com/foo",
@@ -1612,7 +1701,7 @@ test({
1612
1701
  esm_default.removeRoutes();
1613
1702
  esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
1614
1703
  await t.step("https", async () => {
1615
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), expected2);
1704
+ deepStrictEqual(await lookupMock("https://example.com/foo"), expected2);
1616
1705
  });
1617
1706
  const mailtoExpected = {
1618
1707
  subject: "mailto:juliet@example.com",
@@ -1621,7 +1710,7 @@ test({
1621
1710
  esm_default.removeRoutes();
1622
1711
  esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com", { body: mailtoExpected });
1623
1712
  await t.step("mailto", async () => {
1624
- deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com"), mailtoExpected);
1713
+ deepStrictEqual(await lookupMock("mailto:juliet@example.com"), mailtoExpected);
1625
1714
  });
1626
1715
  const mailtoQueryExpected = {
1627
1716
  subject: "mailto:juliet@example.com?subject=Hi",
@@ -1630,12 +1719,12 @@ test({
1630
1719
  esm_default.removeRoutes();
1631
1720
  esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com%3Fsubject%3DHi", { body: mailtoQueryExpected });
1632
1721
  await t.step("mailto with hfields", async () => {
1633
- deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com?subject=Hi"), mailtoQueryExpected);
1722
+ deepStrictEqual(await lookupMock("mailto:juliet@example.com?subject=Hi"), mailtoQueryExpected);
1634
1723
  });
1635
1724
  esm_default.removeRoutes();
1636
1725
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1637
1726
  await t.step("invalid response", async () => {
1638
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1727
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1639
1728
  });
1640
1729
  esm_default.removeRoutes();
1641
1730
  esm_default.get("begin:https://localhost/.well-known/webfinger?", {
@@ -1647,8 +1736,8 @@ test({
1647
1736
  }]
1648
1737
  });
1649
1738
  await t.step("private address", async () => {
1650
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
1651
- deepStrictEqual(await lookupWebFinger("acct:test@localhost", { allowPrivateAddress: true }), {
1739
+ deepStrictEqual(await lookupMock("acct:test@localhost"), null);
1740
+ deepStrictEqual(await lookupMock("acct:test@localhost", { allowPrivateAddress: true }), {
1652
1741
  subject: "acct:test@localhost",
1653
1742
  links: [{
1654
1743
  rel: "self",
@@ -1664,7 +1753,7 @@ test({
1664
1753
  });
1665
1754
  esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
1666
1755
  await t.step("redirection", async () => {
1667
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1756
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1668
1757
  });
1669
1758
  esm_default.removeRoutes();
1670
1759
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1672,7 +1761,7 @@ test({
1672
1761
  headers: { Location: "/.well-known/webfinger" }
1673
1762
  });
1674
1763
  await t.step("infinite redirection", async () => {
1675
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com"), 2e3), null);
1764
+ deepStrictEqual(await withTimeout(() => lookupMock("acct:johndoe@example.com"), 2e3), null);
1676
1765
  });
1677
1766
  esm_default.removeRoutes();
1678
1767
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1680,7 +1769,7 @@ test({
1680
1769
  headers: { Location: "ftp://example.com/" }
1681
1770
  });
1682
1771
  await t.step("redirection to different protocol", async () => {
1683
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1772
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1684
1773
  });
1685
1774
  esm_default.removeRoutes();
1686
1775
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1688,7 +1777,7 @@ test({
1688
1777
  headers: { Location: "https://localhost/" }
1689
1778
  });
1690
1779
  await t.step("redirection to private address", async () => {
1691
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1780
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), null);
1692
1781
  });
1693
1782
  esm_default.removeRoutes();
1694
1783
  let redirectCount = 0;
@@ -1702,13 +1791,13 @@ test({
1702
1791
  });
1703
1792
  await t.step("custom maxRedirection", async () => {
1704
1793
  redirectCount = 0;
1705
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1794
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1706
1795
  redirectCount = 0;
1707
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 2 }), expected);
1796
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 2 }), expected);
1708
1797
  redirectCount = 0;
1709
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1798
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1710
1799
  redirectCount = 0;
1711
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1800
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com"), expected);
1712
1801
  });
1713
1802
  await t.step("maxRedirection: 1 follows exactly one redirect", async () => {
1714
1803
  esm_default.removeRoutes();
@@ -1720,7 +1809,7 @@ test({
1720
1809
  headers: { Location: "/.well-known/webfinger?after=1" }
1721
1810
  } : { body: expected };
1722
1811
  });
1723
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), expected);
1812
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), expected);
1724
1813
  esm_default.removeRoutes();
1725
1814
  count = 0;
1726
1815
  esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
@@ -1730,18 +1819,18 @@ test({
1730
1819
  headers: { Location: `/.well-known/webfinger?after=${count}` }
1731
1820
  } : { body: expected };
1732
1821
  });
1733
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1822
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1734
1823
  });
1735
1824
  esm_default.removeRoutes();
1736
1825
  esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1737
1826
  const timeoutId = setTimeout(() => {
1738
1827
  resolve({ body: expected });
1739
1828
  }, 1e3);
1740
- return () => clearTimeout(timeoutId);
1829
+ responseTimers.push(timeoutId);
1741
1830
  }));
1742
1831
  await t.step("request cancellation", async () => {
1743
1832
  const controller = new AbortController();
1744
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1833
+ const promise = lookupMock("acct:johndoe@example.com", { signal: controller.signal });
1745
1834
  controller.abort();
1746
1835
  deepStrictEqual(await promise, null);
1747
1836
  });
@@ -1757,12 +1846,12 @@ test({
1757
1846
  const timeoutId = setTimeout(() => {
1758
1847
  resolve({ body: expected });
1759
1848
  }, 1e3);
1760
- return () => clearTimeout(timeoutId);
1849
+ responseTimers.push(timeoutId);
1761
1850
  });
1762
1851
  });
1763
1852
  await t.step("cancellation during redirection", async () => {
1764
1853
  const controller = new AbortController();
1765
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1854
+ const promise = lookupMock("acct:johndoe@example.com", { signal: controller.signal });
1766
1855
  setTimeout(() => controller.abort(), 100);
1767
1856
  deepStrictEqual(await promise, null);
1768
1857
  });
@@ -1771,19 +1860,20 @@ test({
1771
1860
  const timeoutId = setTimeout(() => {
1772
1861
  resolve({ body: expected });
1773
1862
  }, 500);
1774
- return () => clearTimeout(timeoutId);
1863
+ responseTimers.push(timeoutId);
1775
1864
  }));
1776
1865
  await t.step("cancellation with immediate abort", async () => {
1777
1866
  const controller = new AbortController();
1778
1867
  controller.abort();
1779
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
1868
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { signal: controller.signal }), null);
1780
1869
  });
1781
1870
  esm_default.removeRoutes();
1782
1871
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
1783
1872
  await t.step("successful request with signal", async () => {
1784
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1873
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1785
1874
  });
1786
1875
  } finally {
1876
+ for (const timer of responseTimers) clearTimeout(timer);
1787
1877
  esm_default.removeRoutes();
1788
1878
  esm_default.hardReset();
1789
1879
  }
@@ -1803,7 +1893,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1803
1893
  esm_default.removeRoutes();
1804
1894
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1805
1895
  const [meterProvider, recorder] = createTestMeterProvider();
1806
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), expected);
1896
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { meterProvider }), expected);
1807
1897
  const counters = recorder.getMeasurements("webfinger.lookup");
1808
1898
  deepStrictEqual(counters.length, 1);
1809
1899
  deepStrictEqual(counters[0].type, "counter");
@@ -1826,7 +1916,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1826
1916
  links: []
1827
1917
  } });
1828
1918
  const [meterProvider, recorder] = createTestMeterProvider();
1829
- await lookupWebFinger("https://example.com/foo", { meterProvider });
1919
+ await lookupMock("https://example.com/foo", { meterProvider });
1830
1920
  const counters = recorder.getMeasurements("webfinger.lookup");
1831
1921
  deepStrictEqual(counters.length, 1);
1832
1922
  deepStrictEqual(counters[0].attributes["webfinger.resource.scheme"], "https");
@@ -1839,7 +1929,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1839
1929
  links: []
1840
1930
  } });
1841
1931
  const [meterProvider, recorder] = createTestMeterProvider();
1842
- await lookupWebFinger("https://example.com:8443/foo", { meterProvider });
1932
+ await lookupMock("https://example.com:8443/foo", { meterProvider });
1843
1933
  const counter = recorder.getMeasurement("webfinger.lookup");
1844
1934
  ok(counter != null);
1845
1935
  deepStrictEqual(counter.attributes["activitypub.remote.host"], "example.com:8443");
@@ -1848,7 +1938,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1848
1938
  esm_default.removeRoutes();
1849
1939
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1850
1940
  const [meterProvider, recorder] = createTestMeterProvider();
1851
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), null);
1941
+ deepStrictEqual(await lookupMock("acct:johndoe@example.com", { meterProvider }), null);
1852
1942
  const counters = recorder.getMeasurements("webfinger.lookup");
1853
1943
  deepStrictEqual(counters.length, 1);
1854
1944
  deepStrictEqual(counters[0].attributes["webfinger.lookup.result"], "not_found");
@@ -1862,7 +1952,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1862
1952
  esm_default.removeRoutes();
1863
1953
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 410 });
1864
1954
  const [meterProvider, recorder] = createTestMeterProvider();
1865
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1955
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1866
1956
  const counter = recorder.getMeasurement("webfinger.lookup");
1867
1957
  ok(counter != null);
1868
1958
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "not_found");
@@ -1872,7 +1962,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1872
1962
  esm_default.removeRoutes();
1873
1963
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
1874
1964
  const [meterProvider, recorder] = createTestMeterProvider();
1875
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1965
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1876
1966
  const counter = recorder.getMeasurement("webfinger.lookup");
1877
1967
  ok(counter != null);
1878
1968
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "error");
@@ -1882,7 +1972,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1882
1972
  esm_default.removeRoutes();
1883
1973
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1884
1974
  const [meterProvider, recorder] = createTestMeterProvider();
1885
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1975
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1886
1976
  const counter = recorder.getMeasurement("webfinger.lookup");
1887
1977
  ok(counter != null);
1888
1978
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1891,7 +1981,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1891
1981
  await t.step("records result=network_error when fetch never reaches the remote", async () => {
1892
1982
  esm_default.removeRoutes();
1893
1983
  const [meterProvider, recorder] = createTestMeterProvider();
1894
- deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal", { meterProvider }), null);
1984
+ deepStrictEqual(await lookupMock("acct:johndoe@fedify-test.internal", { meterProvider }), null);
1895
1985
  const counter = recorder.getMeasurement("webfinger.lookup");
1896
1986
  ok(counter != null);
1897
1987
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
@@ -1900,7 +1990,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1900
1990
  });
1901
1991
  await t.step("records result=invalid for malformed acct: resources", async () => {
1902
1992
  const [meterProvider, recorder] = createTestMeterProvider();
1903
- deepStrictEqual(await lookupWebFinger("acct:johndoe", { meterProvider }), null);
1993
+ deepStrictEqual(await lookupMock("acct:johndoe", { meterProvider }), null);
1904
1994
  const counter = recorder.getMeasurement("webfinger.lookup");
1905
1995
  ok(counter != null);
1906
1996
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1914,7 +2004,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1914
2004
  headers: { Location: "/.well-known/webfinger" }
1915
2005
  });
1916
2006
  const [meterProvider, recorder] = createTestMeterProvider();
1917
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com", {
2007
+ deepStrictEqual(await withTimeout(() => lookupMock("acct:johndoe@example.com", {
1918
2008
  meterProvider,
1919
2009
  maxRedirection: 3
1920
2010
  }), 2e3), null);
@@ -1931,7 +2021,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1931
2021
  headers: { Location: "ftp://example.com/" }
1932
2022
  });
1933
2023
  const [meterProvider, recorder] = createTestMeterProvider();
1934
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2024
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1935
2025
  const counter = recorder.getMeasurement("webfinger.lookup");
1936
2026
  ok(counter != null);
1937
2027
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1944,7 +2034,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1944
2034
  headers: { Location: "https://localhost/" }
1945
2035
  });
1946
2036
  const [meterProvider, recorder] = createTestMeterProvider();
1947
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2037
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1948
2038
  const counter = recorder.getMeasurement("webfinger.lookup");
1949
2039
  ok(counter != null);
1950
2040
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
@@ -1957,7 +2047,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1957
2047
  headers: { Location: "http://[bad" }
1958
2048
  });
1959
2049
  const [meterProvider, recorder] = createTestMeterProvider();
1960
- await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
2050
+ await lookupMock("acct:johndoe@example.com", { meterProvider });
1961
2051
  const counter = recorder.getMeasurement("webfinger.lookup");
1962
2052
  ok(counter != null);
1963
2053
  deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
@@ -1967,7 +2057,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1967
2057
  await t.step("buckets unknown resource schemes as 'other' to keep metric cardinality bounded", async () => {
1968
2058
  esm_default.removeRoutes();
1969
2059
  const [meterProvider, recorder] = createTestMeterProvider();
1970
- await lookupWebFinger("ssh://example.com/foo", { meterProvider });
2060
+ await lookupMock("ssh://example.com/foo", { meterProvider });
1971
2061
  const counter = recorder.getMeasurement("webfinger.lookup");
1972
2062
  ok(counter != null);
1973
2063
  deepStrictEqual(counter.attributes["webfinger.resource.scheme"], "other");
@@ -1976,7 +2066,7 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1976
2066
  esm_default.removeRoutes();
1977
2067
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1978
2068
  const [_unused, recorder] = createTestMeterProvider();
1979
- await lookupWebFinger("acct:johndoe@example.com");
2069
+ await lookupMock("acct:johndoe@example.com");
1980
2070
  deepStrictEqual(recorder.getMeasurements("webfinger.lookup").length, 0);
1981
2071
  deepStrictEqual(recorder.getMeasurements("webfinger.lookup.duration").length, 0);
1982
2072
  });
@@ -1993,25 +2083,23 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
1993
2083
  "Content-Type": "application/jrd+json",
1994
2084
  "Content-Length": "1"
1995
2085
  } }));
1996
- deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
2086
+ deepStrictEqual(await lookupMock("acct:alice@example.com", { allowPrivateAddress: true }), null);
1997
2087
  oversized = false;
1998
- deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
2088
+ deepStrictEqual(await lookupMock("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1999
2089
  } finally {
2000
2090
  esm_default.hardReset();
2001
2091
  }
2002
2092
  });
2003
2093
  test("lookupWebFinger() logs DNS failures as such", async (t) => {
2004
- if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
2005
2094
  for (const result of [
2006
2095
  "throws",
2007
2096
  "empty",
2008
2097
  "private"
2009
2098
  ]) 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" ? [] : [{
2099
+ const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
2012
2100
  address: "127.0.0.1",
2013
2101
  family: 4
2014
- }]));
2102
+ }]);
2015
2103
  const records = [];
2016
2104
  await configure({
2017
2105
  sinks: { buffer: (record) => records.push(record) },
@@ -2026,7 +2114,7 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
2026
2114
  reset: true
2027
2115
  });
2028
2116
  try {
2029
- deepStrictEqual(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
2117
+ deepStrictEqual(await lookupResourceWithDependencies("acct:alice@dns-failure.invalid", {}, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
2030
2118
  const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
2031
2119
  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
2120
  const { error } = failures[0].properties;
@@ -2034,7 +2122,6 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
2034
2122
  deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
2035
2123
  } finally {
2036
2124
  await reset();
2037
- dns.lookup = originalLookup;
2038
2125
  }
2039
2126
  });
2040
2127
  });
@@ -2043,7 +2130,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2043
2130
  sanitizeResources: false
2044
2131
  }, async (t) => {
2045
2132
  await t.step("network_error", async () => {
2046
- const outcome = await lookupWebFinger("acct:johndoe@fedify-test.internal", { withDiagnostics: true });
2133
+ const outcome = await lookupMock("acct:johndoe@fedify-test.internal", { withDiagnostics: true });
2047
2134
  deepStrictEqual(outcome.resource, null);
2048
2135
  deepStrictEqual(outcome.result, "network_error");
2049
2136
  deepStrictEqual("statusCode" in outcome, false, "no HTTP response means no status code");
@@ -2057,7 +2144,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2057
2144
  links: []
2058
2145
  };
2059
2146
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2060
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2147
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2061
2148
  deepStrictEqual(outcome.resource, expected);
2062
2149
  deepStrictEqual(outcome.result, "found");
2063
2150
  deepStrictEqual(outcome.statusCode, 200);
@@ -2066,7 +2153,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2066
2153
  await t.step("not_found", async () => {
2067
2154
  esm_default.removeRoutes();
2068
2155
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
2069
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2156
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2070
2157
  deepStrictEqual(outcome.resource, null);
2071
2158
  deepStrictEqual(outcome.result, "not_found");
2072
2159
  deepStrictEqual(outcome.statusCode, 404);
@@ -2075,7 +2162,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2075
2162
  await t.step("invalid", async () => {
2076
2163
  esm_default.removeRoutes();
2077
2164
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
2078
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2165
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2079
2166
  deepStrictEqual(outcome.resource, null);
2080
2167
  deepStrictEqual(outcome.result, "invalid");
2081
2168
  deepStrictEqual(outcome.statusCode, 200);
@@ -2084,7 +2171,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2084
2171
  await t.step("invalid: a literal JSON null body is not a resource descriptor", async () => {
2085
2172
  esm_default.removeRoutes();
2086
2173
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "null" });
2087
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2174
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2088
2175
  deepStrictEqual(outcome.resource, null);
2089
2176
  deepStrictEqual(outcome.result, "invalid");
2090
2177
  deepStrictEqual(outcome.statusCode, 200);
@@ -2093,7 +2180,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2093
2180
  await t.step("invalid: a JSON array body is not a resource descriptor", async () => {
2094
2181
  esm_default.removeRoutes();
2095
2182
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "[]" });
2096
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2183
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2097
2184
  deepStrictEqual(outcome.resource, null);
2098
2185
  deepStrictEqual(outcome.result, "invalid");
2099
2186
  deepStrictEqual(outcome.statusCode, 200);
@@ -2102,7 +2189,7 @@ test("lookupWebFinger() with withDiagnostics: true", {
2102
2189
  await t.step("error", async () => {
2103
2190
  esm_default.removeRoutes();
2104
2191
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
2105
- const outcome = await lookupWebFinger("acct:johndoe@example.com", { withDiagnostics: true });
2192
+ const outcome = await lookupMock("acct:johndoe@example.com", { withDiagnostics: true });
2106
2193
  deepStrictEqual(outcome.resource, null);
2107
2194
  deepStrictEqual(outcome.result, "error");
2108
2195
  deepStrictEqual(outcome.statusCode, 500);
@@ -2121,11 +2208,297 @@ test("lookupWebFinger() accepts a plain LookupWebFingerOptions-typed variable",
2121
2208
  links: []
2122
2209
  };
2123
2210
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
2124
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { userAgent: "test-agent" }), expected);
2211
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", {
2212
+ userAgent: "test-agent",
2213
+ allowPrivateAddress: true
2214
+ }), expected);
2125
2215
  } finally {
2126
2216
  esm_default.removeRoutes();
2127
2217
  esm_default.hardReset();
2128
2218
  }
2129
2219
  });
2220
+ function recordHops(respond) {
2221
+ const events = [];
2222
+ const unsettled = [];
2223
+ let opens = 0;
2224
+ let closes = 0;
2225
+ let responses = 0;
2226
+ return {
2227
+ events,
2228
+ async openHop(url) {
2229
+ opens++;
2230
+ events.push(`open ${url}`);
2231
+ const states = [];
2232
+ let closed = false;
2233
+ return {
2234
+ url: new URL(url),
2235
+ async fetch(input) {
2236
+ if (closed) throw new TypeError("The fetch session is closed.");
2237
+ const target = new URL(input instanceof Request ? input.url : input);
2238
+ const id = ++responses;
2239
+ events.push(`fetch ${target.href} #${id}`);
2240
+ const fake = respond(target);
2241
+ const state = {
2242
+ id,
2243
+ settled: false
2244
+ };
2245
+ states.push(state);
2246
+ let sent = false;
2247
+ const body = new ReadableStream({
2248
+ pull(controller) {
2249
+ if (!sent && fake.body != null && fake.body !== "") {
2250
+ sent = true;
2251
+ controller.enqueue(new TextEncoder().encode(fake.body));
2252
+ return;
2253
+ }
2254
+ state.settled = true;
2255
+ events.push(`consumed #${id}`);
2256
+ controller.close();
2257
+ },
2258
+ cancel() {
2259
+ state.settled = true;
2260
+ events.push(`cancel #${id}`);
2261
+ if (fake.cancelFails) throw new Error("Cancellation failed");
2262
+ if (fake.cancelNeverSettles) return new Promise(() => {});
2263
+ }
2264
+ }, { highWaterMark: 0 });
2265
+ const response = new Response(body, {
2266
+ status: fake.status ?? 200,
2267
+ headers: fake.headers
2268
+ });
2269
+ if (fake.url != null) Object.defineProperty(response, "url", { value: fake.url });
2270
+ return response;
2271
+ },
2272
+ async close() {
2273
+ if (closed) return;
2274
+ closed = true;
2275
+ closes++;
2276
+ for (const state of states) if (!state.settled) unsettled.push(`#${state.id}`);
2277
+ events.push(`close ${url}`);
2278
+ }
2279
+ };
2280
+ },
2281
+ assertInvariants() {
2282
+ deepStrictEqual(closes, opens);
2283
+ deepStrictEqual(unsettled, []);
2284
+ }
2285
+ };
2286
+ }
2287
+ test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
2288
+ const descriptor = {
2289
+ subject: "acct:johndoe@example.com",
2290
+ links: []
2291
+ };
2292
+ const first = "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
2293
+ const second = "https://example.com/.well-known/webfinger2";
2294
+ await t.step("redirect", async () => {
2295
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2296
+ status: 302,
2297
+ headers: { Location: "/.well-known/webfinger2" },
2298
+ body: "redirecting",
2299
+ url: url.href.replace("example.com", "93.184.215.14")
2300
+ } : { body: JSON.stringify(descriptor) });
2301
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
2302
+ deepStrictEqual(recorder.events, [
2303
+ `open ${first}`,
2304
+ `fetch ${first} #1`,
2305
+ "cancel #1",
2306
+ `close ${first}`,
2307
+ `open ${second}`,
2308
+ `fetch ${second} #2`,
2309
+ "consumed #2",
2310
+ `close ${second}`
2311
+ ]);
2312
+ recorder.assertInvariants();
2313
+ });
2314
+ await t.step("non-2xx response", async () => {
2315
+ const recorder = recordHops(() => ({
2316
+ status: 404,
2317
+ body: "not found"
2318
+ }));
2319
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), null);
2320
+ deepStrictEqual(recorder.events, [
2321
+ `open ${first}`,
2322
+ `fetch ${first} #1`,
2323
+ "cancel #1",
2324
+ `close ${first}`
2325
+ ]);
2326
+ recorder.assertInvariants();
2327
+ });
2328
+ await t.step("too many redirections", async () => {
2329
+ const recorder = recordHops(() => ({
2330
+ status: 302,
2331
+ headers: { Location: "/.well-known/webfinger2" },
2332
+ body: "redirecting"
2333
+ }));
2334
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", { maxRedirection: 1 }, { openHop: recorder.openHop }), null);
2335
+ deepStrictEqual(recorder.events, [
2336
+ `open ${first}`,
2337
+ `fetch ${first} #1`,
2338
+ "cancel #1",
2339
+ `close ${first}`,
2340
+ `open ${second}`,
2341
+ `fetch ${second} #2`,
2342
+ "cancel #2",
2343
+ `close ${second}`
2344
+ ]);
2345
+ recorder.assertInvariants();
2346
+ });
2347
+ await t.step("failed redirect body cancellation", async () => {
2348
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2349
+ status: 302,
2350
+ headers: { Location: "/.well-known/webfinger2" },
2351
+ body: "redirecting",
2352
+ cancelFails: true
2353
+ } : { body: JSON.stringify(descriptor) });
2354
+ const records = [];
2355
+ await configure({
2356
+ sinks: { buffer: (record) => records.push(record) },
2357
+ loggers: [{
2358
+ category: "fedify",
2359
+ sinks: ["buffer"],
2360
+ lowestLevel: "debug"
2361
+ }, {
2362
+ category: ["logtape", "meta"],
2363
+ sinks: []
2364
+ }],
2365
+ reset: true
2366
+ });
2367
+ try {
2368
+ deepStrictEqual(await lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
2369
+ const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
2370
+ deepStrictEqual(failures.map((r) => [r.level, r.properties.url]), [["debug", first]]);
2371
+ const { error } = failures[0].properties;
2372
+ ok(error instanceof Error);
2373
+ deepStrictEqual(error.message, "Cancellation failed");
2374
+ } finally {
2375
+ await reset();
2376
+ }
2377
+ deepStrictEqual(recorder.events, [
2378
+ `open ${first}`,
2379
+ `fetch ${first} #1`,
2380
+ "cancel #1",
2381
+ `close ${first}`,
2382
+ `open ${second}`,
2383
+ `fetch ${second} #2`,
2384
+ "consumed #2",
2385
+ `close ${second}`
2386
+ ]);
2387
+ recorder.assertInvariants();
2388
+ });
2389
+ await t.step("never-settling body cancellation", async () => {
2390
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
2391
+ status: 302,
2392
+ headers: { Location: "/.well-known/webfinger2" },
2393
+ body: "redirecting",
2394
+ cancelNeverSettles: true
2395
+ } : {
2396
+ status: 404,
2397
+ body: "not found",
2398
+ cancelNeverSettles: true
2399
+ });
2400
+ deepStrictEqual(await settleWithin(lookupResourceWithDependencies("acct:johndoe@example.com", {}, { openHop: recorder.openHop })), null);
2401
+ deepStrictEqual(recorder.events, [
2402
+ `open ${first}`,
2403
+ `fetch ${first} #1`,
2404
+ "cancel #1",
2405
+ `close ${first}`,
2406
+ `open ${second}`,
2407
+ `fetch ${second} #2`,
2408
+ "cancel #2",
2409
+ `close ${second}`
2410
+ ]);
2411
+ recorder.assertInvariants();
2412
+ });
2413
+ });
2414
+ /**
2415
+ * Rejects if the given promise does not settle within the timeout, so that
2416
+ * a hang fails the test instead of blocking it.
2417
+ */
2418
+ async function settleWithin(promise, ms = 5e3) {
2419
+ let timer;
2420
+ try {
2421
+ return await Promise.race([promise, new Promise((_, reject) => {
2422
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Did not settle within ${ms} ms.`)), ms);
2423
+ })]);
2424
+ } finally {
2425
+ clearTimeout(timer);
2426
+ }
2427
+ }
2428
+ function neverCancellableResponse(init) {
2429
+ return new Response(new ReadableStream({
2430
+ start(controller) {
2431
+ controller.enqueue(new TextEncoder().encode("never cancelled"));
2432
+ },
2433
+ cancel: () => new Promise(() => {})
2434
+ }), init);
2435
+ }
2436
+ test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
2437
+ const descriptor = {
2438
+ subject: "acct:johndoe@example.com",
2439
+ links: []
2440
+ };
2441
+ esm_default.mockGlobal();
2442
+ try {
2443
+ await t.step("redirect", async () => {
2444
+ esm_default.removeRoutes();
2445
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({
2446
+ status: 302,
2447
+ headers: { Location: "/.well-known/webfinger2" }
2448
+ }));
2449
+ esm_default.get("https://example.com/.well-known/webfinger2", () => new Response(JSON.stringify(descriptor), { headers: { "Content-Type": "application/jrd+json" } }));
2450
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), descriptor);
2451
+ });
2452
+ await t.step("non-2xx response", async () => {
2453
+ esm_default.removeRoutes();
2454
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({ status: 404 }));
2455
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), null);
2456
+ });
2457
+ } finally {
2458
+ esm_default.hardReset();
2459
+ }
2460
+ });
2461
+ test$1("lookupWebFinger() records network_error for rejected hop addresses", async () => {
2462
+ for (const lookup of [
2463
+ () => Promise.reject(/* @__PURE__ */ new Error("DNS unavailable")),
2464
+ () => Promise.resolve([]),
2465
+ () => Promise.resolve([{
2466
+ address: "127.0.0.1",
2467
+ family: 4
2468
+ }])
2469
+ ]) {
2470
+ const [meterProvider, recorder] = createTestMeterProvider();
2471
+ deepStrictEqual(await lookupWebFingerWithDependencies("acct:alice@dns-failure.invalid", { meterProvider }, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
2472
+ const counter = recorder.getMeasurement("webfinger.lookup");
2473
+ ok(counter != null);
2474
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
2475
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "dns-failure.invalid");
2476
+ deepStrictEqual("http.response.status_code" in counter.attributes, false);
2477
+ }
2478
+ });
2479
+ test$1("WebFinger diagnostics classify non-object JSON after closing the hop", async () => {
2480
+ for (const body of [
2481
+ "null",
2482
+ "[]",
2483
+ "42",
2484
+ "\"text\"",
2485
+ "true"
2486
+ ]) {
2487
+ const hops = recordHops(() => ({ body }));
2488
+ const [meterProvider, recorder] = createTestMeterProvider();
2489
+ deepStrictEqual(await lookupWebFingerWithDependencies("acct:alice@example.com", {
2490
+ withDiagnostics: true,
2491
+ meterProvider
2492
+ }, { openHop: hops.openHop }), {
2493
+ resource: null,
2494
+ result: "invalid",
2495
+ statusCode: 200,
2496
+ remoteHost: "example.com"
2497
+ });
2498
+ deepStrictEqual(hops.events.at(-1), "close https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com");
2499
+ hops.assertInvariants();
2500
+ deepStrictEqual(recorder.getMeasurement("webfinger.lookup")?.attributes["webfinger.lookup.result"], "invalid");
2501
+ }
2502
+ });
2130
2503
  //#endregion
2131
2504
  export {};