// 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 realFetch = 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(' n.attributedTo !== GEBLOKT)); // FEP-9098: alleen de echte emoji komt door -- niet de hashtag, niet de // emoji zonder icoon -- en het icoon-adres is geschoond. const tags = uit.notes[0].tag; assert.equal(tags.length, 1); assert.equal(tags[0].name, ':hartje:'); assert.equal(tags[0].icon.url, 'https://203.0.113.20/emoji/hartje.png'); assert.ok(!uit.notes[1].tag, 'een antwoord zonder emoji draagt geen tag-veld'); }); test('de kringfilter houdt goedgekeurd volk en telt de rest (de dichte stand van de gate)', async () => { // Sinds de gate (shaer-9y2) zit de kring in de ROUTE, per verzoek en buiten // de threadcache om -- een poort die net dichtging mag niet twee minuten // open nawerken. Hier de functie zelf. db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run(); db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE); const uit = await AP.getThread('kind', BRON); const kring = AP.filterThreadToCircle('pupil', uit.notes); assert.equal(kring.notes.length, 1); // Ook de zeef zelf leest de ingesloten actor: een kale vergelijking op // attributedTo zou hier stil ALLES wegfilteren. assert.equal(kring.notes[0].attributedTo.name, 'tante'); // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten. assert.equal(kring.hidden, 1); }); test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => { const uit = await AP.getThread('kind', 'https://203.0.113.99/weg'); assert.equal(uit.found, false); assert.equal(uit.notes.length, 0); }); test('de Mastodon-vorm: first is een lege inline-pagina, de antwoorden staan op next', async () => { // Precies wat Mastodon serveert en wat Barts melding (8-8) verklaarde: wie // alleen de eerste pagina leest, ziet op elke Mastodon-post een leeg gesprek. const MPOST = 'https://203.0.113.50/notes/masto-1'; const vorige = 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 === MPOST) return json({ id: MPOST, type: 'Note', attributedTo: TANTE, content: '

toot

', replies: { id: `${MPOST}/replies`, type: 'Collection', first: { type: 'CollectionPage', items: [], next: `${MPOST}/replies?page=true` }, }, }); if (u === `${MPOST}/replies?page=true`) return json({ type: 'CollectionPage', items: [{ id: `${MPOST}/r1`, type: 'Note', attributedTo: TANTE, inReplyTo: MPOST, content: '

eerste echte antwoord

', published: '2026-08-02T10:00:00Z' }], }); return vorige(url); }; const uit = await AP.getThread('kind', MPOST); globalThis.fetch = vorige; assert.equal(uit.notes.length, 1, 'het antwoord op de next-pagina is gevonden'); assert.ok(uit.notes[0].content.includes('eerste echte antwoord')); }); test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => { let calls = 0; const vorige = globalThis.fetch; globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); }; const uit = await AP.getThread('kind', BRON); assert.equal(uit.notes.length, 2); assert.equal(calls, 0, 'alles uit de cache, nul fetches'); globalThis.fetch = vorige; }); // ── Door de route heen, want daar wordt de vorm beslist ────────────── // // De byline van een antwoord verhuisde naar de ingesloten attributedTo // (shaer-nmw). Deze leg had geen route-dekking, en juist hier zit de // emoji-poort die IN die byline knipt -- knipt hij in het oude veld, dan // gebeurt er stil niets meer. test('de thread-route geeft de byline in attributedTo, en de emoji-poort knipt daarin', async (t) => { const crypto = await import('crypto'); const express = (await import('express')).default; const routes = (await import('../src/routes/activitypub.js')).default; const bearer = 'test-token-' + 'c'.repeat(24); db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)') .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write'); const app = express(); app.use(routes); const server = app.listen(0); t.after(() => server.close()); await new Promise((r) => server.once('listening', r)); const get = async () => (await (await realFetch( `http://127.0.0.1:${server.address().port}/ap/users/kind/thread?object=${encodeURIComponent(BRON)}`, { headers: { Authorization: `Bearer ${bearer}` } })).json()); const doc = await get(); assert.ok(!JSON.stringify(doc).includes('"shaer:author":'), 'geen dialect meer op deze leg'); const fromAunt = doc.orderedItems.find((n) => n.id.endsWith('/replies/2')); assert.equal(fromAunt.attributedTo.name, 'tante', 'de byline zit in attributedTo'); assert.equal(fromAunt.attributedTo.preferredUsername, 'tante'); assert.ok((fromAunt.tag || []).some((x) => x.type === 'Emoji'), 'open poort: de emoji van het antwoord blijft'); // Poort dicht: FEP-9098 zit in de tag van de ingesloten actor, dus daar // hoort geknipt te worden -- en het antwoord zelf houdt zijn eigen emoji // niet meer over. db.prepare("UPDATE sites SET gate_custom_emoji = 0 WHERE slug = 'kind'").run(); db.prepare("INSERT INTO ap_guardianships (slug, other_uri, role, status) VALUES ('kind', ?, 'guardian', 'accepted')") .run('https://203.0.113.20/users/tante'); const closed = await get(); const after = closed.orderedItems.find((n) => n.id.endsWith('/replies/2')); assert.ok(!(after.tag || []).some((x) => x.type === 'Emoji'), 'dichte poort: geen emoji meer'); assert.ok(!after.attributedTo.tag, 'ook niet in de byline'); }); // ── Wiens schuld is het? ───────────────────────────────────────────── // // "Replies could not be loaded from your server" wees naar de verkeerde // partij: onze server deed het prima en de BRON weigerde. Een weigering // (401/403/404/410 -- niet gedeeld, of weg) is iets anders dan een storing, en // alleen de server weet welke van de twee het was. test('een weigering van de bron is 404, een storing is 502', async (t) => { const crypto = await import('crypto'); const express = (await import('express')).default; const routes = (await import('../src/routes/activitypub.js')).default; const bearer = 'test-token-' + 'd'.repeat(24); db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)') .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write'); const app = express(); app.use(routes); const server = app.listen(0); t.after(() => server.close()); await new Promise((r) => server.once('listening', r)); const vorige = globalThis.fetch; t.after(() => { globalThis.fetch = vorige; }); const ask = async (uri) => { const r = await realFetch( `http://127.0.0.1:${server.address().port}/ap/users/kind/thread?object=${encodeURIComponent(uri)}`, { headers: { Authorization: `Bearer ${bearer}` } }); return { status: r.status, body: await r.json() }; }; // De bron zegt nee -- precies wat boiert.eu doet met een friends-only post // voor iemand die de auteur niet (meer) volgt. const SHUT = 'https://203.0.113.50/notes/dicht'; globalThis.fetch = async () => new Response('nope', { status: 404 }); const refused = await ask(SHUT); assert.equal(refused.status, 404); assert.equal(refused.body.error, 'not shared by source'); assert.equal(refused.body.sourceStatus, 404); // De bron ligt eruit. Dezelfde lege uitkomst, een andere waarheid. const BROKEN = 'https://203.0.113.51/notes/stuk'; globalThis.fetch = async () => new Response('boem', { status: 500 }); const broken = await ask(BROKEN); assert.equal(broken.status, 502); assert.equal(broken.body.error, 'source unreachable'); });