@fedify/webfinger 2.3.0-dev.994 → 2.3.0-pr.809.37

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,6 +1,6 @@
1
- import { test } from "@fedify/fixture";
1
+ import { createTestMeterProvider, test } from "@fedify/fixture";
2
2
  import { withTimeout } from "es-toolkit";
3
- import { deepStrictEqual } from "node:assert/strict";
3
+ import { deepStrictEqual, ok } from "node:assert/strict";
4
4
  import { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
5
5
  import { getLogger } from "@logtape/logtape";
6
6
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
@@ -11,7 +11,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
11
  var __getOwnPropNames = Object.getOwnPropertyNames;
12
12
  var __getProtoOf = Object.getPrototypeOf;
13
13
  var __hasOwnProp = Object.prototype.hasOwnProperty;
14
- var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
14
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
15
15
  var __copyProps = (to, from, except, desc) => {
16
16
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
17
17
  key = keys[i];
@@ -1227,7 +1227,7 @@ var esm_default = new class FetchMock {
1227
1227
  //#endregion
1228
1228
  //#region deno.json
1229
1229
  var name = "@fedify/webfinger";
1230
- var version = "2.3.0-dev.994+9071ca0a";
1230
+ var version = "2.3.0-pr.809.37+0574ba5c";
1231
1231
  //#endregion
1232
1232
  //#region src/lookup.ts
1233
1233
  const logger = getLogger([
@@ -1236,6 +1236,58 @@ const logger = getLogger([
1236
1236
  "lookup"
1237
1237
  ]);
1238
1238
  const DEFAULT_MAX_REDIRECTION = 5;
1239
+ const WEBFINGER_HISTOGRAM_BUCKETS = [
1240
+ 5,
1241
+ 10,
1242
+ 25,
1243
+ 50,
1244
+ 75,
1245
+ 100,
1246
+ 250,
1247
+ 500,
1248
+ 750,
1249
+ 1e3,
1250
+ 2500,
1251
+ 5e3,
1252
+ 7500,
1253
+ 1e4
1254
+ ];
1255
+ const webFingerInstruments = /* @__PURE__ */ new WeakMap();
1256
+ function getWebFingerInstruments(meterProvider) {
1257
+ let instruments = webFingerInstruments.get(meterProvider);
1258
+ if (instruments == null) {
1259
+ const meter = meterProvider.getMeter(name, version);
1260
+ instruments = {
1261
+ lookup: meter.createCounter("webfinger.lookup", {
1262
+ description: "Outgoing WebFinger lookup attempts.",
1263
+ unit: "{lookup}"
1264
+ }),
1265
+ lookupDuration: meter.createHistogram("webfinger.lookup.duration", {
1266
+ description: "Duration of outgoing WebFinger lookups.",
1267
+ unit: "ms",
1268
+ advice: { explicitBucketBoundaries: [...WEBFINGER_HISTOGRAM_BUCKETS] }
1269
+ })
1270
+ };
1271
+ webFingerInstruments.set(meterProvider, instruments);
1272
+ }
1273
+ return instruments;
1274
+ }
1275
+ function getResourceScheme(resource) {
1276
+ if (typeof resource === "string") {
1277
+ const colon = resource.indexOf(":");
1278
+ return colon > 0 ? resource.substring(0, colon).toLowerCase() : "";
1279
+ }
1280
+ return resource.protocol.replace(/:$/, "").toLowerCase();
1281
+ }
1282
+ const WEBFINGER_LOOKUP_SCHEME_WHITELIST = new Set([
1283
+ "acct",
1284
+ "http",
1285
+ "https",
1286
+ "mailto"
1287
+ ]);
1288
+ function getMetricResourceScheme(scheme) {
1289
+ return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
1290
+ }
1239
1291
  /**
1240
1292
  * Looks up a WebFinger resource.
1241
1293
  * @param resource The resource URL to look up.
@@ -1244,17 +1296,25 @@ const DEFAULT_MAX_REDIRECTION = 5;
1244
1296
  * @since 0.2.0
1245
1297
  */
1246
1298
  async function lookupWebFinger(resource, options = {}) {
1247
- return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("webfinger.lookup", {
1299
+ const tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version);
1300
+ const scheme = getResourceScheme(resource);
1301
+ return await tracer.startActiveSpan("webfinger.lookup", {
1248
1302
  kind: SpanKind.CLIENT,
1249
1303
  attributes: {
1250
1304
  "webfinger.resource": resource.toString(),
1251
- "webfinger.resource.scheme": typeof resource === "string" ? resource.replace(/:.*$/, "") : resource.protocol.replace(/:$/, "")
1305
+ "webfinger.resource.scheme": scheme
1252
1306
  }
1253
1307
  }, async (span) => {
1308
+ const meterProvider = options.meterProvider;
1309
+ const start = meterProvider == null ? 0 : performance.now();
1310
+ let outcome = {
1311
+ resource: null,
1312
+ result: "error"
1313
+ };
1254
1314
  try {
1255
- const result = await lookupWebFingerInternal(resource, options);
1256
- span.setStatus({ code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1257
- return result;
1315
+ outcome = await lookupWebFingerInternal(resource, options);
1316
+ span.setStatus({ code: outcome.resource === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1317
+ return outcome.resource;
1258
1318
  } catch (error) {
1259
1319
  span.setStatus({
1260
1320
  code: SpanStatusCode.ERROR,
@@ -1262,19 +1322,41 @@ async function lookupWebFinger(resource, options = {}) {
1262
1322
  });
1263
1323
  throw error;
1264
1324
  } finally {
1325
+ if (meterProvider != null) recordWebFingerLookup(meterProvider, Math.max(0, performance.now() - start), scheme, outcome);
1265
1326
  span.end();
1266
1327
  }
1267
1328
  });
1268
1329
  }
1330
+ function recordWebFingerLookup(meterProvider, durationMs, scheme, outcome) {
1331
+ const attributes = {
1332
+ "webfinger.lookup.result": outcome.result,
1333
+ "webfinger.resource.scheme": getMetricResourceScheme(scheme)
1334
+ };
1335
+ if (outcome.remoteHost != null) attributes["activitypub.remote.host"] = outcome.remoteHost;
1336
+ if (outcome.statusCode != null) attributes["http.response.status_code"] = outcome.statusCode;
1337
+ const instruments = getWebFingerInstruments(meterProvider);
1338
+ instruments.lookup.add(1, attributes);
1339
+ instruments.lookupDuration.record(durationMs, attributes);
1340
+ }
1269
1341
  async function lookupWebFingerInternal(resource, options = {}) {
1270
1342
  if (typeof resource === "string") resource = new URL(resource);
1271
1343
  let protocol = "https:";
1272
1344
  let server;
1273
- if (resource.protocol === "acct:") {
1345
+ if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
1274
1346
  const atPos = resource.pathname.lastIndexOf("@");
1275
- if (atPos < 0) return null;
1347
+ if (atPos < 0) return {
1348
+ resource: null,
1349
+ result: "invalid"
1350
+ };
1276
1351
  server = resource.pathname.substring(atPos + 1);
1277
- if (server === "") return null;
1352
+ if (server === "" || /[/?#]/.test(server)) return {
1353
+ resource: null,
1354
+ result: "invalid"
1355
+ };
1356
+ if (resource.protocol === "acct:" && (resource.search !== "" || resource.hash !== "")) return {
1357
+ resource: null,
1358
+ result: "invalid"
1359
+ };
1278
1360
  } else {
1279
1361
  protocol = resource.protocol;
1280
1362
  server = resource.host;
@@ -1283,6 +1365,7 @@ async function lookupWebFingerInternal(resource, options = {}) {
1283
1365
  url.searchParams.set("resource", resource.href);
1284
1366
  let redirected = 0;
1285
1367
  while (true) {
1368
+ const remoteHost = url.host;
1286
1369
  logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
1287
1370
  let response;
1288
1371
  if (options.allowPrivateAddress !== true) try {
@@ -1290,7 +1373,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
1290
1373
  } catch (e) {
1291
1374
  if (e instanceof UrlError) {
1292
1375
  logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1293
- return null;
1376
+ return {
1377
+ resource: null,
1378
+ result: "network_error",
1379
+ remoteHost
1380
+ };
1294
1381
  }
1295
1382
  throw e;
1296
1383
  }
@@ -1308,22 +1395,50 @@ async function lookupWebFingerInternal(resource, options = {}) {
1308
1395
  url: url.href,
1309
1396
  error
1310
1397
  });
1311
- return null;
1398
+ return {
1399
+ resource: null,
1400
+ result: "network_error",
1401
+ remoteHost
1402
+ };
1312
1403
  }
1313
1404
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
1314
1405
  redirected++;
1315
1406
  const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
1316
- if (redirected >= maxRedirection) {
1407
+ if (redirected > maxRedirection) {
1317
1408
  logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
1318
- return null;
1409
+ return {
1410
+ resource: null,
1411
+ result: "invalid",
1412
+ statusCode: response.status,
1413
+ remoteHost
1414
+ };
1415
+ }
1416
+ let redirectedUrl;
1417
+ try {
1418
+ redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
1419
+ } catch (e) {
1420
+ logger.error("Invalid Location header while following WebFinger redirect: {error}", {
1421
+ url: url.href,
1422
+ error: e
1423
+ });
1424
+ return {
1425
+ resource: null,
1426
+ result: "invalid",
1427
+ statusCode: response.status,
1428
+ remoteHost
1429
+ };
1319
1430
  }
1320
- const redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
1321
1431
  if (redirectedUrl.protocol !== url.protocol) {
1322
1432
  logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
1323
1433
  protocol: url.protocol,
1324
1434
  redirectedProtocol: redirectedUrl.protocol
1325
1435
  });
1326
- return null;
1436
+ return {
1437
+ resource: null,
1438
+ result: "invalid",
1439
+ statusCode: response.status,
1440
+ remoteHost
1441
+ };
1327
1442
  }
1328
1443
  url = redirectedUrl;
1329
1444
  continue;
@@ -1334,14 +1449,29 @@ async function lookupWebFingerInternal(resource, options = {}) {
1334
1449
  status: response.status,
1335
1450
  statusText: response.statusText
1336
1451
  });
1337
- return null;
1452
+ return {
1453
+ resource: null,
1454
+ result: response.status === 404 || response.status === 410 ? "not_found" : "error",
1455
+ statusCode: response.status,
1456
+ remoteHost
1457
+ };
1338
1458
  }
1339
1459
  try {
1340
- return await response.json();
1460
+ return {
1461
+ resource: await response.json(),
1462
+ result: "found",
1463
+ statusCode: response.status,
1464
+ remoteHost
1465
+ };
1341
1466
  } catch (e) {
1342
1467
  if (e instanceof SyntaxError) {
1343
1468
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1344
- return null;
1469
+ return {
1470
+ resource: null,
1471
+ result: "invalid",
1472
+ statusCode: response.status,
1473
+ remoteHost
1474
+ };
1345
1475
  }
1346
1476
  throw e;
1347
1477
  }
@@ -1359,52 +1489,64 @@ test({
1359
1489
  deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe")), null);
1360
1490
  deepStrictEqual(await lookupWebFinger("acct:johndoe@"), null);
1361
1491
  deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe@")), null);
1492
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com/exploit"), null);
1493
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com?x=1"), null);
1494
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com#frag"), null);
1362
1495
  });
1363
1496
  await t.step("connection refused", async () => {
1364
1497
  deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal"), null);
1365
1498
  deepStrictEqual(await lookupWebFinger("https://fedify-test.internal/foo"), null);
1366
1499
  });
1367
1500
  esm_default.spyGlobal();
1368
- esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1369
- await t.step("not found", async () => {
1370
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1371
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
1372
- });
1373
- const expected = {
1374
- subject: "acct:johndoe@example.com",
1375
- links: []
1376
- };
1377
- esm_default.removeRoutes();
1378
- esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1379
- await t.step("acct", async () => {
1380
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1381
- });
1382
- const expected2 = {
1383
- subject: "https://example.com/foo",
1384
- links: []
1385
- };
1386
- esm_default.removeRoutes();
1387
- esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
1388
- await t.step("https", async () => {
1389
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), expected2);
1390
- });
1391
- esm_default.removeRoutes();
1392
- esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1393
- await t.step("invalid response", async () => {
1394
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1395
- });
1396
- esm_default.removeRoutes();
1397
- esm_default.get("begin:https://localhost/.well-known/webfinger?", {
1398
- subject: "acct:test@localhost",
1399
- links: [{
1400
- rel: "self",
1401
- type: "application/activity+json",
1402
- href: "https://localhost/actor"
1403
- }]
1404
- });
1405
- await t.step("private address", async () => {
1406
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
1407
- deepStrictEqual(await lookupWebFinger("acct:test@localhost", { allowPrivateAddress: true }), {
1501
+ try {
1502
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1503
+ await t.step("not found", async () => {
1504
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1505
+ deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
1506
+ });
1507
+ const expected = {
1508
+ subject: "acct:johndoe@example.com",
1509
+ links: []
1510
+ };
1511
+ esm_default.removeRoutes();
1512
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1513
+ await t.step("acct", async () => {
1514
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1515
+ });
1516
+ const expected2 = {
1517
+ subject: "https://example.com/foo",
1518
+ links: []
1519
+ };
1520
+ esm_default.removeRoutes();
1521
+ esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
1522
+ await t.step("https", async () => {
1523
+ deepStrictEqual(await lookupWebFinger("https://example.com/foo"), expected2);
1524
+ });
1525
+ const mailtoExpected = {
1526
+ subject: "mailto:juliet@example.com",
1527
+ links: []
1528
+ };
1529
+ esm_default.removeRoutes();
1530
+ esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com", { body: mailtoExpected });
1531
+ await t.step("mailto", async () => {
1532
+ deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com"), mailtoExpected);
1533
+ });
1534
+ const mailtoQueryExpected = {
1535
+ subject: "mailto:juliet@example.com?subject=Hi",
1536
+ links: []
1537
+ };
1538
+ esm_default.removeRoutes();
1539
+ esm_default.get("https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com%3Fsubject%3DHi", { body: mailtoQueryExpected });
1540
+ await t.step("mailto with hfields", async () => {
1541
+ deepStrictEqual(await lookupWebFinger("mailto:juliet@example.com?subject=Hi"), mailtoQueryExpected);
1542
+ });
1543
+ esm_default.removeRoutes();
1544
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1545
+ await t.step("invalid response", async () => {
1546
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1547
+ });
1548
+ esm_default.removeRoutes();
1549
+ esm_default.get("begin:https://localhost/.well-known/webfinger?", {
1408
1550
  subject: "acct:test@localhost",
1409
1551
  links: [{
1410
1552
  rel: "self",
@@ -1412,109 +1554,342 @@ test({
1412
1554
  href: "https://localhost/actor"
1413
1555
  }]
1414
1556
  });
1557
+ await t.step("private address", async () => {
1558
+ deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
1559
+ deepStrictEqual(await lookupWebFinger("acct:test@localhost", { allowPrivateAddress: true }), {
1560
+ subject: "acct:test@localhost",
1561
+ links: [{
1562
+ rel: "self",
1563
+ type: "application/activity+json",
1564
+ href: "https://localhost/actor"
1565
+ }]
1566
+ });
1567
+ });
1568
+ esm_default.removeRoutes();
1569
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1570
+ status: 302,
1571
+ headers: { Location: "/.well-known/webfinger2" }
1572
+ });
1573
+ esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
1574
+ await t.step("redirection", async () => {
1575
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1576
+ });
1577
+ esm_default.removeRoutes();
1578
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1579
+ status: 302,
1580
+ headers: { Location: "/.well-known/webfinger" }
1581
+ });
1582
+ await t.step("infinite redirection", async () => {
1583
+ deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com"), 2e3), null);
1584
+ });
1585
+ esm_default.removeRoutes();
1586
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1587
+ status: 302,
1588
+ headers: { Location: "ftp://example.com/" }
1589
+ });
1590
+ await t.step("redirection to different protocol", async () => {
1591
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1592
+ });
1593
+ esm_default.removeRoutes();
1594
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1595
+ status: 302,
1596
+ headers: { Location: "https://localhost/" }
1597
+ });
1598
+ await t.step("redirection to private address", async () => {
1599
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1600
+ });
1601
+ esm_default.removeRoutes();
1602
+ let redirectCount = 0;
1603
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1604
+ redirectCount++;
1605
+ if (redirectCount < 3) return {
1606
+ status: 302,
1607
+ headers: { Location: `/.well-known/webfinger?redirect=${redirectCount}` }
1608
+ };
1609
+ return { body: expected };
1610
+ });
1611
+ await t.step("custom maxRedirection", async () => {
1612
+ redirectCount = 0;
1613
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1614
+ redirectCount = 0;
1615
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 2 }), expected);
1616
+ redirectCount = 0;
1617
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1618
+ redirectCount = 0;
1619
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1620
+ });
1621
+ await t.step("maxRedirection: 1 follows exactly one redirect", async () => {
1622
+ esm_default.removeRoutes();
1623
+ let count = 0;
1624
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1625
+ count++;
1626
+ return count < 2 ? {
1627
+ status: 302,
1628
+ headers: { Location: "/.well-known/webfinger?after=1" }
1629
+ } : { body: expected };
1630
+ });
1631
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), expected);
1632
+ esm_default.removeRoutes();
1633
+ count = 0;
1634
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1635
+ count++;
1636
+ return count < 3 ? {
1637
+ status: 302,
1638
+ headers: { Location: `/.well-known/webfinger?after=${count}` }
1639
+ } : { body: expected };
1640
+ });
1641
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 1 }), null);
1642
+ });
1643
+ esm_default.removeRoutes();
1644
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1645
+ const timeoutId = setTimeout(() => {
1646
+ resolve({ body: expected });
1647
+ }, 1e3);
1648
+ return () => clearTimeout(timeoutId);
1649
+ }));
1650
+ await t.step("request cancellation", async () => {
1651
+ const controller = new AbortController();
1652
+ const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1653
+ controller.abort();
1654
+ deepStrictEqual(await promise, null);
1655
+ });
1656
+ esm_default.removeRoutes();
1657
+ let redirectCount2 = 0;
1658
+ esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1659
+ redirectCount2++;
1660
+ if (redirectCount2 === 1) return {
1661
+ status: 302,
1662
+ headers: { Location: "/.well-known/webfinger2" }
1663
+ };
1664
+ return new Promise((resolve) => {
1665
+ const timeoutId = setTimeout(() => {
1666
+ resolve({ body: expected });
1667
+ }, 1e3);
1668
+ return () => clearTimeout(timeoutId);
1669
+ });
1670
+ });
1671
+ await t.step("cancellation during redirection", async () => {
1672
+ const controller = new AbortController();
1673
+ const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1674
+ setTimeout(() => controller.abort(), 100);
1675
+ deepStrictEqual(await promise, null);
1676
+ });
1677
+ esm_default.removeRoutes();
1678
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1679
+ const timeoutId = setTimeout(() => {
1680
+ resolve({ body: expected });
1681
+ }, 500);
1682
+ return () => clearTimeout(timeoutId);
1683
+ }));
1684
+ await t.step("cancellation with immediate abort", async () => {
1685
+ const controller = new AbortController();
1686
+ controller.abort();
1687
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
1688
+ });
1689
+ esm_default.removeRoutes();
1690
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
1691
+ await t.step("successful request with signal", async () => {
1692
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1693
+ });
1694
+ } finally {
1695
+ esm_default.removeRoutes();
1696
+ esm_default.hardReset();
1697
+ }
1698
+ }
1699
+ });
1700
+ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1701
+ sanitizeOps: false,
1702
+ sanitizeResources: false
1703
+ }, async (t) => {
1704
+ esm_default.spyGlobal();
1705
+ try {
1706
+ const expected = {
1707
+ subject: "acct:johndoe@example.com",
1708
+ links: []
1709
+ };
1710
+ await t.step("records result=found for a successful acct lookup", async () => {
1711
+ esm_default.removeRoutes();
1712
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1713
+ const [meterProvider, recorder] = createTestMeterProvider();
1714
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), expected);
1715
+ const counters = recorder.getMeasurements("webfinger.lookup");
1716
+ deepStrictEqual(counters.length, 1);
1717
+ deepStrictEqual(counters[0].type, "counter");
1718
+ deepStrictEqual(counters[0].value, 1);
1719
+ deepStrictEqual(counters[0].attributes["webfinger.lookup.result"], "found");
1720
+ deepStrictEqual(counters[0].attributes["webfinger.resource.scheme"], "acct");
1721
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "example.com");
1722
+ deepStrictEqual(counters[0].attributes["http.response.status_code"], 200);
1723
+ const durations = recorder.getMeasurements("webfinger.lookup.duration");
1724
+ deepStrictEqual(durations.length, 1);
1725
+ deepStrictEqual(durations[0].type, "histogram");
1726
+ deepStrictEqual(durations[0].attributes["webfinger.lookup.result"], "found");
1727
+ deepStrictEqual(durations[0].attributes["webfinger.resource.scheme"], "acct");
1728
+ ok(typeof durations[0].value === "number" && durations[0].value >= 0);
1415
1729
  });
