source: Klonkt/test/webfinger-bare-host.test.js@ 1d5ffc0

main
Last change on this file since 1d5ffc0 was 679924e, checked in by Bart <bart@…>, 5 weeks ago

WebFinger: de primaire site via de ene bron van waarheid

Een verse instance heeft geen primaire site: is_primary is 0 by default en de
backfill draait alleen op het moment dat de kolom erbij komt. Een site die
daarna wordt aangemaakt laat de instance dus zonder vlag achter.

De HTML-kant merkte daar niets van, want getPrimarySite() valt terug op de
oudste site. Deze route hield zijn eigen is_primary-only kopie aan, precies het
verspreide gedrag dat middleware/site.js zegt te hebben opgeruimd. Dus / gaf de
site en WebFinger gaf 404, uit dezelfde database, in hetzelfde verzoek.

Gevonden op instance loop (🩵.is.wildenvrij.nl): één site, slug "mee",
is_primary 0. De andere instances kunnen in dezelfde staat staan.

De nieuwe test zet alle vlaggen op 0 en eist dat een kale host dan nog steeds
de oudste site vindt.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 6.0 KB
Line 
1// One Ward, however you spell its address.
2//
3// Shaer never asks for a URL. You type a handle, and its `Handle` parser turns a
4// bare host into `acct:<host>@<host>` — the WebFinger convention for "give me
5// this server's primary actor". WebFinger here only ever looked the user part up
6// as a site slug, so that resource 404'd and the app could not find a Ward it was
7// pointed straight at.
8//
9// The emoji host makes the second half of the problem visible. Foundation's URL
10// (and Node's, and every browser's) silently punycodes a host, so a pasted
11// `https://🩵.is.wildenvrij.nl` arrives as `xn--zz9h.is.wildenvrij.nl` while a
12// typed `🩵.is.wildenvrij.nl` arrives verbatim. Same Ward, two spellings, and a
13// byte comparison says they are strangers.
14import { test } from 'node:test';
15import assert from 'node:assert/strict';
16
17process.env.DATABASE_PATH = ':memory:';
18process.env.PUBLIC_BASE_URL = 'https://test.example';
19
20const dbMod = await import('../src/config/database.js');
21const db = dbMod.default;
22dbMod.initializeDatabase();
23const express = (await import('express')).default;
24const routes = (await import('../src/routes/activitypub.js')).default;
25
26db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
27 .run('u1', 'u1', 'u1@t', 'x', 'god');
28// `kid` is the primary site; `oma` is a second public site that must NOT be
29// what a bare host resolves to.
30// Explicit created_at: getPrimarySite() falls back to the OLDEST site, and two
31// rows inserted in the same second would make that order a coin flip.
32db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
33 .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
34db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,0,?)')
35 .run('s2', 'oma', 'oma', 'u1', '2026-06-01 00:00:00');
36
37const app = express();
38app.use(routes);
39const server = app.listen(0);
40await new Promise((r) => server.once('listening', r));
41const port = server.address().port;
42test.after(() => server.close());
43
44/// Ask WebFinger for a resource while the server believes it is served at `base`.
45async function finger(resource, base = 'https://test.example') {
46 const previous = process.env.PUBLIC_BASE_URL;
47 process.env.PUBLIC_BASE_URL = base;
48 try {
49 const url = `http://127.0.0.1:${port}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
50 const res = await fetch(url);
51 return { status: res.status, body: res.status === 200 ? await res.json() : null };
52 } finally {
53 process.env.PUBLIC_BASE_URL = previous;
54 }
55}
56
57/// The `self` link is the actor the client will actually fetch next.
58const actorOf = (body) => body.links.find((l) => l.rel === 'self').href;
59
60test('a normal handle still resolves (the case that already worked)', async () => {
61 const { status, body } = await finger('acct:oma@test.example');
62 assert.equal(status, 200);
63 assert.equal(actorOf(body), 'https://test.example/ap/users/oma', 'a named slug wins over the primary fallback');
64});
65
66test('a bare host resolves the primary actor', async () => {
67 // The whole bug: this is what Shaer sends when you type `test.example`.
68 const { status, body } = await finger('acct:test.example@test.example');
69 assert.equal(status, 200, 'the bare host is a valid address, not a 404');
70 assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'and it means the PRIMARY site, not just any site');
71});
72
73test('unicode and punycode spellings of one host find one Ward', async () => {
74 const unicode = 'https://🩵.is.wildenvrij.nl';
75 const punycode = 'https://xn--zz9h.is.wildenvrij.nl';
76
77 const typed = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', unicode);
78 const pasted = await finger('acct:xn--zz9h.is.wildenvrij.nl@xn--zz9h.is.wildenvrij.nl', unicode);
79
80 assert.equal(typed.status, 200, 'typed by hand: the emoji host');
81 assert.equal(pasted.status, 200, 'pasted as a URL: the client already punycoded it');
82 assert.deepEqual(actorOf(typed.body), actorOf(pasted.body), 'both spellings are the same Ward');
83
84 // And it does not matter which spelling the server itself is configured with.
85 const configuredAscii = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', punycode);
86 assert.equal(configuredAscii.status, 200, 'PUBLIC_BASE_URL may be written either way too');
87
88 assert.equal(typed.body.subject, 'acct:kid@xn--zz9h.is.wildenvrij.nl',
89 'the subject we answer with is the canonical one, never the alias that was asked for');
90});
91
92test('a bare host resolves even when no site carries the primary flag', async () => {
93 // This is the state a fresh instance is actually in: is_primary defaults to 0
94 // and the backfill only runs when the column is first added, so a site created
95 // afterwards leaves the instance with no primary at all. The HTML side coped
96 // (getPrimarySite falls back to the oldest) while this route kept its own
97 // is_primary-only lookup — so / served the site and WebFinger said 404.
98 db.prepare('UPDATE sites SET is_primary = 0').run();
99 try {
100 const { status, body } = await finger('acct:test.example@test.example');
101 assert.equal(status, 200, 'an unflagged instance is still discoverable');
102 assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'falls back to the oldest site');
103 } finally {
104 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run('s1');
105 }
106});
107
108test('an unknown user is still a 404', async () => {
109 // The fallback must not turn every miss into the primary actor, or a typo
110 // silently connects a child to the wrong account.
111 const { status } = await finger('acct:nobody@test.example');
112 assert.equal(status, 404);
113});
114
115test('a bare host that is not ours is still a 404', async () => {
116 const { status } = await finger('acct:elders.example@elders.example');
117 assert.equal(status, 404, 'we only answer for the host we are actually serving');
118});
119
120test('a malformed resource is a 400', async () => {
121 const { status } = await finger('https://test.example/ap/users/kid');
122 assert.equal(status, 400);
123});
Note: See TracBrowser for help on using the repository browser.