@fedify/webfinger 2.3.0-dev.1137 → 2.3.0-dev.1145

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,702 @@ 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/" },
176
- },
177
- );
235
+ await t.step("redirection to different protocol", async () => {
236
+ deepStrictEqual(
237
+ await lookupWebFinger("acct:johndoe@example.com"),
238
+ null,
239
+ );
240
+ });
178
241
 
179
- await t.step("redirection to private address", async () => {
180
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
181
- });
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
+ );
182
250
 
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}`,
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 };
194
360
  },
195
- };
196
- }
197
- return { body: expected };
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);
534
+ },
535
+ );
536
+
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
+ );
198
557
  },
199
558
  );
200
559
 
201
- await t.step("custom maxRedirection", async () => {
202
- // Test with maxRedirection: 2 (should fail)
203
- redirectCount = 0;
560
+ await t.step("records result=not_found with status 404", async () => {
561
+ fetchMock.removeRoutes();
562
+ fetchMock.get(
563
+ "begin:https://example.com/.well-known/webfinger?",
564
+ { status: 404 },
565
+ );
566
+ const [meterProvider, recorder] = createTestMeterProvider();
567
+ const result = await lookupWebFinger("acct:johndoe@example.com", {
568
+ meterProvider,
569
+ });
570
+ deepStrictEqual(result, null);
571
+
572
+ const counters = recorder.getMeasurements("webfinger.lookup");
573
+ deepStrictEqual(counters.length, 1);
204
574
  deepStrictEqual(
205
- await lookupWebFinger("acct:johndoe@example.com", {
206
- maxRedirection: 2,
207
- }),
208
- null,
575
+ counters[0].attributes["webfinger.lookup.result"],
576
+ "not_found",
577
+ );
578
+ deepStrictEqual(
579
+ counters[0].attributes["http.response.status_code"],
580
+ 404,
581
+ );
582
+ deepStrictEqual(
583
+ counters[0].attributes["activitypub.remote.host"],
584
+ "example.com",
209
585
  );
210
586
 
211
- // Test with maxRedirection: 3 (should succeed)
212
- redirectCount = 0;
587
+ const durations = recorder.getMeasurements("webfinger.lookup.duration");
588
+ deepStrictEqual(durations.length, 1);
213
589
  deepStrictEqual(
214
- await lookupWebFinger("acct:johndoe@example.com", {
215
- maxRedirection: 3,
216
- }),
217
- expected,
590
+ durations[0].attributes["webfinger.lookup.result"],
591
+ "not_found",
218
592
  );
593
+ });
219
594
 
220
- // Test with default maxRedirection: 5 (should succeed)
221
- redirectCount = 0;
595
+ await t.step("records result=not_found with status 410", async () => {
596
+ fetchMock.removeRoutes();
597
+ fetchMock.get(
598
+ "begin:https://example.com/.well-known/webfinger?",
599
+ { status: 410 },
600
+ );
601
+ const [meterProvider, recorder] = createTestMeterProvider();
602
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
603
+ const counter = recorder.getMeasurement("webfinger.lookup");
604
+ ok(counter != null);
222
605
  deepStrictEqual(
223
- await lookupWebFinger("acct:johndoe@example.com"),
224
- expected,
606
+ counter.attributes["webfinger.lookup.result"],
607
+ "not_found",
225
608
  );
609
+ deepStrictEqual(counter.attributes["http.response.status_code"], 410);
226
610
  });
227
611
 
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
- }),
612
+ await t.step(
613
+ "records result=error for non-2xx, non-404/410 HTTP responses",
614
+ async () => {
615
+ fetchMock.removeRoutes();
616
+ fetchMock.get(
617
+ "begin:https://example.com/.well-known/webfinger?",
618
+ { status: 500 },
619
+ );
620
+ const [meterProvider, recorder] = createTestMeterProvider();
621
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
622
+ const counter = recorder.getMeasurement("webfinger.lookup");
623
+ ok(counter != null);
624
+ deepStrictEqual(
625
+ counter.attributes["webfinger.lookup.result"],
626
+ "error",
627
+ );
628
+ deepStrictEqual(counter.attributes["http.response.status_code"], 500);
629
+ },
239
630
  );
240
631
 
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
- });
632
+ await t.step(
633
+ "records result=invalid for malformed JSON bodies",
634
+ async () => {
635
+ fetchMock.removeRoutes();
636
+ fetchMock.get(
637
+ "begin:https://example.com/.well-known/webfinger?",
638
+ { body: "not json" },
639
+ );
640
+ const [meterProvider, recorder] = createTestMeterProvider();
641
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
642
+ const counter = recorder.getMeasurement("webfinger.lookup");
643
+ ok(counter != null);
644
+ deepStrictEqual(
645
+ counter.attributes["webfinger.lookup.result"],
646
+ "invalid",
647
+ );
648
+ deepStrictEqual(counter.attributes["http.response.status_code"], 200);
272
649
  },
273
650
  );
274
651
 
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
- });
652
+ await t.step(
653
+ "records result=network_error when fetch never reaches the remote",
654
+ async () => {
655
+ fetchMock.removeRoutes();
656
+ const [meterProvider, recorder] = createTestMeterProvider();
657
+ const result = await lookupWebFinger(
658
+ "acct:johndoe@fedify-test.internal",
659
+ { meterProvider },
660
+ );
661
+ deepStrictEqual(result, null);
662
+ const counter = recorder.getMeasurement("webfinger.lookup");
663
+ ok(counter != null);
664
+ deepStrictEqual(
665
+ counter.attributes["webfinger.lookup.result"],
666
+ "network_error",
667
+ );
668
+ deepStrictEqual(
669
+ "http.response.status_code" in counter.attributes,
670
+ false,
671
+ "no HTTP response means no status code attribute",
672
+ );
673
+ deepStrictEqual(
674
+ counter.attributes["activitypub.remote.host"],
675
+ "fedify-test.internal",
676
+ );
677
+ },
678
+ );
281
679
 
282
- // Cancel during the delayed second request after redirection
283
- setTimeout(() => controller.abort(), 100);
284
- deepStrictEqual(await promise, null);
285
- });
680
+ await t.step(
681
+ "records result=invalid for malformed acct: resources",
682
+ async () => {
683
+ const [meterProvider, recorder] = createTestMeterProvider();
684
+ const result = await lookupWebFinger("acct:johndoe", { meterProvider });
685
+ deepStrictEqual(result, null);
686
+ const counter = recorder.getMeasurement("webfinger.lookup");
687
+ ok(counter != null);
688
+ deepStrictEqual(
689
+ counter.attributes["webfinger.lookup.result"],
690
+ "invalid",
691
+ );
692
+ deepStrictEqual(
693
+ counter.attributes["webfinger.resource.scheme"],
694
+ "acct",
695
+ );
696
+ deepStrictEqual(
697
+ "activitypub.remote.host" in counter.attributes,
698
+ false,
699
+ "a malformed acct resource has no usable remote host",
700
+ );
701
+ },
702
+ );
286
703
 
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
- }),
704
+ await t.step(
705
+ "records result=invalid when the redirect chain exceeds maxRedirection",
706
+ async () => {
707
+ fetchMock.removeRoutes();
708
+ // The redirect Location drops the original `?resource=...` query
709
+ // string, so the second hop's URL no longer contains a `?`. The
710
+ // route pattern omits the trailing `?` so it still matches.
711
+ fetchMock.get(
712
+ "begin:https://example.com/.well-known/webfinger",
713
+ {
714
+ status: 302,
715
+ headers: { Location: "/.well-known/webfinger" },
716
+ },
717
+ );
718
+ const [meterProvider, recorder] = createTestMeterProvider();
719
+ const result = await withTimeout(
720
+ () =>
721
+ lookupWebFinger("acct:johndoe@example.com", {
722
+ meterProvider,
723
+ maxRedirection: 3,
724
+ }),
725
+ 2000,
726
+ );
727
+ deepStrictEqual(result, null);
728
+ const counter = recorder.getMeasurement("webfinger.lookup");
729
+ ok(counter != null);
730
+ deepStrictEqual(
731
+ counter.attributes["webfinger.lookup.result"],
732
+ "invalid",
733
+ );
734
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
735
+ deepStrictEqual(
736
+ counter.attributes["activitypub.remote.host"],
737
+ "example.com",
738
+ );
739
+ },
298
740
  );
299
741
 
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();
742
+ await t.step(
743
+ "records result=invalid for cross-protocol redirects",
744
+ async () => {
745
+ fetchMock.removeRoutes();
746
+ fetchMock.get(
747
+ "begin:https://example.com/.well-known/webfinger?",
748
+ {
749
+ status: 302,
750
+ headers: { Location: "ftp://example.com/" },
751
+ },
752
+ );
753
+ const [meterProvider, recorder] = createTestMeterProvider();
754
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
755
+ const counter = recorder.getMeasurement("webfinger.lookup");
756
+ ok(counter != null);
757
+ deepStrictEqual(
758
+ counter.attributes["webfinger.lookup.result"],
759
+ "invalid",
760
+ );
761
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
762
+ },
763
+ );
304
764
 
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
- });
765
+ await t.step(
766
+ "records result=network_error when a redirect points to a private address",
767
+ async () => {
768
+ fetchMock.removeRoutes();
769
+ fetchMock.get(
770
+ "begin:https://example.com/.well-known/webfinger?",
771
+ {
772
+ status: 302,
773
+ headers: { Location: "https://localhost/" },
774
+ },
775
+ );
776
+ const [meterProvider, recorder] = createTestMeterProvider();
777
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
778
+ const counter = recorder.getMeasurement("webfinger.lookup");
779
+ ok(counter != null);
780
+ deepStrictEqual(
781
+ counter.attributes["webfinger.lookup.result"],
782
+ "network_error",
783
+ );
784
+ deepStrictEqual(
785
+ counter.attributes["activitypub.remote.host"],
786
+ "localhost",
787
+ "remote.host reflects the latest URL we attempted, even after a redirect",
788
+ );
789
+ },
790
+ );
311
791
 
312
- fetchMock.removeRoutes();
313
- fetchMock.get(
314
- "begin:https://example.com/.well-known/webfinger?",
315
- { body: expected },
792
+ await t.step(
793
+ "records result=invalid for malformed Location headers",
794
+ async () => {
795
+ fetchMock.removeRoutes();
796
+ fetchMock.get(
797
+ "begin:https://example.com/.well-known/webfinger?",
798
+ {
799
+ status: 302,
800
+ headers: { Location: "http://[bad" },
801
+ },
802
+ );
803
+ const [meterProvider, recorder] = createTestMeterProvider();
804
+ await lookupWebFinger("acct:johndoe@example.com", { meterProvider });
805
+ const counter = recorder.getMeasurement("webfinger.lookup");
806
+ ok(counter != null);
807
+ deepStrictEqual(
808
+ counter.attributes["webfinger.lookup.result"],
809
+ "invalid",
810
+ );
811
+ deepStrictEqual(counter.attributes["http.response.status_code"], 302);
812
+ deepStrictEqual(
813
+ counter.attributes["activitypub.remote.host"],
814
+ "example.com",
815
+ );
816
+ },
316
817
  );
317
818
 
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
- });
819
+ await t.step(
820
+ "buckets unknown resource schemes as 'other' to keep metric cardinality bounded",
821
+ async () => {
822
+ // Lookups whose redirect chain ends on an unusual scheme (or a
823
+ // resource the caller passes with a non-fediverse scheme) must
824
+ // not leak that scheme into the metric attribute.
825
+ fetchMock.removeRoutes();
826
+ const [meterProvider, recorder] = createTestMeterProvider();
827
+ // `ssh:` is not a WebFinger scheme; lookupWebFingerInternal will
828
+ // attempt to build a host from the URL, fail, and return null.
829
+ // The metric still records, and its scheme attribute must be
830
+ // bucketed as `other`.
831
+ await lookupWebFinger("ssh://example.com/foo", { meterProvider });
832
+ const counter = recorder.getMeasurement("webfinger.lookup");
833
+ ok(counter != null);
834
+ deepStrictEqual(
835
+ counter.attributes["webfinger.resource.scheme"],
836
+ "other",
837
+ );
838
+ },
839
+ );
326
840
 
841
+ await t.step(
842
+ "omits measurements when no meterProvider is provided",
843
+ async () => {
844
+ fetchMock.removeRoutes();
845
+ fetchMock.get(
846
+ "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com",
847
+ { body: expected },
848
+ );
849
+ const [_unused, recorder] = createTestMeterProvider();
850
+ await lookupWebFinger("acct:johndoe@example.com");
851
+ deepStrictEqual(
852
+ recorder.getMeasurements("webfinger.lookup").length,
853
+ 0,
854
+ );
855
+ deepStrictEqual(
856
+ recorder.getMeasurements("webfinger.lookup.duration").length,
857
+ 0,
858
+ );
859
+ },
860
+ );
861
+ } finally {
862
+ fetchMock.removeRoutes();
327
863
  fetchMock.hardReset();
328
- },
864
+ }
329
865
  });
330
866
 
331
867
  // cSpell: ignore johndoe