1416
- esm_default.removeRoutes();
1417
- esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1418
- status: 302,
1419
- headers: { Location: "/.well-known/webfinger2" }
1730
+ await t.step("records scheme=https for an https resource lookup", async () => {
1731
+ esm_default.removeRoutes();
1732
+ esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: {
1733
+ subject: "https://example.com/foo",
1734
+ links: []
1735
+ } });
1736
+ const [meterProvider, recorder] = createTestMeterProvider();
1737
+ await lookupWebFinger("https://example.com/foo", { meterProvider });
1738
+ const counters = recorder.getMeasurements("webfinger.lookup");
1739
+ deepStrictEqual(counters.length, 1);
1740
+ deepStrictEqual(counters[0].attributes["webfinger.resource.scheme"], "https");
1741
+ deepStrictEqual(counters[0].attributes["webfinger.lookup.result"], "found");
1420
1742
  });
1421
- esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
1422
- await t.step("redirection", async () => {
1423
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1743
+ await t.step("records non-default ports for URL resources", async () => {
1744
+ esm_default.removeRoutes();
1745
+ esm_default.get("https://example.com:8443/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%3A8443%2Ffoo", { body: {
1746
+ subject: "https://example.com:8443/foo",
1747
+ links: []
1748
+ } });
1749
+ const [meterProvider, recorder] = createTestMeterProvider();
1750
+ await lookupWebFinger("https://example.com:8443/foo", { meterProvider });
1751
+ const counter = recorder.getMeasurement("webfinger.lookup");
1752
+ ok(counter != null);
1753
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "example.com:8443");
1424
1754
  });
1425
- esm_default.removeRoutes();
1426
- esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1427
- status: 302,
1428
- headers: { Location: "/.well-known/webfinger" }
1755
+ await t.step("records result=not_found with status 404", async () => {
1756
+ esm_default.removeRoutes();
1757
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1758
+ const [meterProvider, recorder] = createTestMeterProvider();
1759
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { meterProvider }), null);
1760
+ const counters = recorder.getMeasurements("webfinger.lookup");
1761
+ deepStrictEqual(counters.length, 1);
1762
+ deepStrictEqual(counters[0].attributes["webfinger.lookup.result"], "not_found");
1763
+ deepStrictEqual(counters[0].attributes["http.response.status_code"], 404);
1764
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "example.com");
1765
+ const durations = recorder.getMeasurements("webfinger.lookup.duration");
1766
+ deepStrictEqual(durations.length, 1);
1767
+ deepStrictEqual(durations[0].attributes["webfinger.lookup.result"], "not_found");
1429
1768
  });
