source: Klonkt/test/thread.test.js@ 03583f6

main
Last change on this file since 03583f6 was 03583f6, checked in by Robin <roboburr@…>, 5 weeks ago

De thread onder een vreemde post: ophalen via je eigen server, nooit bewaren

shaer-tqz, Barts besluit van 7-8. GET /ap/users/:slug/thread?object=<uri>,
bearer-only: de eigen app vraagt, Klonkt doet de ondertekende GET die de
app niet kan (de sleutel woont hier), loopt EEN pagina van de AS2
replies-collectie af (cap 30), normaliseert en geeft terug. Cache twee
minuten in het geheugen, weg bij een herstart. Geen tabel, geen migratie:
Klonkt is hier een tolk, geen archief. Dat is de afwijking van wat
Mastodon doet (dat bewaart /context in zijn database) en dat is bewust --
bij een instance van een persoon slaat die opslag alleen surfgedrag op.

NIET hetzelfde als maybeCrawlThread: die kruipt de thread onder je EIGEN
posts af en bewaart in ap_interactions -- dat zijn de jouwe, die horen te
blijven. De naambotsing die dat bijna opleverde (THREAD_TTL_MS) is met
THREAD_VIEW_* opgelost en het onderscheid staat nu in het commentaar.

Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
antwoorden uit de kring die de guardians al kennen (gevolgd of volgend),
en shaer:hidden telt wat erbuiten viel -- geteld, nooit stil weggelaten.
Geblokkeerden zijn een andere categorie: onzichtbaar, ook als getal.

Vier tests: de gewone lezer (schonen, sorteren, blokkade onzichtbaar), de
ward met de telling, de onbereikbare bron die dat zegt in plaats van een
lege thread te veinzen, en de cache die de tweede lezing nul fetches kost.

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

  • Property mode set to 100644
File size: 5.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</p>', '2026-08-01T09:00:00Z'),
44 reply(3, GEBLOKT, '<p>naar bericht</p>', '2026-08-01T11:00:00Z'),
45];
46
47globalThis.fetch = async (url) => {
48 const u = String(url);
49 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
50 if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: '<p>de post</p>', replies: `${BRON}/replies` });
51 if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` });
52 if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden });
53 if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
54 if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde'));
55 if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar'));
56 return new Response('not found', { status: 404 });
57};
58
59test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
60 const uit = await AP.getThread('kind', BRON, { isWard: false });
61 assert.equal(uit.found, true);
62 assert.equal(uit.notes.length, 2);
63 // Oudste eerst: een gesprek lees je van boven naar beneden.
64 assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
65 assert.equal(uit.notes[1]['shaer:author'].name, 'vreemde');
66 // De sanitizer heeft het script eruit gehaald, de tekst mag blijven.
67 assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
68 assert.ok(uit.notes[1].content.includes('Hoi!'));
69 // Een blokkade is onzichtbaar: niet in de lijst en NIET in de telling.
70 assert.equal(uit.hidden, 0);
71 assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
72});
73
74test('een ward ziet alleen de kring van de guardians, de rest wordt geteld', async () => {
75 // Eigen cache-sleutel per (slug, uri) zou hier de vorige uitkomst hergeven;
76 // een andere slug dwingt een verse opbouw af zonder aan de cache te morrelen.
77 db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
78 db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
79 db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('pupil', 'actor', ?)").run(GEBLOKT);
80 const uit = await AP.getThread('pupil', BRON, { isWard: true });
81 assert.equal(uit.notes.length, 1);
82 assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
83 // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
84 assert.equal(uit.hidden, 1);
85});
86
87test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
88 const uit = await AP.getThread('kind', 'https://203.0.113.99/weg', { isWard: false });
89 assert.equal(uit.found, false);
90 assert.equal(uit.notes.length, 0);
91});
92
93test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => {
94 let calls = 0;
95 const vorige = globalThis.fetch;
96 globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
97 const uit = await AP.getThread('kind', BRON, { isWard: false });
98 assert.equal(uit.notes.length, 2);
99 assert.equal(calls, 0, 'alles uit de cache, nul fetches');
100 globalThis.fetch = vorige;
101});
Note: See TracBrowser for help on using the repository browser.