| 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 |
|
|---|
| 11 | import { test } from 'node:test';
|
|---|
| 12 | import assert from 'node:assert/strict';
|
|---|
| 13 |
|
|---|
| 14 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 15 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 16 |
|
|---|
| 17 | const dbMod = await import('../src/config/database.js');
|
|---|
| 18 | const db = dbMod.default;
|
|---|
| 19 | dbMod.initializeDatabase();
|
|---|
| 20 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 21 |
|
|---|
| 22 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 23 | .run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 24 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
|
|---|
| 25 |
|
|---|
| 26 | const BRON = 'https://203.0.113.10/notes/post-1';
|
|---|
| 27 | const TANTE = 'https://203.0.113.20/users/tante';
|
|---|
| 28 | const VREEMDE = 'https://203.0.113.30/users/vreemde';
|
|---|
| 29 | const GEBLOKT = 'https://203.0.113.40/users/naar';
|
|---|
| 30 |
|
|---|
| 31 | // De tante is gevolgd (accepted): zij zit in de kring van de guardians.
|
|---|
| 32 | db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', ?, 'accepted')").run(TANTE);
|
|---|
| 33 | db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('kind', 'actor', ?)").run(GEBLOKT);
|
|---|
| 34 |
|
|---|
| 35 | const actorDoc = (id, naam) => ({ id, type: 'Person', preferredUsername: naam, name: naam, inbox: `${id}/inbox` });
|
|---|
| 36 | const reply = (n, actor, content, published) => ({
|
|---|
| 37 | id: `${BRON}/replies/${n}`, type: 'Note', attributedTo: actor,
|
|---|
| 38 | inReplyTo: BRON, content, published,
|
|---|
| 39 | });
|
|---|
| 40 |
|
|---|
| 41 | const 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.
|
|---|
| 57 | const echteFetch = globalThis.fetch;
|
|---|
| 58 |
|
|---|
| 59 | globalThis.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 |
|
|---|
| 71 | test('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 |
|
|---|
| 94 | test('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 |
|
|---|
| 110 | test('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 |
|
|---|
| 116 | test('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 |
|
|---|
| 143 | test('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.
|
|---|
| 159 | test('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.
|
|---|
| 202 | test('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 | });
|
|---|