1430
- await t.step("infinite redirection", async () => {
1431
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com"), 2e3), null);
1769
+ await t.step("records result=not_found with status 410", async () => {
1770
+ esm_default.removeRoutes();
1771
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 410 });
1772
+ const [meterProvider, recorder] = createTestMeterProvider();
1773
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1774
+ const counter = recorder.getMeasurement("webfinger.lookup");
1775
+ ok(counter != null);
1776
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "not_found");
1777
+ deepStrictEqual(counter.attributes["http.response.status_code"], 410);
1432
1778
  });
1433
- esm_default.removeRoutes();
1434
- esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1435
- status: 302,
1436
- headers: { Location: "ftp://example.com/" }
1779
+ await t.step("records result=error for non-2xx, non-404/410 HTTP responses", async () => {
1780
+ esm_default.removeRoutes();
1781
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 500 });
1782
+ const [meterProvider, recorder] = createTestMeterProvider();
1783
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1784
+ const counter = recorder.getMeasurement("webfinger.lookup");
1785
+ ok(counter != null);
1786
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "error");
1787
+ deepStrictEqual(counter.attributes["http.response.status_code"], 500);
1437
1788
  });
1438
- await t.step("redirection to different protocol", async () => {
1439
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1789
+ await t.step("records result=invalid for malformed JSON bodies", async () => {
1790
+ esm_default.removeRoutes();
1791
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1792
+ const [meterProvider, recorder] = createTestMeterProvider();
1793
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1794
+ const counter = recorder.getMeasurement("webfinger.lookup");
1795
+ ok(counter != null);
1796
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
1797
+ deepStrictEqual(counter.attributes["http.response.status_code"], 200);
1440
1798
  });
1441
- esm_default.removeRoutes();
1442
- esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1443
- status: 302,
1444
- headers: { Location: "https://localhost/" }
1799
+ await t.step("records result=network_error when fetch never reaches the remote", async () => {
1800
+ esm_default.removeRoutes();
1801
+ const [meterProvider, recorder] = createTestMeterProvider();
1802
+ deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal", { meterProvider }), null);
1803
+ const counter = recorder.getMeasurement("webfinger.lookup");
1804
+ ok(counter != null);
1805
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
1806
+ deepStrictEqual("http.response.status_code" in counter.attributes, false, "no HTTP response means no status code attribute");
1807
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "fedify-test.internal");
1445
1808
  });
1446
- await t.step("redirection to private address", async () => {
1447
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1809
+ await t.step("records result=invalid for malformed acct: resources", async () => {
1810
+ const [meterProvider, recorder] = createTestMeterProvider();
1811
+ deepStrictEqual(await lookupWebFinger("acct:johndoe", { meterProvider }), null);
1812
+ const counter = recorder.getMeasurement("webfinger.lookup");
1813
+ ok(counter != null);
1814
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
1815
+ deepStrictEqual(counter.attributes["webfinger.resource.scheme"], "acct");
1816
+ deepStrictEqual("activitypub.remote.host" in counter.attributes, false, "a malformed acct resource has no usable remote host");
1448
1817
  });
1449
- esm_default.removeRoutes();
1450
- let redirectCount = 0;
1451
- esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1452
- redirectCount++;
1453
- if (redirectCount < 3) return {
1818
+ await t.step("records result=invalid when the redirect chain exceeds maxRedirection", async () => {
1819
+ esm_default.removeRoutes();
1820
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
1454
1821
  status: 302,
1455
- headers: { Location: `/.well-known/webfinger?redirect=${redirectCount}` }
1456
- };
1457
- return { body: expected };
1822
+ headers: { Location: "/.well-known/webfinger" }
1823
+ });
1824
+ const [meterProvider, recorder] = createTestMeterProvider();
1825
+ deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com", {
1826
+ meterProvider,
1827
+ maxRedirection: 3
1828
+ }), 2e3), null);
1829
+ const counter = recorder.getMeasurement("webfinger.lookup");
1830
+ ok(counter != null);
1831
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
1832
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
1833
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "example.com");
1458
1834
  });
