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

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,7 +1,7 @@
1
- import { test } from "@fedify/fixture";
1
+ import { createTestMeterProvider, test } from "@fedify/fixture";
2
2
  import { withTimeout } from "es-toolkit";
3
3
  import fetchMock from "fetch-mock";
4
- import { deepStrictEqual } from "node:assert/strict";
4
+ import { deepStrictEqual, ok } from "node:assert/strict";
5
5
  import type { ResourceDescriptor } from "./jrd.ts";
6
6
  import { lookupWebFinger } from "./lookup.ts";
7
7
 
@@ -15,6 +15,21 @@ test({
15
15
  deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe")), null);
16
16
  deepStrictEqual(await lookupWebFinger("acct:johndoe@"), null);
17
17
  deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe@")), null);
18
+ // Per RFC 7565, the acct: authority is bare `host`: no path,
19
+ // query, or fragment is allowed. Reject such inputs rather than
20
+ // forwarding them to a remote WebFinger lookup.
21
+ deepStrictEqual(
22
+ await lookupWebFinger("acct:johndoe@example.com/exploit"),
23
+ null,
24
+ );
25
+ deepStrictEqual(
26
+ await lookupWebFinger("acct:johndoe@example.com?x=1"),
27
+ null,
28
+ );
29
+ deepStrictEqual(
30
+ await lookupWebFinger("acct:johndoe@example.com#frag"),
31
+ null,
32
+ );
18
33
  });
19
34
 
