| 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
|
|---|
| 11 | import { test } from 'node:test';
|
|---|
| 12 | import assert from 'node:assert/strict';
|
|---|
| 13 |
|
|---|
| 14 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 15 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 16 |
|
|---|
| 17 | const dbMod = await import('../src/config/database.js');
|
|---|
| 18 | const db = dbMod.default;
|
|---|
| 19 | dbMod.initializeDatabase();
|
|---|
| 20 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 21 | const Guardianship = await import('../src/services/guardianship/index.js');
|
|---|
| 22 |
|
|---|
| 23 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 24 | .run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 25 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
|
|---|
| 26 | const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
|
|---|
| 27 | const user = { id: 'u1', username: 'u1' };
|
|---|
| 28 |
|
|---|
| 29 | test('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 |
|
|---|
| 38 | test('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 |
|
|---|
| 45 | test('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 |
|
|---|
| 61 | test('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 |
|
|---|
| 72 | test('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 |
|
|---|
| 94 | test('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 |
|
|---|
| 103 | test('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 |
|
|---|
| 114 | test('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 |
|
|---|
| 125 | test('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 |
|
|---|
| 136 | test('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 |
|
|---|
| 146 | test('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 |
|
|---|
| 160 | test('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 |
|
|---|
| 167 | test('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 | });
|
|---|