source: Klonkt/test/thread.test.js@ 3bbf73d

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

Alleen namen: het Nederlands van vandaag terug naar Engels

shaer-8ms zegt het al: Engelse namen, Nederlands commentaar waar dat helpt. Ik
ben er vandaag opnieuw van afgedreven en zet dat recht voordat er meer bovenop
komt -- gatesFor, messageItem, sentItem, conversationItems, ownHandle,
CONVERSATION_UNION, MESSAGE_COLUMNS, cursorOf, en de locals eromheen
(rows/more/oldest/direction/incoming/heads/self/size).

Geen gedrag, alleen namen, en daarom een eigen commit: die is na te lopen.
851/851 groen voor en na. Het commentaar blijft Nederlands.

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 realFetch = 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 get = async () => (await (await realFetch(
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 get();
178 assert.ok(!JSON.stringify(doc).includes('"shaer:author":'), 'geen dialect meer op deze leg');
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');
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 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');
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 ask = async (uri) => {
220 const r = await realFetch(
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 SHUT = 'https://203.0.113.50/notes/dicht';
229 globalThis.fetch = async () => new Response('nope', { status: 404 });
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);
234
235 // De bron ligt eruit. Dezelfde lege uitkomst, een andere waarheid.
236 const BROKEN = 'https://203.0.113.51/notes/stuk';
237 globalThis.fetch = async () => new Response('boem', { status: 500 });
238 const broken = await ask(BROKEN);
239 assert.equal(broken.status, 502);
240 assert.equal(broken.body.error, 'source unreachable');
241});
Note: See TracBrowser for help on using the repository browser.