source: Klonkt/test/thread.test.js@ aae5881

main
Last change on this file since aae5881 was aae5881, checked in by Robin <roboburr@…>, 4 weeks ago

De thread-melding wijst nu naar de juiste partij

Bart opende een post van boiert.eu en las "Replies could not be loaded from
your server" terwijl onze server het prima deed: de BRON weigerde. Het was een
friends-only post van een account dat hij 's ochtends nog volgde en nu niet
meer, en Klonkt geeft een niet-publieke note alleen aan followers -- boiert
weigerde dus terecht, met 404, ook op het ondertekende verzoek.

signedGetJson slikte die status ("if (!r.ok) return null"), dus getThread wist
niet WAAROM het misging en de route kon het niet zeggen. Nu geeft hij de status
door aan wie erom vraagt, en getThread draagt hem als sourceStatus.

De route splitst daarop: 401/403/404/410 is een besluit van die server (niet
gedeeld, of weg) en blijft 404 'not shared by source'; al het andere, inclusief
een status die we niet eens kregen, wordt 502 'source unreachable'. Zonder dat
onderscheid vervang je een verkeerde schuldige door een andere gok.

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

  • Property mode set to 100644
File size: 12.1 KB
Line 
1// De thread onder een post (shaer-tqz): ophalen, normaliseren, niet bewaren.
2//
3// De vier dingen die hier stil kunnen breken en die een gebruiker allemaal
4// anders voelt: een antwoord dat niet doorkomt (te streng), een vreemde die
5// bij een ward doorkomt (te los), HTML die ongeschoond doorreist (gevaarlijk),
6// en een geblokkeerde die meegeteld wordt in shaer:hidden (een blokkade hoort
7// onzichtbaar te zijn, ook als getal).
8//
9// In-memory SQLite, gestubde fetch op TEST-NET-3. Run: npm test
10
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13
14process.env.DATABASE_PATH = ':memory:';
15process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = await import('../src/services/ActivityPubService.js');
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@test', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
25
26const BRON = 'https://203.0.113.10/notes/post-1';
27const TANTE = 'https://203.0.113.20/users/tante';
28const VREEMDE = 'https://203.0.113.30/users/vreemde';
29const GEBLOKT = 'https://203.0.113.40/users/naar';
30
31// De tante is gevolgd (accepted): zij zit in de kring van de guardians.
32db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', ?, 'accepted')").run(TANTE);
33db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('kind', 'actor', ?)").run(GEBLOKT);
34
35const actorDoc = (id, naam) => ({ id, type: 'Person', preferredUsername: naam, name: naam, inbox: `${id}/inbox` });
36const reply = (n, actor, content, published) => ({
37 id: `${BRON}/replies/${n}`, type: 'Note', attributedTo: actor,
38 inReplyTo: BRON, content, published,
39});
40
41const antwoorden = [
42 reply(1, VREEMDE, '<p>Hoi! <script>alert(1)</script></p>', '2026-08-01T10:00:00Z'),
43 { ...reply(2, TANTE, '<p>Dag lieverd :hartje:</p>', '2026-08-01T09:00:00Z'),
44 // FEP-9098 zoals Mastodon hem stuurt, plus een niet-Emoji-tag en een
45 // emoji zonder bruikbaar icoon: alleen de echte hoort erdoor te komen.
46 tag: [
47 { type: 'Emoji', name: ':hartje:', icon: { type: 'Image', url: 'https://203.0.113.20/emoji/hartje.png' } },
48 { type: 'Emoji', name: ':kapot:', icon: {} },
49 { type: 'Hashtag', name: '#muziek' },
50 ] },
51 reply(3, GEBLOKT, '<p>naar bericht</p>', '2026-08-01T11:00:00Z'),
52];
53
54// De echte fetch, voordat de stub hem overneemt: de route-test onderaan doet
55// een ECHT verzoek aan een testserver op localhost, en dat mag de stub niet
56// beantwoorden met zijn 404.
57const echteFetch = globalThis.fetch;
58
59globalThis.fetch = async (url) => {
60 const u = String(url);
61 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
62 if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: '<p>de post</p>', replies: `${BRON}/replies` });
63 if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` });
64 if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden });
65 if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
66 if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde'));
67 if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar'));
68 return new Response('not found', { status: 404 });
69};
70
71test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
72 const uit = await AP.getThread('kind', BRON);
73 assert.equal(uit.found, true);
74 assert.equal(uit.notes.length, 2);
75 // Oudste eerst: een gesprek lees je van boven naar beneden.
76 assert.equal(uit.notes[0].attributedTo.name, 'tante');
77 assert.equal(uit.notes[1].attributedTo.name, 'vreemde');
78 // De sanitizer heeft het script eruit gehaald, de tekst mag blijven.
79 assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
80 assert.ok(uit.notes[1].content.includes('Hoi!'));
81 // Een blokkade is onzichtbaar: niet in de lijst en NIET in een telling --
82 // getThread telt sindsdien niets meer, dat doet de kringfilter in de route.
83 assert.equal(uit.hidden, undefined);
84 assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
85 // FEP-9098: alleen de echte emoji komt door -- niet de hashtag, niet de
86 // emoji zonder icoon -- en het icoon-adres is geschoond.
87 const tags = uit.notes[0].tag;
88 assert.equal(tags.length, 1);
89 assert.equal(tags[0].name, ':hartje:');
90 assert.equal(tags[0].icon.url, 'https://203.0.113.20/emoji/hartje.png');
91 assert.ok(!uit.notes[1].tag, 'een antwoord zonder emoji draagt geen tag-veld');
92});
93
94test('de kringfilter houdt goedgekeurd volk en telt de rest (de dichte stand van de gate)', async () => {
95 // Sinds de gate (shaer-9y2) zit de kring in de ROUTE, per verzoek en buiten
96 // de threadcache om -- een poort die net dichtging mag niet twee minuten
97 // open nawerken. Hier de functie zelf.
98 db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
99 db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
100 const uit = await AP.getThread('kind', BRON);
101 const kring = AP.filterThreadToCircle('pupil', uit.notes);
102 assert.equal(kring.notes.length, 1);
103 // Ook de zeef zelf leest de ingesloten actor: een kale vergelijking op
104 // attributedTo zou hier stil ALLES wegfilteren.
105 assert.equal(kring.notes[0].attributedTo.name, 'tante');
106 // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
107 assert.equal(kring.hidden, 1);
108});
109
110test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
111 const uit = await AP.getThread('kind', 'https://203.0.113.99/weg');
112 assert.equal(uit.found, false);
113 assert.equal(uit.notes.length, 0);
114});
115
116test('de Mastodon-vorm: first is een lege inline-pagina, de antwoorden staan op next', async () => {
117 // Precies wat Mastodon serveert en wat Barts melding (8-8) verklaarde: wie
118 // alleen de eerste pagina leest, ziet op elke Mastodon-post een leeg gesprek.
119 const MPOST = 'https://203.0.113.50/notes/masto-1';
120 const vorige = globalThis.fetch;
121 globalThis.fetch = async (url) => {
122 const u = String(url);
123 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
124 if (u === MPOST) return json({
125 id: MPOST, type: 'Note', attributedTo: TANTE, content: '<p>toot</p>',
126 replies: {
127 id: `${MPOST}/replies`, type: 'Collection',
128 first: { type: 'CollectionPage', items: [], next: `${MPOST}/replies?page=true` },
129 },
130 });
131 if (u === `${MPOST}/replies?page=true`) return json({
132 type: 'CollectionPage',
133 items: [{ id: `${MPOST}/r1`, type: 'Note', attributedTo: TANTE, inReplyTo: MPOST, content: '<p>eerste echte antwoord</p>', published: '2026-08-02T10:00:00Z' }],
134 });
135 return vorige(url);
136 };
137 const uit = await AP.getThread('kind', MPOST);
138 globalThis.fetch = vorige;
139 assert.equal(uit.notes.length, 1, 'het antwoord op de next-pagina is gevonden');
140 assert.ok(uit.notes[0].content.includes('eerste echte antwoord'));
141});
142
143test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => {
144 let calls = 0;
145 const vorige = globalThis.fetch;
146 globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
147 const uit = await AP.getThread('kind', BRON);
148 assert.equal(uit.notes.length, 2);
149 assert.equal(calls, 0, 'alles uit de cache, nul fetches');
150 globalThis.fetch = vorige;
151});
152
153// ── Door de route heen, want daar wordt de vorm beslist ──────────────
154//
155// De byline van een antwoord verhuisde naar de ingesloten attributedTo
156// (shaer-nmw). Deze leg had geen route-dekking, en juist hier zit de
157// emoji-poort die IN die byline knipt -- knipt hij in het oude veld, dan
158// gebeurt er stil niets meer.
159test('de thread-route geeft de byline in attributedTo, en de emoji-poort knipt daarin', async (t) => {
160 const crypto = await import('crypto');
161 const express = (await import('express')).default;
162 const routes = (await import('../src/routes/activitypub.js')).default;
163
164 const bearer = 'test-token-' + 'c'.repeat(24);
165 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
166 .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
167
168 const app = express();
169 app.use(routes);
170 const server = app.listen(0);
171 t.after(() => server.close());
172 await new Promise((r) => server.once('listening', r));
173 const haal = async () => (await (await echteFetch(
174 `http://127.0.0.1:${server.address().port}/ap/users/kind/thread?object=${encodeURIComponent(BRON)}`,
175 { headers: { Authorization: `Bearer ${bearer}` } })).json());
176
177 const doc = await haal();
178 assert.ok(!JSON.stringify(doc).includes('"shaer:author":'), 'geen dialect meer op deze leg');
179 const vanTante = doc.orderedItems.find((n) => n.id.endsWith('/replies/2'));
180 assert.equal(vanTante.attributedTo.name, 'tante', 'de byline zit in attributedTo');
181 assert.equal(vanTante.attributedTo.preferredUsername, 'tante');
182 assert.ok((vanTante.tag || []).some((x) => x.type === 'Emoji'), 'open poort: de emoji van het antwoord blijft');
183
184 // Poort dicht: FEP-9098 zit in de tag van de ingesloten actor, dus daar
185 // hoort geknipt te worden -- en het antwoord zelf houdt zijn eigen emoji
186 // niet meer over.
187 db.prepare("UPDATE sites SET gate_custom_emoji = 0 WHERE slug = 'kind'").run();
188 db.prepare("INSERT INTO ap_guardianships (slug, other_uri, role, status) VALUES ('kind', ?, 'guardian', 'accepted')")
189 .run('https://203.0.113.20/users/tante');
190 const dicht = await haal();
191 const na = dicht.orderedItems.find((n) => n.id.endsWith('/replies/2'));
192 assert.ok(!(na.tag || []).some((x) => x.type === 'Emoji'), 'dichte poort: geen emoji meer');
193 assert.ok(!na.attributedTo.tag, 'ook niet in de byline');
194});
195
196// ── Wiens schuld is het? ─────────────────────────────────────────────
197//
198// "Replies could not be loaded from your server" wees naar de verkeerde
199// partij: onze server deed het prima en de BRON weigerde. Een weigering
200// (401/403/404/410 -- niet gedeeld, of weg) is iets anders dan een storing, en
201// alleen de server weet welke van de twee het was.
202test('een weigering van de bron is 404, een storing is 502', async (t) => {
203 const crypto = await import('crypto');
204 const express = (await import('express')).default;
205 const routes = (await import('../src/routes/activitypub.js')).default;
206
207 const bearer = 'test-token-' + 'd'.repeat(24);
208 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
209 .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
210
211 const app = express();
212 app.use(routes);
213 const server = app.listen(0);
214 t.after(() => server.close());
215 await new Promise((r) => server.once('listening', r));
216
217 const vorige = globalThis.fetch;
218 t.after(() => { globalThis.fetch = vorige; });
219 const vraag = async (uri) => {
220 const r = await echteFetch(
221 `http://127.0.0.1:${server.address().port}/ap/users/kind/thread?object=${encodeURIComponent(uri)}`,
222 { headers: { Authorization: `Bearer ${bearer}` } });
223 return { status: r.status, body: await r.json() };
224 };
225
226 // De bron zegt nee -- precies wat boiert.eu doet met een friends-only post
227 // voor iemand die de auteur niet (meer) volgt.
228 const DICHT = 'https://203.0.113.50/notes/dicht';
229 globalThis.fetch = async () => new Response('nope', { status: 404 });
230 const nee = await vraag(DICHT);
231 assert.equal(nee.status, 404);
232 assert.equal(nee.body.error, 'not shared by source');
233 assert.equal(nee.body.sourceStatus, 404);
234
235 // De bron ligt eruit. Dezelfde lege uitkomst, een andere waarheid.
236 const STUK = 'https://203.0.113.51/notes/stuk';
237 globalThis.fetch = async () => new Response('boem', { status: 500 });
238 const stuk = await vraag(STUK);
239 assert.equal(stuk.status, 502);
240 assert.equal(stuk.body.error, 'source unreachable');
241});
Note: See TracBrowser for help on using the repository browser.