// De thread onder een post (shaer-tqz): ophalen, normaliseren, niet bewaren. // // De vier dingen die hier stil kunnen breken en die een gebruiker allemaal // anders voelt: een antwoord dat niet doorkomt (te streng), een vreemde die // bij een ward doorkomt (te los), HTML die ongeschoond doorreist (gevaarlijk), // en een geblokkeerde die meegeteld wordt in shaer:hidden (een blokkade hoort // onzichtbaar te zijn, ook als getal). // // In-memory SQLite, gestubde fetch op TEST-NET-3. Run: npm test import { test } from 'node:test'; import assert from 'node:assert/strict'; process.env.DATABASE_PATH = ':memory:'; process.env.PUBLIC_BASE_URL = 'https://klonkt.test'; const dbMod = await import('../src/config/database.js'); const db = dbMod.default; dbMod.initializeDatabase(); const AP = await import('../src/services/ActivityPubService.js'); db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') .run('u1', 'u1', 'u1@test', 'x', 'god'); db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1'); const BRON = 'https://203.0.113.10/notes/post-1'; const TANTE = 'https://203.0.113.20/users/tante'; const VREEMDE = 'https://203.0.113.30/users/vreemde'; const GEBLOKT = 'https://203.0.113.40/users/naar'; // De tante is gevolgd (accepted): zij zit in de kring van de guardians. db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', ?, 'accepted')").run(TANTE); db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('kind', 'actor', ?)").run(GEBLOKT); const actorDoc = (id, naam) => ({ id, type: 'Person', preferredUsername: naam, name: naam, inbox: `${id}/inbox` }); const reply = (n, actor, content, published) => ({ id: `${BRON}/replies/${n}`, type: 'Note', attributedTo: actor, inReplyTo: BRON, content, published, }); const antwoorden = [ reply(1, VREEMDE, '
Hoi!
', '2026-08-01T10:00:00Z'), { ...reply(2, TANTE, 'Dag lieverd :hartje:
', '2026-08-01T09:00:00Z'), // FEP-9098 zoals Mastodon hem stuurt, plus een niet-Emoji-tag en een // emoji zonder bruikbaar icoon: alleen de echte hoort erdoor te komen. tag: [ { type: 'Emoji', name: ':hartje:', icon: { type: 'Image', url: 'https://203.0.113.20/emoji/hartje.png' } }, { type: 'Emoji', name: ':kapot:', icon: {} }, { type: 'Hashtag', name: '#muziek' }, ] }, reply(3, GEBLOKT, 'naar bericht
', '2026-08-01T11:00:00Z'), ]; // De echte fetch, voordat de stub hem overneemt: de route-test onderaan doet // een ECHT verzoek aan een testserver op localhost, en dat mag de stub niet // beantwoorden met zijn 404. const echteFetch = globalThis.fetch; globalThis.fetch = async (url) => { const u = String(url); const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } }); if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: 'de post
', replies: `${BRON}/replies` }); if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` }); if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden }); if (u === TANTE) return json(actorDoc(TANTE, 'tante')); if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde')); if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar')); return new Response('not found', { status: 404 }); }; test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => { const uit = await AP.getThread('kind', BRON); assert.equal(uit.found, true); assert.equal(uit.notes.length, 2); // Oudste eerst: een gesprek lees je van boven naar beneden. assert.equal(uit.notes[0].attributedTo.name, 'tante'); assert.equal(uit.notes[1].attributedTo.name, 'vreemde'); // De sanitizer heeft het script eruit gehaald, de tekst mag blijven. assert.ok(!uit.notes[1].content.includes('