source: Klonkt/test/thread.test.js@ 61d52ed

main
Last change on this file since 61d52ed was 61d52ed, checked in by Bart <bart@…>, 7 hours ago

Eén note ophalen, voor wie een gesprek naar boven volgt

De thread-route (shaer-tqz) geeft wat ONDER een post hangt. Een lezer die een
antwoord kreeg en de ouder niet, kon die ouder bij een server in secure mode
niet halen: daar telt alleen een ondertekend verzoek, en de sleutel woont hier.
R9999 haalt ouders voortaan op zodra een antwoord binnenkomt, en heeft daar
deze route voor nodig -- zonder anonieme terugval, om dezelfde reden als bij de
thread.

GET /ap/users/:slug/object?uri= -- bearer-only en niets bewaren, dezelfde
weigering-of-storing (404/502) en dezelfde poorten als de thread. Een
geblokkeerde auteur is er niet. Voor een ward met een dichte kring is een note
van buiten de kring een 404 'outside the circle', met shaer:hidden erbij in
plaats van verzwegen.

inReplyTo alleen als de bron hem gaf. De thread vult een ontbrekende inReplyTo
aan met de post waaronder het antwoord in de replies-collectie stond, en daar is
dat bewijs. Voor een losse note is er geen bewijs, en een verzonnen ouder is
erger dan geen.

De vorm van één note (readerNote) en de poorten (readerGates) zijn uit de
thread getild, zodat de twee niet uit elkaar kunnen lopen. De thread-route
gedraagt zich hetzelfde.

Tests: getObject (de ouder van de bron, geen verzonnen ouder voor een wortel,
blokkade, weigering) en de route (zonder bearer, andermans bearer, alleen
https, weigering is 404).

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

  • Property mode set to 100644
