source: Klonkt/test/gates-family.test.js

main
Last change on this file was ffb371c, checked in by roboburr <roboburr@…>, 5 weeks ago

Antwoorden is ook iets: een eigen poort (shaer-r4c)

Bart, 8-8: "Ook meedoen aan een gesprek valt onder een gate, maak die en maak die
zichtbaar."

HIER STOND EEN AANNAME, EN DIE WAS VAN MIJ. compose liet een antwoord bewust
door, met als reden "een antwoord is meedoen aan een gesprek, geen eigen podium".
Dat is een ontwerpkeuze die ik als vanzelfsprekend had opgeschreven, en Bart
draait hem terug: meedoen aan een gesprek is ook iets waar guardians over gaan.

EEN EIGEN POORT, NIET ONDER COMPOSE. Los in beide richtingen, en daar staan
toetsen op: je kunt willen dat een kind meepraat zonder eigen podium, en ook
precies andersom. Zou compose antwoorden meesluiten, dan is de nieuwe rij in het
paneel een knop die niets doet.

DE DEUR NAAST DE POORT. De innamepoort in ingestOutboxActivity dekt alleen C2S --
Shaer. routes/posts.js roept deliverReply op drie plekken RECHTSTREEKS aan, dus
de eigen webinterface van Klonkt loopt er nooit langs. Een poort die alleen in de
app staat is geen poort. De echte controle staat nu in deliverReply zelf, het
knooppunt dat beide paden delen; de outbox houdt zijn eigen check zodat de app
een nette 403 gated_replies krijgt in plaats van een stille null.

De boei komt daar niet langs en dat is geen toeval: een hulpvraag is altijd
direct en loopt via deliverDirectNote. Er is dus geen uitzondering nodig om hem
open te houden -- maar er staat wel een toets op, want dit is het gevaarlijkste
dat deze poort kan doen: een kind dat om hulp vraagt in het draadje waar het
misgaat.

Een direct antwoord wordt door allebei de poorten gedekt (messages en replies).
Een prive-antwoord is allebei, en dan mag allebei hem tegenhouden.

Paneel en shaer:capabilities krijgen hem gratis uit de catalogus. Labels in
nl/en/de. Zes toetsen, drie mutaties gecontroleerd. Suite 646/646.

  • Property mode set to 100644
