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

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

De gate-familie functioneel: acht poorten die echt schakelen (shaer-ahy.1)

Barts opdracht (8-8): "maak ze allemaal maar functioneel." Alle
setting-gates uit de catalogus hebben nu een kolom, zijn voorstelbaar en
beslisbaar via de bestaande tally, en worden ECHT afgedwongen -- bij de
aflevering (wat dicht is wordt nooit geserialiseerd, de regel die de
embeds al hadden) of bij de inname (wat de ward niet mag versturen
weigert de outbox met een eerlijke 403).

externalThreads de thread-kring (shaer-9y2): dicht is de kring van de

guardians met telling, open is alles; per verzoek en
buiten de threadcache om, want een poort die net
dichtging mag niet twee minuten open nawerken

images/music bijlagen gefilterd op mediaType, ook in de thread
quoteCards shaer:quote niet geserialiseerd
customEmoji Emoji-tags en byline-emoji niet geserialiseerd; de

:shortcode: blijft als tekst staan, dat is eerlijk

messages de berichten-poot dicht voor vreemden en vrienden,

maar NOOIT voor het guardian-kanaal, en de outbox
weigert directe berichten -- behalve de reddingsboei:
een poort die het hulpkanaal afsnijdt beschermt
niemand

compose de outbox weigert eigen posts; een antwoord valt

onder het gesprek, niet onder een eigen podium

accountMove de harde weigering van shaer-tge is een gate

geworden met dezelfde standaard: guardians kunnen
hem nu openzetten

De capabilities dragen de hele familie, zodat de app VOORAF weet wat hij
mag aanbieden -- de (+) kaart leest shaer:compose al. De voorstelroute
herschreef een onbekende feature stilletjes naar externalEmbeds; dat is
nu een 400, want een voorstel dat op de verkeerde poort landt mag een
guardian nooit overkomen. Het paneel leest de standen catalogusbreed.

TWEE blijven bewust gepland. publicProfile is niet een veld dat je
wegfiltert maar het hele publieke web-oppervlak (de ontwerpvraag van
shaer-hj0); een half slot leest als een heel slot en dat is gevaarlijker
dan geen. independence draagt gezag over een kind over en zijn vorm
hoort bij shaer-90v beslist te worden, niet hier geimproviseerd.

Zestien nieuwe/aangepaste toetsen, alle 640 groen.

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

  • Property mode set to 100644
File size: 7.2 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
54globalThis.fetch = async (url) => {
55 const u = String(url);
56 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
57 if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: '<p>de post</p>', replies: `${BRON}/replies` });
58 if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` });
59 if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden });
60 if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
61 if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde'));
62 if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar'));
63 return new Response('not found', { status: 404 });
64};
65
66test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
67 const uit = await AP.getThread('kind', BRON);
68 assert.equal(uit.found, true);
69 assert.equal(uit.notes.length, 2);
70 // Oudste eerst: een gesprek lees je van boven naar beneden.
71 assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
72 assert.equal(uit.notes[1]['shaer:author'].name, 'vreemde');
73 // De sanitizer heeft het script eruit gehaald, de tekst mag blijven.
74 assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
75 assert.ok(uit.notes[1].content.includes('Hoi!'));
76 // Een blokkade is onzichtbaar: niet in de lijst en NIET in een telling --
77 // getThread telt sindsdien niets meer, dat doet de kringfilter in de route.
78 assert.equal(uit.hidden, undefined);
79 assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
80 // FEP-9098: alleen de echte emoji komt door -- niet de hashtag, niet de
81 // emoji zonder icoon -- en het icoon-adres is geschoond.
82 const tags = uit.notes[0].tag;
83 assert.equal(tags.length, 1);
84 assert.equal(tags[0].name, ':hartje:');
85 assert.equal(tags[0].icon.url, 'https://203.0.113.20/emoji/hartje.png');
86 assert.ok(!uit.notes[1].tag, 'een antwoord zonder emoji draagt geen tag-veld');
87});
88
89test('de kringfilter houdt goedgekeurd volk en telt de rest (de dichte stand van de gate)', async () => {
90 // Sinds de gate (shaer-9y2) zit de kring in de ROUTE, per verzoek en buiten
91 // de threadcache om -- een poort die net dichtging mag niet twee minuten
92 // open nawerken. Hier de functie zelf.
93 db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
94 db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
95 const uit = await AP.getThread('kind', BRON);
96 const kring = AP.filterThreadToCircle('pupil', uit.notes);
97 assert.equal(kring.notes.length, 1);
98 assert.equal(kring.notes[0]['shaer:author'].name, 'tante');
99 // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
100 assert.equal(kring.hidden, 1);
101});
102
103test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
104 const uit = await AP.getThread('kind', 'https://203.0.113.99/weg');
105 assert.equal(uit.found, false);
106 assert.equal(uit.notes.length, 0);
107});
108
109test('de Mastodon-vorm: first is een lege inline-pagina, de antwoorden staan op next', async () => {
110 // Precies wat Mastodon serveert en wat Barts melding (8-8) verklaarde: wie
111 // alleen de eerste pagina leest, ziet op elke Mastodon-post een leeg gesprek.
112 const MPOST = 'https://203.0.113.50/notes/masto-1';
113 const vorige = globalThis.fetch;
114 globalThis.fetch = async (url) => {
115 const u = String(url);
116 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
117 if (u === MPOST) return json({
118 id: MPOST, type: 'Note', attributedTo: TANTE, content: '<p>toot</p>',
119 replies: {
120 id: `${MPOST}/replies`, type: 'Collection',
121 first: { type: 'CollectionPage', items: [], next: `${MPOST}/replies?page=true` },
122 },
123 });
124 if (u === `${MPOST}/replies?page=true`) return json({
125 type: 'CollectionPage',
126 items: [{ id: `${MPOST}/r1`, type: 'Note', attributedTo: TANTE, inReplyTo: MPOST, content: '<p>eerste echte antwoord</p>', published: '2026-08-02T10:00:00Z' }],
127 });
128 return vorige(url);
129 };
130 const uit = await AP.getThread('kind', MPOST);
131 globalThis.fetch = vorige;
132 assert.equal(uit.notes.length, 1, 'het antwoord op de next-pagina is gevonden');
133 assert.ok(uit.notes[0].content.includes('eerste echte antwoord'));
134});
135
136test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => {
137 let calls = 0;
138 const vorige = globalThis.fetch;
139 globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
140 const uit = await AP.getThread('kind', BRON);
141 assert.equal(uit.notes.length, 2);
142 assert.equal(calls, 0, 'alles uit de cache, nul fetches');
143 globalThis.fetch = vorige;
144});
Note: See TracBrowser for help on using the repository browser.