File size: 17.1 KB
RevLine 
[03583f6]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'),
[9c9a3d9]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 ] },
[03583f6]51 reply(3, GEBLOKT, '<p>naar bericht</p>', '2026-08-01T11:00:00Z'),
52];
53
[75f2897]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.
[3bbf73d]57const realFetch = globalThis.fetch;
[75f2897]58
[03583f6]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 () => {
[3b43e4c]72 const uit = await AP.getThread('kind', BRON);
[03583f6]73 assert.equal(uit.found, true);
74 assert.equal(uit.notes.length, 2);
75 // Oudste eerst: een gesprek lees je van boven naar beneden.
[75f2897]76 assert.equal(uit.notes[0].attributedTo.name, 'tante');
77 assert.equal(uit.notes[1].attributedTo.name, 'vreemde');
[03583f6]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!'));
[3b43e4c]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);
[03583f6]84 assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
[9c9a3d9]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');
[03583f6]92});
93
[3b43e4c]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.
[03583f6]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);
[3b43e4c]100 const uit = await AP.getThread('kind', BRON);
101 const kring = AP.filterThreadToCircle('pupil', uit.notes);
102 assert.equal(kring.notes.length, 1);
[75f2897]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');
[03583f6]106 // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
[3b43e4c]107 assert.equal(kring.hidden, 1);
[03583f6]108});
109
110test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
[3b43e4c]111 const uit = await AP.getThread('kind', 'https://203.0.113.99/weg');
[03583f6]112 assert.equal(uit.found, false);
113 assert.equal(uit.notes.length, 0);
114});
115
[c4b24b3]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 };
[3b43e4c]137 const uit = await AP.getThread('kind', MPOST);
[c4b24b3]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
[03583f6]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); };
[3b43e4c]147 const uit = await AP.getThread('kind', BRON);
[03583f6]148 assert.equal(uit.notes.length, 2);
149 assert.equal(calls, 0, 'alles uit de cache, nul fetches');
150 globalThis.fetch = vorige;
151});
[75f2897]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));
[3bbf73d]173 const get = async () => (await (await realFetch(
[75f2897]174 `http://127.0.0.1:${server.address().port}/ap/users/kind/thread?object=${encodeURIComponent(BRON)}`,
175 { headers: { Authorization: `Bearer ${bearer}` } })).json());
176
[3bbf73d]177 const doc = await get();
[75f2897]178 assert.ok(!JSON.stringify(doc).includes('"shaer:author":'), 'geen dialect meer op deze leg');
[3bbf73d]179 const fromAunt = doc.orderedItems.find((n) => n.id.endsWith('/replies/2'));
180 assert.equal(fromAunt.attributedTo.name, 'tante', 'de byline zit in attributedTo');
181 assert.equal(fromAunt.attributedTo.preferredUsername, 'tante');
182 assert.ok((fromAunt.tag || []).some((x) => x.type === 'Emoji'), 'open poort: de emoji van het antwoord blijft');
[75f2897]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');
[3bbf73d]190 const closed = await get();
191 const after = closed.orderedItems.find((n) => n.id.endsWith('/replies/2'));
192 assert.ok(!(after.tag || []).some((x) => x.type === 'Emoji'), 'dichte poort: geen emoji meer');
193 assert.ok(!after.attributedTo.tag, 'ook niet in de byline');
[75f2897]194});
[aae5881]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; });
[3bbf73d]219 const ask = async (uri) => {
220 const r = await realFetch(
[aae5881]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.
[3bbf73d]228 const SHUT = 'https://203.0.113.50/notes/dicht';
[aae5881]229 globalThis.fetch = async () => new Response('nope', { status: 404 });
[3bbf73d]230 const refused = await ask(SHUT);
231 assert.equal(refused.status, 404);
232 assert.equal(refused.body.error, 'not shared by source');
233 assert.equal(refused.body.sourceStatus, 404);
[aae5881]234
235 // De bron ligt eruit. Dezelfde lege uitkomst, een andere waarheid.
[3bbf73d]236 const BROKEN = 'https://203.0.113.51/notes/stuk';
[aae5881]237 globalThis.fetch = async () => new Response('boem', { status: 500 });
[3bbf73d]238 const broken = await ask(BROKEN);
239 assert.equal(broken.status, 502);
240 assert.equal(broken.body.error, 'source unreachable');
[aae5881]241});
[61d52ed]242
243// ── Eén note, naar boven (de object-route) ───────────────────────────
244//
245// De thread geeft alleen wat onder een post hangt. Een lezer die een antwoord
246// kreeg en de ouder niet, vraagt de ouder hier. Wat hier stil kan breken: een
247// ouder die verzonnen wordt (de thread-fallback die meelekt), een geblokkeerde
248// die toch zichtbaar wordt, en een route zonder bearer-check.
249test('getObject geeft de note zelf, met alleen de ouder die de bron noemde', async (t) => {
250 const vorige = globalThis.fetch;
251 t.after(() => { globalThis.fetch = vorige; });
252 const KIND_REPLY = 'https://203.0.113.60/notes/antwoord';
253 const WORTEL = 'https://203.0.113.60/notes/wortel';
254 globalThis.fetch = async (url) => {
255 const u = String(url);
256 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
257 if (u === KIND_REPLY) return json({ id: KIND_REPLY, type: 'Note', attributedTo: TANTE, inReplyTo: WORTEL, content: '<p>ja <script>x</script></p>', published: '2026-09-07T13:42:26Z' });
258 if (u === WORTEL) return json({ id: WORTEL, type: 'Note', attributedTo: TANTE, content: '<p>begin</p>' });
259 if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
260 return new Response('not found', { status: 404 });
261 };
262
263 const antwoord = await AP.getObject('kind', KIND_REPLY);
264 assert.equal(antwoord.found, true);
265 assert.equal(antwoord.note.inReplyTo, WORTEL, 'de ouder die de bron gaf');
266 assert.equal(antwoord.note.attributedTo.name, 'tante');
267 assert.ok(!antwoord.note.content.includes('<script'), 'geschoond');
268
269 const wortel = await AP.getObject('kind', WORTEL);
270 assert.equal(wortel.found, true);
271 assert.equal(wortel.note.inReplyTo, undefined, 'geen ouder verzonnen voor een wortel');
272});
273
274test('getObject: een geblokkeerde auteur is er niet, een weigering is een weigering', async (t) => {
275 const vorige = globalThis.fetch;
276 t.after(() => { globalThis.fetch = vorige; });
277 const VAN_NAAR = 'https://203.0.113.40/notes/van-naar';
278 globalThis.fetch = async (url) => (String(url) === VAN_NAAR
279 ? new Response(JSON.stringify({ id: VAN_NAAR, type: 'Note', attributedTo: GEBLOKT, content: 'x' }), { status: 200 })
280 : new Response('nope', { status: 410 }));
281
282 const geblokt = await AP.getObject('kind', VAN_NAAR);
283 assert.equal(geblokt.found, false);
284 assert.equal(geblokt.note, null);
285
286 const weg = await AP.getObject('kind', 'https://203.0.113.61/notes/weg');
287 assert.equal(weg.found, false);
288 assert.equal(weg.sourceStatus, 410);
289});
290
291test('de object-route: alleen met de eigen bearer, en een weigering is 404', async (t) => {
292 const crypto = await import('crypto');
293 const express = (await import('express')).default;
294 const routes = (await import('../src/routes/activitypub.js')).default;
295
296 // Een eigen site zonder guardians, zodat de poorten van de tests hierboven
297 // (de ward met de dichte emoji-poort) hier niet meetellen.
298 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s-lezer', 'lezer', 'Lezer', 'u1');
299 const bearer = 'test-token-' + 'e'.repeat(24);
300 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
301 .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'lezer', 'read write');
302
303 const app = express();
304 app.use(routes);
305 const server = app.listen(0);
306 t.after(() => server.close());
307 await new Promise((r) => server.once('listening', r));
308
309 const vorige = globalThis.fetch;
310 t.after(() => { globalThis.fetch = vorige; });
311 const OUDER = 'https://203.0.113.62/notes/ouder';
312 globalThis.fetch = async (url) => {
313 const u = String(url);
314 if (u === OUDER) return new Response(JSON.stringify({ id: OUDER, type: 'Note', attributedTo: VREEMDE, content: '<p>de ouder</p>', inReplyTo: BRON }), { status: 200 });
315 if (u === VREEMDE) return new Response(JSON.stringify(actorDoc(VREEMDE, 'vreemde')), { status: 200 });
316 return new Response('nope', { status: 404 });
317 };
318 const ask = async (slug, uri, auth = `Bearer ${bearer}`) => {
319 const r = await realFetch(
320 `http://127.0.0.1:${server.address().port}/ap/users/${slug}/object?uri=${encodeURIComponent(uri)}`,
321 { headers: auth ? { Authorization: auth } : {} });
322 return { status: r.status, body: r.status === 403 ? null : await r.json() };
323 };
324
325 assert.equal((await ask('lezer', OUDER, null)).status, 403, 'zonder bearer niets');
326 assert.equal((await ask('kind', OUDER)).status, 403, 'niet met de bearer van een ander');
327 assert.equal((await ask('lezer', 'http://203.0.113.62/notes/ouder')).status, 400, 'alleen https');
328
329 const ok = await ask('lezer', OUDER);
330 assert.equal(ok.status, 200);
331 assert.equal(ok.body.id, OUDER);
332 assert.equal(ok.body.inReplyTo, BRON);
333 assert.equal(ok.body.attributedTo.name, 'vreemde');
334
335 const dicht = await ask('lezer', 'https://203.0.113.63/notes/dicht');
336 assert.equal(dicht.status, 404);
337 assert.equal(dicht.body.error, 'not shared by source');
338});
Note: See TracBrowser for help on using the repository browser.