20
35
  await t.step("connection refused", async () => {
@@ -29,81 +44,117 @@ test({
29
44
  });
30
45
 
31
46
  fetchMock.spyGlobal();
32
- fetchMock.get(
33
- "begin:https://example.com/.well-known/webfinger?",
34
- { status: 404 },
35
- );
47
+ // Wrap the rest of the outer test in try/finally so the global
48
+ // `fetch` spy is torn down even if a t.step assertion below
49
+ // throws. Matches the cleanup pattern of the metrics test
50
+ // further down in this file.
51
+ try {
52
+ fetchMock.get(
53
+ "begin:https://example.com/.well-known/webfinger?",
54
+ { status: 404 },
55
+ );
36
56
 
37
- await t.step("not found", async () => {
38
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
39
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
40
- });
57
+ await t.step("not found", async () => {
58
+ deepStrictEqual(
59
+ await lookupWebFinger("acct:johndoe@example.com"),
60
+ null,
61
+ );
62
+ deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
63
+ });
41
64
 
42
- const expected: ResourceDescriptor = {
43
- subject: "acct:johndoe@example.com",
44
- links: [],
45
- };
46
- fetchMock.removeRoutes();
47
- fetchMock.get(
48
- "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
49
- { body: expected },
50
- );
65
+ const expected: ResourceDescriptor = {
66
+ subject: "acct:johndoe@example.com",
67
+ links: [],
68
+ };
69
+ fetchMock.removeRoutes();
70
+ fetchMock.get(
71
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
72
+ { body: expected },
73
+ );
51
74
 
52
- await t.step("acct", async () => {
53
- deepStrictEqual(
54
- await lookupWebFinger("acct:johndoe@example.com"),
55
- expected,
75
+ await t.step("acct", async () => {
76
+ deepStrictEqual(
77
+ await lookupWebFinger("acct:johndoe@example.com"),
78
+ expected,
79
+ );
80
+ });
81
+
82
+ const expected2: ResourceDescriptor = {
83
+ subject: "https://example.com/foo",
84
+ links: [],
85
+ };
86
+ fetchMock.removeRoutes();
87
+ fetchMock.get(
88
+ "https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo",
89
+ { body: expected2 },
56
90
  );
57
- });
58
91
 
59
- const expected2: ResourceDescriptor = {
60
- subject: "https://example.com/foo",
61
- links: [],
62
- };
63
- fetchMock.removeRoutes();
64
- fetchMock.get(
65
- "https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo",
66
- { body: expected2 },
67
- );
92
+ await t.step("https", async () => {
93
+ deepStrictEqual(
94
+ await lookupWebFinger("https://example.com/foo"),
95
+ expected2,
96
+ );
97
+ });
68
98
 
69
- await t.step("https", async () => {
70
- deepStrictEqual(
71
- await lookupWebFinger("https://example.com/foo"),
72
- expected2,
99
+ const mailtoExpected: ResourceDescriptor = {
100
+ subject: "mailto:juliet@example.com",
101
+ links: [],
102
+ };
103
+ fetchMock.removeRoutes();
104
+ fetchMock.get(
105
+ "https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com",
106
+ { body: mailtoExpected },
73
107
  );
74
- });
75
108
 
76
- fetchMock.removeRoutes();
77
- fetchMock.get(
78
- "begin:https://example.com/.well-known/webfinger?",
79
- { body: "not json" },
80
- );
109
+ await t.step("mailto", async () => {
110
+ // RFC 7033 permits any URI as a WebFinger resource, and RFC 7565
111
+ // explicitly references `mailto:` as an example. The opaque-path
112
+ // host extraction (after the last `@`) applies to `mailto:` just
113
+ // like `acct:`.
114
+ deepStrictEqual(
115
+ await lookupWebFinger("mailto:juliet@example.com"),
116
+ mailtoExpected,
117
+ );
118
+ });
81
119
 
82
- await t.step("invalid response", async () => {
83
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
84
- });
120
+ const mailtoQueryExpected: ResourceDescriptor = {
121
+ subject: "mailto:juliet@example.com?subject=Hi",
122
+ links: [],
123
+ };
124
+ fetchMock.removeRoutes();
125
+ fetchMock.get(
126
+ "https://example.com/.well-known/webfinger?resource=mailto%3Ajuliet%40example.com%3Fsubject%3DHi",
127
+ { body: mailtoQueryExpected },
128
+ );
85
129
 
86
- fetchMock.removeRoutes();
87
- fetchMock.get(
88
- "begin:https://localhost/.well-known/webfinger?",
89
- {
90
- subject: "acct:test@localhost",
91
- links: [
92
- {
93
- rel: "self",
94
- type: "application/activity+json",
95
- href: "https://localhost/actor",
96
- },
97
- ],
98
- },
99
- );
130
+ await t.step("mailto with hfields", async () => {
131
+ // RFC 6068 §2 allows `mailto:` URIs to carry `?hfields=...`
132
+ // header fields and fragment identifiers. Unlike `acct:`,
133
+ // those components are part of the grammar, so the lookup
134
+ // must accept them and forward the full resource URI to the
135
+ // WebFinger endpoint.
136
+ deepStrictEqual(
137
+ await lookupWebFinger("mailto:juliet@example.com?subject=Hi"),
138
+ mailtoQueryExpected,
139
+ );
140
+ });
100
141
 
101
- await t.step("private address", async () => {
102
- deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
103
- deepStrictEqual(
104
- await lookupWebFinger("acct:test@localhost", {
105
- allowPrivateAddress: true,
106
- }),
142
+ fetchMock.removeRoutes();
143
+ fetchMock.get(
144
+ "begin:https://example.com/.well-known/webfinger?",
145
+ { body: "not json" },
146
+ );
147
+
148
+ await t.step("invalid response", async () => {
149
+ deepStrictEqual(
150
+ await lookupWebFinger("acct:johndoe@example.com"),
151
+ null,
152
+ );
153
+ });
154
+
155
+ fetchMock.removeRoutes();
156
+ fetchMock.get(
157
+ "begin:https://localhost/.well-known/webfinger?",
107
158
  {
108
159
  subject: "acct:test@localhost",
109
160
  links: [
@@ -115,217 +166,723 @@ test({
115
166
  ],
116
167
  },
117
168
  );
118
- });
119
169
 
120
- fetchMock.removeRoutes();
121
- fetchMock.get(
122
- "begin:https://example.com/.well-known/webfinger?",
123
- {
124
- status: 302,
125
- headers: { Location: "/.well-known/webfinger2" },
126
- },
127
- );
128
- fetchMock.get(
129
- "begin:https://example.com/.well-known/webfinger2",
130
- { body: expected },
131
- );
170
+ await t.step("private address", async () => {
171
+ deepStrictEqual(await lookupWebFinger("acct:test@localhost"), null);
172
+ deepStrictEqual(
173
+ await lookupWebFinger("acct:test@localhost", {
174
+ allowPrivateAddress: true,
175
+ }),
176
+ {
177
+ subject: "acct:test@localhost",
178
+ links: [
179
+ {
180
+ rel: "self",
181
+ type: "application/activity+json",
182
+ href: "https://localhost/actor",
183
+ },
184
+ ],
185
+ },
186
+ );
187
+ });
132
188
 
133
- await t.step("redirection", async () => {
134
- deepStrictEqual(
135
- await lookupWebFinger("acct:johndoe@example.com"),
136
- expected,
189
+ fetchMock.removeRoutes();
190
+ fetchMock.get(
191
+ "begin:https://example.com/.well-known/webfinger?",
192
+ {
193
+ status: 302,
194
+ headers: { Location: "/.well-known/webfinger2" },
195
+ },
196
+ );
197
+ fetchMock.get(
198
+ "begin:https://example.com/.well-known/webfinger2",
199
+ { body: expected },
137
200
  );
138
- });
139
201
 
140
- fetchMock.removeRoutes();
141
- fetchMock.get(
142
- "begin:https://example.com/.well-known/webfinger?",
143
- {
144
- status: 302,
145
- headers: { Location: "/.well-known/webfinger" },
146
- },
147
- );
202
+ await t.step("redirection", async () => {
203
+ deepStrictEqual(
204
+ await lookupWebFinger("acct:johndoe@example.com"),
205
+ expected,
206
+ );
207
+ });
148
208
 
149
- await t.step("infinite redirection", async () => {
150
- const result = await withTimeout(
151
- () => lookupWebFinger("acct:johndoe@example.com"),
152
- 2000,
209
+ fetchMock.removeRoutes();
210
+ fetchMock.get(
211
+ "begin:https://example.com/.well-known/webfinger?",
212
+ {
213
+ status: 302,
214
+ headers: { Location: "/.well-known/webfinger" },
215
+ },
153
216
  );
154
- deepStrictEqual(result, null);
155
- });
156
217
 
157
- fetchMock.removeRoutes();
158
- fetchMock.get(
159
- "begin:https://example.com/.well-known/webfinger?",
160
- {
161
- status: 302,
162
- headers: { Location: "ftp://example.com/" },
163
- },
164
- );
218
+ await t.step("infinite redirection", async () => {
219
+ const result = await withTimeout(
220
+ () => lookupWebFinger("acct:johndoe@example.com"),
221
+ 2000,
222
+ );
223
+ deepStrictEqual(result, null);
224
+ });
165
225
 
166
- await t.step("redirection to different protocol", async () => {
167
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
168
- });
226
+ fetchMock.removeRoutes();
227
+ fetchMock.get(
228
+ "begin:https://example.com/.well-known/webfinger?",
229
+ {
230
+ status: 302,
231
+ headers: { Location: "ftp://example.com/" },
232
+ },
233
+ );
169
234
 
170
- fetchMock.removeRoutes();
171
- fetchMock.get(
172
- "begin:https://example.com/.well-known/webfinger?",
173
- {
174
- status: 302,
175
- headers: { Location: "https://localhost/" },
235
+ await t.step("redirection to different protocol", async () => {
236
+ deepStrictEqual(
237
+ await lookupWebFinger("acct:johndoe@example.com"),
238
+ null,
239
+ );
240
+ });
241
+
242
+ fetchMock.removeRoutes();
243
+ fetchMock.get(
244
+ "begin:https://example.com/.well-known/webfinger?",
245
+ {
246
+ status: 302,
247
+ headers: { Location: "https://localhost/" },
248
+ },
249
+ );
250
+
251
+ await t.step("redirection to private address", async () => {
252
+ deepStrictEqual(
253
+ await lookupWebFinger("acct:johndoe@example.com"),
254
+ null,
255
+ );
256
+ });
257
+
258
+ fetchMock.removeRoutes();
259
+ let redirectCount = 0;
260
+ fetchMock.get(
261
+ "begin:https://example.com/.well-known/webfinger",
262
+ () => {
263
+ redirectCount++;
264
+ if (redirectCount < 3) {
265
+ return {
266
+ status: 302,
267
+ headers: {
268
+ Location: `/.well-known/webfinger?redirect=${redirectCount}`,
269
+ },
270
+ };
271
+ }
272
+ return { body: expected };
273
+ },
274
+ );
275
+
276
+ await t.step("custom maxRedirection", async () => {
277
+ // Test with maxRedirection: 1 (should fail; mock has 2 redirects)
278
+ redirectCount = 0;
279
+ deepStrictEqual(
280
+ await lookupWebFinger("acct:johndoe@example.com", {
281
+ maxRedirection: 1,
282
+ }),
283
+ null,
284
+ );
285
+
286
+ // Test with maxRedirection: 2 (should succeed; mock has exactly 2
287
+ // redirects, and `maxRedirection: N` follows up to N redirects)
288
+ redirectCount = 0;
289
+ deepStrictEqual(
290
+ await lookupWebFinger("acct:johndoe@example.com", {
291
+ maxRedirection: 2,
292
+ }),
293
+ expected,
294
+ );
295
+
296
+ // Test with maxRedirection: 3 (should succeed)
297
+ redirectCount = 0;
298
+ deepStrictEqual(
299
+ await lookupWebFinger("acct:johndoe@example.com", {
300
+ maxRedirection: 3,
301
+ }),
302
+ expected,
303
+ );
304
+
305
+ // Test with default maxRedirection: 5 (should succeed)
306
+ redirectCount = 0;
307
+ deepStrictEqual(
308
+ await lookupWebFinger("acct:johndoe@example.com"),
309
+ expected,
310
+ );
311
+ });
312
+
313
+ // Regression: `maxRedirection: 1` must allow exactly one 302 to be
314
+ // followed. An earlier implementation incremented the counter before
315
+ // the `>=` check, so `maxRedirection: 1` rejected the first redirect
316
+ // instead of following it. The expected semantics is "follow up to
317
+ // N redirects".
318
+ await t.step(
319
+ "maxRedirection: 1 follows exactly one redirect",
320
+ async () => {
321
+ // Mock with a single redirect: 302 → 200. Under the corrected
322
+ // semantics, `maxRedirection: 1` follows it and reaches the body.
323
+ fetchMock.removeRoutes();
324
+ let count = 0;
325
+ fetchMock.get(
326
+ "begin:https://example.com/.well-known/webfinger",
327
+ () => {
328
+ count++;
329
+ return count < 2
330
+ ? {
331
+ status: 302,
332
+ headers: { Location: "/.well-known/webfinger?after=1" },
333
+ }
334
+ : { body: expected };
335
+ },
336
+ );
337
+ deepStrictEqual(
338
+ await lookupWebFinger("acct:johndoe@example.com", {
339
+ maxRedirection: 1,
340
+ }),
341
+ expected,
342
+ );
343
+
344
+ // Mock with two redirects. `maxRedirection: 1` rejects the
345
+ // second redirect.
346
+ fetchMock.removeRoutes();
347
+ count = 0;
348
+ fetchMock.get(
349
+ "begin:https://example.com/.well-known/webfinger",
350
+ () => {
351
+ count++;
352
+ return count < 3
353
+ ? {
354
+ status: 302,
355
+ headers: {
356
+ Location: `/.well-known/webfinger?after=${count}`,
357
+ },
358
+ }
359
+ : { body: expected };
360
+ },
361
+ );
362
+ deepStrictEqual(
363
+ await lookupWebFinger("acct:johndoe@example.com", {
364
+ maxRedirection: 1,
365
+ }),
366
+ null,
367
+ );
368
+ },
369
+ );
370
+
371
+ fetchMock.removeRoutes();
372
+ fetchMock.get(
373
+ "begin:https://example.com/.well-known/webfinger?",
374
+ () =>
375
+ new Promise((resolve) => {
376
+ const timeoutId = setTimeout(() => {
377
+ resolve({ body: expected });
378
+ }, 1000);
379
+
380
+ return () => clearTimeout(timeoutId);
381
+ }),
382
+ );
383
+
384
+ await t.step("request cancellation", async () => {
385
+ // Test cancelling a request immediately using AbortController
386
+ const controller = new AbortController();
387
+ const promise = lookupWebFinger("acct:johndoe@example.com", {
388
+ signal: controller.signal,
389
+ });
390
+
391
+ // Abort the request right after starting it
392
+ controller.abort();
393
+ deepStrictEqual(await promise, null);
394
+ });
395
+
396
+ fetchMock.removeRoutes();
397
+ let redirectCount2 = 0;
398
+ fetchMock.get(
399
+ "begin:https://example.com/.well-known/webfinger",
400
+ () => {
401
+ redirectCount2++;
402
+ if (redirectCount2 === 1) {
403
+ return {
404
+ status: 302,
405
+ headers: { Location: "/.well-known/webfinger2" },
406
+ };
407
+ }
408
+ return new Promise((resolve) => {
409
+ const timeoutId = setTimeout(() => {
410
+ resolve({ body: expected });
411
+ }, 1000);
412
+
413
+ return () => clearTimeout(timeoutId);
414
+ });
415
+ },
416
+ );
417
+
418
+ await t.step("cancellation during redirection", async () => {
419
+ // Test cancelling a request during redirection process
420
+ const controller = new AbortController();
421
+ const promise = lookupWebFinger("acct:johndoe@example.com", {
422
+ signal: controller.signal,
423
+ });
424
+
425
+ // Cancel during the delayed second request after redirection
426
+ setTimeout(() => controller.abort(), 100);
427
+ deepStrictEqual(await promise, null);
428
+ });
429
+
430
+ fetchMock.removeRoutes();
431
+ fetchMock.get(
432
+ "begin:https://example.com/.well-known/webfinger?",
433
+ () =>
434
+ new Promise((resolve) => {
435
+ const timeoutId = setTimeout(() => {
436
+ resolve({ body: expected });
437
+ }, 500);
438
+
439
+ return () => clearTimeout(timeoutId);
440
+ }),
441
+ );
442
+
443
+ await t.step("cancellation with immediate abort", async () => {
444
+ // Test starting a request with an already aborted AbortController
445
+ const controller = new AbortController();
446
+ controller.abort();
447
+
448
+ // Use a signal that was already aborted before starting the request
449
+ const result = await lookupWebFinger("acct:johndoe@example.com", {
450
+ signal: controller.signal,
451
+ });
452
+ deepStrictEqual(result, null);
453
+ });
454
+
455
+ fetchMock.removeRoutes();
456
+ fetchMock.get(
457
+ "begin:https://example.com/.well-known/webfinger?",
458
+ { body: expected },
459
+ );
460
+
461
+ await t.step("successful request with signal", async () => {
462
+ // Test successful request with a normal AbortController signal
463
+ const controller = new AbortController();
464
+ const result = await lookupWebFinger("acct:johndoe@example.com", {
465
+ signal: controller.signal,
466
+ });
467
+ deepStrictEqual(result, expected);
468
+ });
469
+ } finally {
470
+ fetchMock.removeRoutes();
471
+ fetchMock.hardReset();
472
+ }
473
+ },
474
+ });
475
+
476
+ test("lookupWebFinger() records webfinger.lookup counter and duration", {
477
+ sanitizeOps: false,
478
+ sanitizeResources: false,
479
+ }, async (t) => {
480
+ fetchMock.spyGlobal();
481
+ try {
482
+ const expected: ResourceDescriptor = {
483
+ subject: "acct:johndoe@example.com",
484
+ links: [],
485
+ };
486
+
487
+ await t.step(
488
+ "records result=found for a successful acct lookup",
489
+ async () => {
490
+ fetchMock.removeRoutes();
491
+ fetchMock.get(
492
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
493
+ { body: expected },
494
+ );
495
+ const [meterProvider, recorder] = createTestMeterProvider();
496
+ const result = await lookupWebFinger("acct:johndoe@example.com", {
497
+ meterProvider,
498
+ });
499
+ deepStrictEqual(result, expected);
500
+
501
+ const counters = recorder.getMeasurements("webfinger.lookup");
502
+ deepStrictEqual(counters.length, 1);
503
+ deepStrictEqual(counters[0].type, "counter");
504
+ deepStrictEqual(counters[0].value, 1);
505
+ deepStrictEqual(
506
+ counters[0].attributes["webfinger.lookup.result"],
507
+ "found",
508
+ );
509
+ deepStrictEqual(
510
+ counters[0].attributes["webfinger.resource.scheme"],
511
+ "acct",
512
+ );
513
+ deepStrictEqual(
514
+ counters[0].attributes["activitypub.remote.host"],
515
+ "example.com",
516
+ );
517
+ deepStrictEqual(
518
+ counters[0].attributes["http.response.status_code"],
519
+ 200,
520
+ );
521
+
522
+ const durations = recorder.getMeasurements("webfinger.lookup.duration");
523
+ deepStrictEqual(durations.length, 1);
524
+ deepStrictEqual(durations[0].type, "histogram");
525
+ deepStrictEqual(
526
+ durations[0].attributes["webfinger.lookup.result"],
527
+ "found",
528
+ );
529
+ deepStrictEqual(
530
+ durations[0].attributes["webfinger.resource.scheme"],
531
+ "acct",
532
+ );
533
+ ok(typeof durations[0].value === "number" && durations[0].value >= 0);
176
534
  },
177
535
  );
178
536
 
179
- await t.step("redirection to private address", async () => {
180
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
181
- });
537
+ await t.step(
538
+ "records scheme=https for an https resource lookup",
539
+ async () => {
540
+ fetchMock.removeRoutes();
541
+ fetchMock.get(
542
+ "https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo",
543
+ { body: { subject: "https://example.com/foo", links: [] } },
544
+ );
545
+ const [meterProvider, recorder] = createTestMeterProvider();
546
+ await lookupWebFinger("https://example.com/foo", { meterProvider });
547
+ const counters = recorder.getMeasurements("webfinger.lookup");
548
+ deepStrictEqual(counters.length, 1);
549
+ deepStrictEqual(
550
+ counters[0].attributes["webfinger.resource.scheme"],
551
+ "https",
552
+ );
553
+ deepStrictEqual(
554
+ counters[0].attributes["webfinger.lookup.result"],
555
+ "found",
556
+ );
557
+ },
558
+ );
182
559
 
183
- fetchMock.removeRoutes();
184
- let redirectCount = 0;
185
- fetchMock.get(
186
- "begin:https://example.com/.well-known/webfinger",
187
- () => {
188
- redirectCount++;
189
- if (redirectCount < 3) {
190
- return {
191
- status: 302,
192
- headers: {
193
- Location: `/.well-known/webfinger?redirect=${redirectCount}`,
194
- },
195
- };
196
- }
197
- return { body: expected };
560
+ await t.step(
561
+ "records non-default ports for URL resources",
562
+ async () => {
563
+ fetchMock.removeRoutes();
564
+ fetchMock.get(
565
+ "https://example.com:8443/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%3A8443%2Ffoo",
566
+ { body: { subject: "https://example.com:8443/foo", links: [] } },
567
+ );
568
+ const [meterProvider, recorder] = createTestMeterProvider();
569
+ await lookupWebFinger("https://example.com:8443/foo", {
570
+ meterProvider,
571
+ });
572
+ const counter = recorder.getMeasurement("webfinger.lookup");
573
+ ok(counter != null);
574
+ deepStrictEqual(
575
+ counter.attributes["activitypub.remote.host"],
576
+ "example.com:8443",
577
+ );
198
578
  },
199
579
  );
200
580
 
201
- await t.step("custom maxRedirection", async () => {
202
- // Test with maxRedirection: 2 (should fail)
203
- redirectCount = 0;
581
+ await t.step("records result=not_found with status 404", async () => {
582
+ fetchMock.removeRoutes();
583
+ fetchMock.get(
584
+ "begin:https://example.com/.well-known/webfinger?",
585
+ { status: 404 },
586
+ );
587
+ const [meterProvider, recorder] = createTestMeterProvider();
588
+ const result = await lookupWebFinger("acct:johndoe@example.com", {
589
+ meterProvider,
590
+ });
591
+ deepStrictEqual(result, null);
592
+
593
+ const counters = recorder.getMeasurements("webfinger.lookup");
594
+ deepStrictEqual(counters.length, 1);
204
595
  deepStrictEqual(
205
- await lookupWebFinger("acct:johndoe@example.com", {
206
- maxRedirection: 2,
207
- }),
208
- null,
596
+ counters[0].attributes["webfinger.lookup.result"],
597
+ "not_found",
598
+ );
599
+ deepStrictEqual(
600
+ counters[0].attributes["http.response.status_code"],
601
+ 404,
602
+ );
603
+ deepStrictEqual(
604
+ counters[0].attributes["activitypub.remote.host"],
605
+ "example.com",
209
606
  );
210
607
 
211
- // Test with maxRedirection: 3 (should succeed)
212
- redirectCount = 0;
608
+ const durations = recorder.getMeasurements("webfinger.lookup.duration");
609
+ deepStrictEqual(durations.length, 1);
213
610
  deepStrictEqual(
214
- await lookupWebFinger("acct:johndoe@example.com", {
215
- maxRedirection: 3,
216
- }),
217
- expected,
611
+ durations[0].attributes["webfinger.lookup.result"],
612
+ "not_found",
218
613
  );
614
+ });
219
615
 
220
- // Test with default maxRedirection: 5 (should succeed)
221
- redirectCount = 0;
616
+ await t.step("records result=not_found with status 410", async () => {
617
+ fetchMock.removeRoutes();
618
+ fetchMock.get(
619
+ "begin:https://example.com/.well-known/webfinger?",
620
+ { status: 410 },
621
+ );
622
+ const [meterProvider, recorder] = createTestMeterProvider();
623
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
624
+ const counter = recorder.getMeasurement("webfinger.lookup");
625
+ ok(counter != null);
222
626
  deepStrictEqual(
223
- await lookupWebFinger("acct:johndoe@example.com"),
224
- expected,
627
+ counter.attributes["webfinger.lookup.result"],
628
+ "not_found",
225
629
  );
630
+ deepStrictEqual(counter.attributes["http.response.status_code"], 410);
226
631
  });
227
632
 
228
- fetchMock.removeRoutes();
229
- fetchMock.get(
230
- "begin:https://example.com/.well-known/webfinger?",
231
- () =>
232
- new Promise((resolve) => {
233
- const timeoutId = setTimeout(() => {
234
- resolve({ body: expected });
235
- }, 1000);
236
-
237
- return () => clearTimeout(timeoutId);
238
- }),
633
+ await t.step(
634
+ "records result=error for non-2xx, non-404/410 HTTP responses",
635
+ async () => {
636
+ fetchMock.removeRoutes();
637
+ fetchMock.get(
638
+ "begin:https://example.com/.well-known/webfinger?",
639
+ { status: 500 },
640
+ );
641
+ const [meterProvider, recorder] = createTestMeterProvider();
642
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
643
+ const counter = recorder.getMeasurement("webfinger.lookup");
644
+ ok(counter != null);
645
+ deepStrictEqual(
646
+ counter.attributes["webfinger.lookup.result"],
647
+ "error",
648
+ );
649
+ deepStrictEqual(counter.attributes["http.response.status_code"], 500);
650
+ },
239
651
  );
240
652
 
241
- await t.step("request cancellation", async () => {
242
- // Test cancelling a request immediately using AbortController
243
- const controller = new AbortController();
244
- const promise = lookupWebFinger("acct:johndoe@example.com", {
245
- signal: controller.signal,
246
- });
247
-
248
- // Abort the request right after starting it
249
- controller.abort();
250
- deepStrictEqual(await promise, null);
251
- });
252
-
253
- fetchMock.removeRoutes();
254
- let redirectCount2 = 0;
255
- fetchMock.get(
256
- "begin:https://example.com/.well-known/webfinger",
257
- () => {
258
- redirectCount2++;
259
- if (redirectCount2 === 1) {
260
- return {
261
- status: 302,
262
- headers: { Location: "/.well-known/webfinger2" },
263
- };
264
- }
265
- return new Promise((resolve) => {
266
- const timeoutId = setTimeout(() => {
267
- resolve({ body: expected });
268
- }, 1000);
269
-
270
- return () => clearTimeout(timeoutId);
271
- });
653
+ await t.step(
654
+ "records result=invalid for malformed JSON bodies",
655
+ async () => {
656
+ fetchMock.removeRoutes();
657
+ fetchMock.get(
658
+ "begin:https://example.com/.well-known/webfinger?",
659
+ { body: "not json" },
660
+ );
661
+ const [meterProvider, recorder] = createTestMeterProvider();
662
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
663
+ const counter = recorder.getMeasurement("webfinger.lookup");
664
+ ok(counter != null);
665
+ deepStrictEqual(
666
+ counter.attributes["webfinger.lookup.result"],
667
+ "invalid",
668
+ );
669
+ deepStrictEqual(counter.attributes["http.response.status_code"], 200);
272
670
  },
273
671
  );
274
672
 
275
- await t.step("cancellation during redirection", async () => {
276
- // Test cancelling a request during redirection process
277
- const controller = new AbortController();
278
- const promise = lookupWebFinger("acct:johndoe@example.com", {
279
- signal: controller.signal,
280
- });
673
+ await t.step(
674
+ "records result=network_error when fetch never reaches the remote",
675
+ async () => {
676
+ fetchMock.removeRoutes();
677
+ const [meterProvider, recorder] = createTestMeterProvider();
678
+ const result = await lookupWebFinger(
679
+ "acct:johndoe@fedify-test.internal",
680
+ { meterProvider },
681
+ );
682
+ deepStrictEqual(result, null);
683
+ const counter = recorder.getMeasurement("webfinger.lookup");
684
+ ok(counter != null);
685
+ deepStrictEqual(
686
+ counter.attributes["webfinger.lookup.result"],
687
+ "network_error",
688
+ );
689
+ deepStrictEqual(
690
+ "http.response.status_code" in counter.attributes,
691
+ false,
692
+ "no HTTP response means no status code attribute",
693
+ );
694
+ deepStrictEqual(
695
+ counter.attributes["activitypub.remote.host"],
696
+ "fedify-test.internal",
697
+ );
698
+ },
699
+ );
281
700
 
282
- // Cancel during the delayed second request after redirection
283
- setTimeout(() => controller.abort(), 100);
284
- deepStrictEqual(await promise, null);
285
- });
701
+ await t.step(
702
+ "records result=invalid for malformed acct: resources",
703
+ async () => {
704
+ const [meterProvider, recorder] = createTestMeterProvider();
705
+ const result = await lookupWebFinger("acct:johndoe", { meterProvider });
706
+ deepStrictEqual(result, null);
707
+ const counter = recorder.getMeasurement("webfinger.lookup");
708
+ ok(counter != null);
709
+ deepStrictEqual(
710
+ counter.attributes["webfinger.lookup.result"],
711
+ "invalid",
712
+ );
713
+ deepStrictEqual(
714
+ counter.attributes["webfinger.resource.scheme"],
715
+ "acct",
716
+ );
717
+ deepStrictEqual(
718
+ "activitypub.remote.host" in counter.attributes,
719
+ false,
720
+ "a malformed acct resource has no usable remote host",
721
+ );
722
+ },
723
+ );
286
724
 
287
- fetchMock.removeRoutes();
288
- fetchMock.get(
289
- "begin:https://example.com/.well-known/webfinger?",
290
- () =>
291
- new Promise((resolve) => {
292
- const timeoutId = setTimeout(() => {
293
- resolve({ body: expected });
294
- }, 500);
295
-
296
- return () => clearTimeout(timeoutId);
297
- }),
725
+ await t.step(
726
+ "records result=invalid when the redirect chain exceeds maxRedirection",
727
+ async () => {
728
+ fetchMock.removeRoutes();
729
+ // The redirect Location drops the original `?resource=...` query
730
+ // string, so the second hop's URL no longer contains a `?`. The
731
+ // route pattern omits the trailing `?` so it still matches.
732
+ fetchMock.get(
733
+ "begin:https://example.com/.well-known/webfinger",
734
+ {
735
+ status: 302,
736
+ headers: { Location: "/.well-known/webfinger" },
737
+ },
738
+ );
739
+ const [meterProvider, recorder] = createTestMeterProvider();
740
+ const result = await withTimeout(
741
+ () =>
742
+ lookupWebFinger("acct:johndoe@example.com", {
743
+ meterProvider,
744
+ maxRedirection: 3,
745
+ }),
746
+ 2000,
747
+ );
748
+ deepStrictEqual(result, null);
749
+ const counter = recorder.getMeasurement("webfinger.lookup");
750
+ ok(counter != null);
751
+ deepStrictEqual(
752
+ counter.attributes["webfinger.lookup.result"],
753
+ "invalid",
754
+ );
755
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
756
+ deepStrictEqual(
757
+ counter.attributes["activitypub.remote.host"],
758
+ "example.com",
759
+ );
760
+ },
298
761
  );
299
762
 
300
- await t.step("cancellation with immediate abort", async () => {
301
- // Test starting a request with an already aborted AbortController
302
- const controller = new AbortController();
303
- controller.abort();
763
+ await t.step(
764
+ "records result=invalid for cross-protocol redirects",
765
+ async () => {
766
+ fetchMock.removeRoutes();
767
+ fetchMock.get(
768
+ "begin:https://example.com/.well-known/webfinger?",
769
+ {
770
+ status: 302,
771
+ headers: { Location: "ftp://example.com/" },
772
+ },
773
+ );
774
+ const [meterProvider, recorder] = createTestMeterProvider();
775
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
776
+ const counter = recorder.getMeasurement("webfinger.lookup");
777
+ ok(counter != null);
778
+ deepStrictEqual(
779
+ counter.attributes["webfinger.lookup.result"],
780
+ "invalid",
781
+ );
782
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
783
+ },
784
+ );
304
785
 
305
- // Use a signal that was already aborted before starting the request
306
- const result = await lookupWebFinger("acct:johndoe@example.com", {
307
- signal: controller.signal,
308
- });
309
- deepStrictEqual(result, null);
310
- });
786
+ await t.step(
787
+ "records result=network_error when a redirect points to a private address",
788
+ async () => {
789
+ fetchMock.removeRoutes();
790
+ fetchMock.get(
791
+ "begin:https://example.com/.well-known/webfinger?",
792
+ {
793
+ status: 302,
794
+ headers: { Location: "https://localhost/" },
795
+ },
796
+ );
797
+ const [meterProvider, recorder] = createTestMeterProvider();
798
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
799
+ const counter = recorder.getMeasurement("webfinger.lookup");
800
+ ok(counter != null);
801
+ deepStrictEqual(
802
+ counter.attributes["webfinger.lookup.result"],
803
+ "network_error",
804
+ );
805
+ deepStrictEqual(
806
+ counter.attributes["activitypub.remote.host"],
807
+ "localhost",
808
+ "remote.host reflects the latest URL we attempted, even after a redirect",
809
+ );
810
+ },
811
+ );
311
812
 
312
- fetchMock.removeRoutes();
313
- fetchMock.get(
314
- "begin:https://example.com/.well-known/webfinger?",
315
- { body: expected },
813
+ await t.step(
814
+ "records result=invalid for malformed Location headers",
815
+ async () => {
816
+ fetchMock.removeRoutes();
817
+ fetchMock.get(
818
+ "begin:https://example.com/.well-known/webfinger?",
819
+ {
820
+ status: 302,
821
+ headers: { Location: "http://[bad" },
822
+ },
823
+ );
824
+ const [meterProvider, recorder] = createTestMeterProvider();
825
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
826
+ const counter = recorder.getMeasurement("webfinger.lookup");
827
+ ok(counter != null);
828
+ deepStrictEqual(
829
+ counter.attributes["webfinger.lookup.result"],
830
+ "invalid",
831
+ );
832
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
833
+ deepStrictEqual(
834
+ counter.attributes["activitypub.remote.host"],
835
+ "example.com",
836
+ );
837
+ },
316
838
  );
317
839
 
318
- await t.step("successful request with signal", async () => {
319
- // Test successful request with a normal AbortController signal
320
- const controller = new AbortController();
321
- const result = await lookupWebFinger("acct:johndoe@example.com", {
322
- signal: controller.signal,
323
- });
324
- deepStrictEqual(result, expected);
325
- });
840
+ await t.step(
841
+ "buckets unknown resource schemes as 'other' to keep metric cardinality bounded",
842
+ async () => {
843
+ // Lookups whose redirect chain ends on an unusual scheme (or a
844
+ // resource the caller passes with a non-fediverse scheme) must
845
+ // not leak that scheme into the metric attribute.
846
+ fetchMock.removeRoutes();
847
+ const [meterProvider, recorder] = createTestMeterProvider();
848
+ // `ssh:` is not a WebFinger scheme; lookupWebFingerInternal will
849
+ // attempt to build a host from the URL, fail, and return null.
850
+ // The metric still records, and its scheme attribute must be
851
+ // bucketed as `other`.
852
+ await lookupWebFinger("ssh://example.com/foo", { meterProvider });
853
+ const counter = recorder.getMeasurement("webfinger.lookup");
854
+ ok(counter != null);
855
+ deepStrictEqual(
856
+ counter.attributes["webfinger.resource.scheme"],
857
+ "other",
858
+ );
859
+ },
860
+ );
326
861
 
862
+ await t.step(
863
+ "omits measurements when no meterProvider is provided",
864
+ async () => {
865
+ fetchMock.removeRoutes();
866
+ fetchMock.get(
867
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
868
+ { body: expected },
869
+ );
870
+ const [_unused, recorder] = createTestMeterProvider();
871
+ await lookupWebFinger("acct:johndoe@example.com");
872
+ deepStrictEqual(
873
+ recorder.getMeasurements("webfinger.lookup").length,
874
+ 0,
875
+ );
876
+ deepStrictEqual(
877
+ recorder.getMeasurements("webfinger.lookup.duration").length,
878
+ 0,
879
+ );
880
+ },
881
+ );
882
+ } finally {
883
+ fetchMock.removeRoutes();
327
884
  fetchMock.hardReset();
328
- },
885
+ }
329
886
  });
330
887
 
331
888
  // cSpell: ignore johndoe