1459
- await t.step("custom maxRedirection", async () => {
1460
- redirectCount = 0;
1461
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 2 }), null);
1462
- redirectCount = 0;
1463
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1464
- redirectCount = 0;
1465
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1835
+ await t.step("records result=invalid for cross-protocol redirects", async () => {
1836
+ esm_default.removeRoutes();
1837
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1838
+ status: 302,
1839
+ headers: { Location: "ftp://example.com/" }
1840
+ });
1841
+ const [meterProvider, recorder] = createTestMeterProvider();
1842
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1843
+ const counter = recorder.getMeasurement("webfinger.lookup");
1844
+ ok(counter != null);
1845
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
1846
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
1466
1847
  });
1467
- esm_default.removeRoutes();
1468
- esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1469
- const timeoutId = setTimeout(() => {
1470
- resolve({ body: expected });
1471
- }, 1e3);
1472
- return () => clearTimeout(timeoutId);
1473
- }));
1474
- await t.step("request cancellation", async () => {
1475
- const controller = new AbortController();
1476
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1477
- controller.abort();
1478
- deepStrictEqual(await promise, null);
1848
+ await t.step("records result=network_error when a redirect points to a private address", async () => {
1849
+ esm_default.removeRoutes();
1850
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1851
+ status: 302,
1852
+ headers: { Location: "https://localhost/" }
1853
+ });
1854
+ const [meterProvider, recorder] = createTestMeterProvider();
1855
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1856
+ const counter = recorder.getMeasurement("webfinger.lookup");
1857
+ ok(counter != null);
1858
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "network_error");
1859
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "localhost", "remote.host reflects the latest URL we attempted, even after a redirect");
1479
1860
  });