File size: 10.2 KB
Line 
1// De gate-familie functioneel (shaer-ahy.1, Barts opdracht 8-8): niet alleen
2// een toets in het paneel, maar een poort die echt dichtkan -- bij de
3// AFLEVERING (wat dicht is wordt nooit geserialiseerd) of bij de INNAME (wat
4// de ward niet mag versturen wordt aan de outbox geweigerd).
5//
6// De twee dingen die hier stil kunnen breken: een poort die dicht lijkt maar
7// niets tegenhoudt (een guardian waant het kind beschermd), en een poort die
8// het hulpkanaal mee afsluit (de boei MOET door elke dichte deur heen).
9//
10// In-memory SQLite. Run: npm test
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');
21const Guardianship = await import('../src/services/guardianship/index.js');
22
23db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
24 .run('u1', 'u1', 'u1@test', 'x', 'god');
25db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
26const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
27const user = { id: 'u1', username: 'u1' };
28
29test('elke beschikbare gate in de catalogus heeft een kolom', () => {
30 // De bewaker van de bedrading: een catalogusrij zonder kolom is een toets
31 // die nergens op aangesloten is, en dat merkt pas iemand die erop drukt.
32 for (const g of Guardianship.gated.GATE_CATALOGUE.filter((x) => x.available !== false)) {
33 if (g.fixed) continue; // shaer:follows is een stroom, geen stand
34 assert.ok(Guardianship.gated.featureColumn(g.feature), `${g.feature} mist een kolom`);
35 }
36});
37
38test('de generieke regel: expliciet wint, de automatiek sluit voor een ward', () => {
39 assert.equal(Guardianship.wardGateAllowed(null, false), true);
40 assert.equal(Guardianship.wardGateAllowed(null, true), false);
41 assert.equal(Guardianship.wardGateAllowed(1, true), true); // guardians openden hem
42 assert.equal(Guardianship.wardGateAllowed(0, false), false); // expliciete 0 wint ook zonder wardstatus
43});
44
45test('gateAttachments: beeld en muziek dicht laten de rest staan, leeg wordt undefined', () => {
46 const atts = [
47 { type: 'Document', mediaType: 'image/webp', url: 'https://x/p.webp' },
48 { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://x/a.mp3' },
49 { type: 'Document', mediaType: 'video/mp4', url: 'https://x/v.mp4' },
50 ];
51 const zonderBeeld = AP.gateAttachments(atts, { images: false });
52 assert.deepEqual(zonderBeeld.map((a) => a.mediaType), ['audio/mpeg', 'video/mp4']);
53 const zonderMuziek = AP.gateAttachments(atts, { audio: false });
54 assert.deepEqual(zonderMuziek.map((a) => a.mediaType), ['image/webp', 'video/mp4']);
55 // Alles weg is undefined, zoals de serialisatie een leeg veld overal weglaat.
56 assert.equal(AP.gateAttachments([atts[0]], { images: false }), undefined);
57 // En de poort open verandert niets.
58 assert.equal(AP.gateAttachments(atts, {}).length, 3);
59});
60
61test('stripEmojiTags haalt alleen de Emoji-tags weg', () => {
62 const tags = [
63 { type: 'Emoji', name: ':hartje:', icon: { url: 'https://x/h.png' } },
64 { type: 'Mention', href: 'https://x/u', name: '@u' },
65 { type: 'Hashtag', name: '#muziek' },
66 ];
67 const uit = AP.stripEmojiTags(tags);
68 assert.deepEqual(uit.map((t) => t.type), ['Mention', 'Hashtag']);
69 assert.equal(AP.stripEmojiTags([tags[0]]), undefined);
70});
71
72test('compose dicht weigert een eigen post aan de outbox, maar geen antwoord', async () => {
73 db.prepare("UPDATE sites SET gate_compose = 0 WHERE slug = 'kind'").run();
74 const post = { type: 'Create', object: { type: 'Note', content: '<p>hoi</p>', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
75 const uit = await AP.ingestOutboxActivity(site(), user, post);
76 assert.equal(uit.status, 403);
77 assert.equal(uit.error, 'gated_compose');
78 // Een antwoord is meedoen aan een gesprek, geen eigen podium: dat valt
79 // onder messages/het gesprek, niet onder compose.
80 const reply = { type: 'Create', object: { type: 'Note', content: '<p>ja!</p>', inReplyTo: 'https://elders/x', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
81 const uit2 = await AP.ingestOutboxActivity(site(), user, reply);
82 assert.notEqual(uit2.error, 'gated_compose');
83 db.prepare("UPDATE sites SET gate_compose = NULL WHERE slug = 'kind'").run();
84});
85
86// ── Antwoorden: een eigen poort (shaer-r4c) ─────────────────────────────
87//
88// Hier stond de aanname dat een antwoord meedoen is en geen eigen podium, en
89// dus onder compose door mocht. Bart heeft die teruggedraaid: meedoen aan een
90// gesprek valt ook onder een poort. Wat deze toetsen bewaken is dat het een
91// EIGEN poort is en geen aanhangsel van compose -- anders is hij in het paneel
92// wel te zien maar niet los te zetten.
93
94test('replies dicht weigert een antwoord', async () => {
95 db.prepare("UPDATE sites SET gate_replies = 0 WHERE slug = 'kind'").run();
96 const reply = { type: 'Create', object: { type: 'Note', content: '<p>ja!</p>', inReplyTo: 'https://elders/x', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
97 const uit = await AP.ingestOutboxActivity(site(), user, reply);
98 assert.equal(uit.status, 403);
99 assert.equal(uit.error, 'gated_replies');
100 db.prepare("UPDATE sites SET gate_replies = NULL WHERE slug = 'kind'").run();
101});
102
103test('replies dicht laat een EIGEN post staan', async () => {
104 // Los van compose, en dat is de hele reden dat het een eigen rij is: je kunt
105 // willen dat een kind een podium heeft zonder in vreemde draadjes te duiken.
106 db.prepare("UPDATE sites SET gate_replies = 0 WHERE slug = 'kind'").run();
107 const post = { type: 'Create', object: { type: 'Note', content: '<p>hoi</p>', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
108 const uit = await AP.ingestOutboxActivity(site(), user, post);
109 assert.notEqual(uit.error, 'gated_replies');
110 assert.notEqual(uit.error, 'gated_compose');
111 db.prepare("UPDATE sites SET gate_replies = NULL WHERE slug = 'kind'").run();
112});
113
114test('compose dicht laat een antwoord staan zolang replies open is', async () => {
115 // De andere richting van dezelfde onafhankelijkheid. Zou compose ook
116 // antwoorden dichtzetten, dan is de nieuwe rij een knop die niets doet.
117 db.prepare("UPDATE sites SET gate_compose = 0, gate_replies = 1 WHERE slug = 'kind'").run();
118 const reply = { type: 'Create', object: { type: 'Note', content: '<p>ja!</p>', inReplyTo: 'https://elders/x', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
119 const uit = await AP.ingestOutboxActivity(site(), user, reply);
120 assert.notEqual(uit.error, 'gated_compose');
121 assert.notEqual(uit.error, 'gated_replies');
122 db.prepare("UPDATE sites SET gate_compose = NULL, gate_replies = NULL WHERE slug = 'kind'").run();
123});
124
125test('replies dicht houdt ook een DIRECT antwoord tegen', async () => {
126 // Een prive-antwoord is allebei: meedoen aan een gesprek en een bericht. Dan
127 // mag allebei hem tegenhouden. Zou alleen de messages-poort gelden, dan is
128 // dat het gat waar een dichte replies-poort omheen loopt.
129 db.prepare("UPDATE sites SET gate_replies = 0, gate_messages = 1 WHERE slug = 'kind'").run();
130 const dm = { type: 'Create', object: { type: 'Note', content: '<p>psst</p>', inReplyTo: 'https://elders/x', to: ['https://elders/ap/users/vreemde'] } };
131 const uit = await AP.ingestOutboxActivity(site(), user, dm);
132 assert.equal(uit.error, 'gated_replies');
133 db.prepare("UPDATE sites SET gate_replies = NULL, gate_messages = NULL WHERE slug = 'kind'").run();
134});
135
136test('replies dicht laat de REDDINGSBOEI door, ook als die een antwoord is', async () => {
137 // Het gevaarlijkste dat deze poort kan doen. Een kind dat om hulp vraagt in
138 // een draadje waar het misgaat, is precies het geval waarvoor de boei bestaat.
139 db.prepare("UPDATE sites SET gate_replies = 0 WHERE slug = 'kind'").run();
140 const hulp = { type: 'Create', object: { type: 'Note', content: '<p>🛟</p>', 'shaer:helpRequest': true, inReplyTo: 'https://elders/x', to: ['https://elders/ap/users/oma'] } };
141 const uit = await AP.ingestOutboxActivity(site(), user, hulp);
142 assert.notEqual(uit.error, 'gated_replies');
143 db.prepare("UPDATE sites SET gate_replies = NULL WHERE slug = 'kind'").run();
144});
145
146test('messages dicht weigert een direct bericht, maar NOOIT de reddingsboei', async () => {
147 db.prepare("UPDATE sites SET gate_messages = 0 WHERE slug = 'kind'").run();
148 const dm = { type: 'Create', object: { type: 'Note', content: '<p>psst</p>', to: ['https://elders/ap/users/vreemde'] } };
149 const uit = await AP.ingestOutboxActivity(site(), user, dm);
150 assert.equal(uit.status, 403);
151 assert.equal(uit.error, 'gated_messages');
152 // De boei gaat door elke dichte deur heen: een poort die het hulpkanaal
153 // afsnijdt beschermt niemand.
154 const hulp = { type: 'Create', object: { type: 'Note', content: '<p>🛟</p>', 'shaer:helpRequest': true, to: ['https://elders/ap/users/oma'] } };
155 const uit2 = await AP.ingestOutboxActivity(site(), user, hulp);
156 assert.notEqual(uit2.error, 'gated_messages');
157 db.prepare("UPDATE sites SET gate_messages = NULL WHERE slug = 'kind'").run();
158});
159
160test('accountMove dicht weigert de verhuizing voordat er iets vertrekt', async () => {
161 db.prepare("UPDATE sites SET gate_account_move = 0 WHERE slug = 'kind'").run();
162 const uit = await AP.moveAccount(site(), '@iemand@elders.example');
163 assert.equal(uit.error, 'guarded_account');
164 db.prepare("UPDATE sites SET gate_account_move = NULL WHERE slug = 'kind'").run();
165});
166
167test('replies dicht houdt OOK het webpad tegen, niet alleen de app', async () => {
168 // routes/posts.js roept deliverReply rechtstreeks aan en gaat nooit langs
169 // ingestOutboxActivity. Een poort die alleen in C2S staat heeft een deur
170 // ernaast -- en die deur is de eigen webinterface van het kind.
171 db.prepare("UPDATE sites SET gate_replies = 0 WHERE slug = 'kind'").run();
172 const parent = { id: 'https://elders/x', attributedTo: 'https://elders/ap/users/vreemde' };
173 const uit = await AP.deliverReply(site(), { postId: '', postSlug: null, parent, text: 'ja!' });
174 assert.equal(uit, null);
175 db.prepare("UPDATE sites SET gate_replies = NULL WHERE slug = 'kind'").run();
176});
Note: See TracBrowser for help on using the repository browser.