1480
- esm_default.removeRoutes();
1481
- let redirectCount2 = 0;
1482
- esm_default.get("begin:https://example.com/.well-known/webfinger", () => {
1483
- redirectCount2++;
1484
- if (redirectCount2 === 1) return {
1861
+ await t.step("records result=invalid for malformed Location headers", async () => {
1862
+ esm_default.removeRoutes();
1863
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", {
1485
1864
  status: 302,
1486
- headers: { Location: "/.well-known/webfinger2" }
1487
- };
1488
- return new Promise((resolve) => {
1489
- const timeoutId = setTimeout(() => {
1490
- resolve({ body: expected });
1491
- }, 1e3);
1492
- return () => clearTimeout(timeoutId);
1865
+ headers: { Location: "http://[bad" }
1493
1866
  });
1867
+ const [meterProvider, recorder] = createTestMeterProvider();
1868
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
1869
+ const counter = recorder.getMeasurement("webfinger.lookup");
1870
+ ok(counter != null);
1871
+ deepStrictEqual(counter.attributes["webfinger.lookup.result"], "invalid");
1872
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
1873
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "example.com");
1494
1874
  });
1495
- await t.step("cancellation during redirection", async () => {
1496
- const controller = new AbortController();
1497
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1498
- setTimeout(() => controller.abort(), 100);
1499
- deepStrictEqual(await promise, null);
1875
+ await t.step("buckets unknown resource schemes as 'other' to keep metric cardinality bounded", async () => {
1876
+ esm_default.removeRoutes();
1877
+ const [meterProvider, recorder] = createTestMeterProvider();
1878
+ await lookupWebFinger("ssh://example.com/foo", { meterProvider });
1879
+ const counter = recorder.getMeasurement("webfinger.lookup");
1880
+ ok(counter != null);
1881
+ deepStrictEqual(counter.attributes["webfinger.resource.scheme"], "other");
1500
1882
  });
1501
- esm_default.removeRoutes();
1502
- esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
1503
- const timeoutId = setTimeout(() => {
1504
- resolve({ body: expected });
1505
- }, 500);
1506
- return () => clearTimeout(timeoutId);
1507
- }));
1508
- await t.step("cancellation with immediate abort", async () => {
1509
- const controller = new AbortController();
1510
- controller.abort();
1511
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
1883
+ await t.step("omits measurements when no meterProvider is provided", async () => {
1884
+ esm_default.removeRoutes();
1885
+ esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1886
+ const [_unused, recorder] = createTestMeterProvider();
1887
+ await lookupWebFinger("acct:johndoe@example.com");
1888
+ deepStrictEqual(recorder.getMeasurements("webfinger.lookup").length, 0);
1889
+ deepStrictEqual(recorder.getMeasurements("webfinger.lookup.duration").length, 0);
1512
1890
  });
1891
+ } finally {
1513
1892
  esm_default.removeRoutes();
1514
- esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
1515
- await t.step("successful request with signal", async () => {
1516
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1517
- });
1518
1893
  esm_default.hardReset();
1519
1894
  }
1520
1895
  });