source: Klonkt/test/outgoing-follow-gate.test.js@ 709dc6f

main
Last change on this file since 709dc6f was 709dc6f, checked in by Bart <bart@…>, 4 weeks ago

Twee richtingen, twee poorten: shaer:following naast shaer:follows

In de catalogus stond één rij voor twee mechanismen. Het paneel telde
listReviewsByDirection(slug, 'incoming') en zette dat getal onder
shaer:follows, dus een guardian las "3 wachtend" en wist niet of er drie
vreemden bij zijn kind wilden of dat zijn kind drie keer had gevraagd of het
iemand mocht volgen. Dat zijn niet dezelfde zorg, en sinds shaer-p729 bestaan
ze allebei echt.

Nu twee poorten, en het verschil ertussen is opzet. §5.3 EIST dat een Follow
naar een ward langs de guardians gaat: shaer:follows blijft dus fixed. Over de
andere richting zegt de FEP niets — wat je verder gated is expliciet aan de
implementatie gelaten — dus shaer:following is onze keuze, en dan hoort hij ook
echt losgelaten te kunnen worden. Verstelbaar, met gate_following als kolom en
dezelfde automatiek als de rest: onbeslist is dicht voor een ward en open voor
ieder ander. Een kind dat erin groeit hoeft niet eeuwig te blijven vragen.

Geen eigen ownFollowsAllowed(): wardGateAllowed() is er al en zegt er zelf bij
dat het één implementatie hoort te zijn. Een derde kopie zou precies de tweede
plek zijn die er anders over kan gaan denken.

De labels zijn nog aan de clients: de server geeft de feature-naam door, de
Guardian PWA en Shaer moeten er nog woorden bij kiezen.

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

  • Property mode set to 100644
File size: 9.7 KB
Line 
1// FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
2//
3// A ward's own follow used to go straight out; the guardians got a note
4// afterwards, which is informing, not gating — the door is already open by the
5// time the message lands. Now it waits, with two exceptions that are not
6// favours but the same decision already taken: the ward's own guardian, and
7// someone a guardian already admitted through the inbound gate.
8//
9// The mutual shortcut only counts followers who came through that gate. A
10// follower a free actor collected before it was ever a ward was never seen by
11// a guardian, so following them back is a new question. Rows that predate the
12// marker are grandfathered (Barts besluit, 3-8).
13import { test } from 'node:test';
14import assert from 'node:assert/strict';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://test.example';
18
19const dbMod = await import('../src/config/database.js');
20const db = dbMod.default;
21dbMod.initializeDatabase();
22const AP = (await import('../src/services/ActivityPubService.js')).default;
23const G = await import('../src/services/guardianship/index.js');
24
25const BASE = 'https://test.example';
26const local = (slug) => `${BASE}/ap/users/${slug}`;
27const STRANGER = 'https://elders.example/users/stranger';
28const PAL = 'https://elders.example/users/pal';
29const OLDPAL = 'https://elders.example/users/oldpal';
30
31db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
32 .run('u1', 'u1', 'u1@test', 'x', 'god');
33let n = 0;
34function site(slug) {
35 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)')
36 .run(`s${++n}`, slug, slug, 'u1', n === 1 ? 1 : 0);
37 return db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
38}
39const guards = (slug, other) =>
40 db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status) VALUES (?, 'ward', ?, 'accepted')")
41 .run(slug, other);
42const follower = (slug, uri, gateApproved) =>
43 db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox, gate_approved) VALUES (?,?,?,?)')
44 .run(slug, uri, `${uri}/inbox`, gateApproved ? 1 : 0);
45
46const kid = site('kid');
47site('mum');
48guards('kid', local('mum')); // kid is a ward, watched by mum
49follower('kid', PAL, true); // came through the §5.3 gate
50follower('kid', OLDPAL, false); // followed back when kid was still free
51
52const free = site('freebird'); // no guardians at all
53
54test('a free actor is not gated at all', async () => {
55 assert.equal(await AP.gateOutgoingFollow(free, STRANGER), null,
56 'guardianship is the only thing that gates a follow; a free account keeps its own counsel');
57});
58
59test('a stranger has to wait for the guardians', async () => {
60 const held = await AP.gateOutgoingFollow(kid, STRANGER);
61 assert.ok(held, 'held, not sent');
62 assert.equal(held.status, 'pending');
63 assert.equal(held.target_uri, STRANGER);
64});
65
66test('following your own guardian needs nobody\'s permission', async () => {
67 assert.equal(await AP.gateOutgoingFollow(kid, local('mum')), null,
68 'asking mum whether you may follow mum is not a question');
69});
70
71test('a follower the guardians already admitted may be followed back', async () => {
72 assert.equal(await AP.gateOutgoingFollow(kid, PAL), null,
73 'a guardian said yes to this person by name; asking twice teaches people to stop reading');
74});
75
76test('but a follower from before the guardians existed is a fresh question', async () => {
77 const held = await AP.gateOutgoingFollow(kid, OLDPAL);
78 assert.ok(held, 'never went through the gate, so nobody ever vetted them');
79 assert.equal(held.status, 'pending');
80});
81
82test('asking twice does not queue the same request twice', async () => {
83 const again = await AP.gateOutgoingFollow(kid, STRANGER);
84 assert.ok(again);
85 assert.equal(G.outgoing.listForWard('kid').filter((o) => o.target_uri === STRANGER).length, 1);
86});
87
88test('the guardians see it in their own queue, apart from the inbound one', () => {
89 const q = G.outgoingFollowsCollection(`${local('kid')}/queues/outgoing-follows`, 'kid', local('mum'));
90 const mine = q.orderedItems.filter((o) => o['shaer:target'] === STRANGER);
91 assert.equal(mine.length, 1);
92 assert.equal(mine[0]['shaer:direction'], 'outgoing',
93 'a guardian must be able to tell "wants to follow your ward" from "your ward wants to follow"');
94 assert.equal(mine[0]['shaer:myVote'], false);
95});
96
97test('a guardian approving lets it through from then on', async () => {
98 const pending = G.outgoing.listForWard('kid').find((o) => o.target_uri === STRANGER);
99 const r = G.outgoing.decide(pending.id, local('mum'), 'approve', [local('mum')]);
100 assert.equal(r.outcome, 'approved');
101 assert.equal(await AP.gateOutgoingFollow(kid, STRANGER), null,
102 'the row stays behind as the record, so an unfollow and refollow is not a second question');
103});
104
105test('a refusal is remembered too, and does not re-ask by re-tapping', async () => {
106 const held = await AP.gateOutgoingFollow(kid, OLDPAL);
107 const r = G.outgoing.decide(held.id, local('mum'), 'reject', [local('mum')]);
108 assert.equal(r.outcome, 'rejected');
109 const again = await AP.gateOutgoingFollow(kid, OLDPAL);
110 assert.equal(again.status, 'denied', 'tapping follow again does not put it back in front of mum');
111});
112
113// ── De poort zit in followActor, niet alleen in C2S (Barts melding 8-8) ──
114//
115// Esmee's volgverzoeken kwamen nooit bij haar guardians aan. Niet omdat een
116// guardian elders ze niet kon beantwoorden -- die weg werkt -- maar omdat er
117// nooit een verzoek werd aangemaakt: de poort stond in `case 'Follow'` van de
118// C2S-outbox, en dus alleen als je via Shaer volgt. Vanuit Klonkts eigen
119// webinterface liep je er zo omheen.
120//
121// Dezelfde deur-naast-de-poort als bij de antwoordpoort (shaer-r4c), en de reden
122// dat die mutatie 0 fouten gaf: er stond niets op.
123
124test('een ward die vanaf het WEB volgt wordt ook tegengehouden', async () => {
125 const uit = await AP.followActor(kid, 'https://elders.example/users/webvriend');
126 assert.equal(uit.held, true, 'vastgehouden, niet gevolgd');
127 assert.equal(uit.status, 'pending');
128 // En er ligt echt iets voor de guardians, anders is "held" een leeg gebaar.
129 assert.ok(G.outgoing.findFor('kid', 'https://elders.example/users/webvriend'));
130});
131
132test('ook als het kind met een HANDLE volgt', async () => {
133 // De poort staat NA het oplossen. Zou hij alleen naar de ruwe invoer kijken,
134 // dan is elke @naam@server een sluiproute -- de fout die we hier repareren,
135 // een maat kleiner.
136 const uit = await AP.followActor(kid, 'https://elders.example/users/handlevriend');
137 assert.equal(uit.held, true);
138});
139
140test('een goedgekeurd verzoek komt er WEL doorheen', async () => {
141 // Zonder deze doorlaat stuit een goedgekeurd verzoek opnieuw op de poort en
142 // wacht het voor eeuwig -- de poort zou zichzelf voeden.
143 const uit = await AP.followActor(kid, 'https://elders.example/users/webvriend', false, { approved: true });
144 assert.notEqual(uit.held, true);
145});
146
147test('een site ZONDER guardians merkt er niets van', async () => {
148 // Een volwassen account is geen ward. Zou de poort daar ook dichtklappen, dan
149 // kan niemand op deze instance nog iemand volgen.
150 const vrij = site('vrij');
151 const uit = await AP.followActor(vrij, 'https://elders.example/users/iemand');
152 assert.notEqual(uit && uit.held, true);
153});
154
155// ── shaer:following als eigen poort (shaer-p729) ──────────────────────────
156// De uitgaande kant stond als één rij met de inkomende in het paneel, en dan
157// telt een guardian de ene richting en hoort niets over de andere. Nu twee
158// poorten. Het VERSCHIL tussen die twee is opzet: §5.3 eist dat een Follow
159// NAAR een ward langs de guardians gaat, dus die staat vast aan. Over deze
160// richting zegt de FEP niets, dus die is van ons -- en dan hoort hij ook echt
161// losgelaten te kunnen worden.
162
163test('onbeslist betekent dicht voor een ward, net als bij de andere poorten', async () => {
164 const wim = site('wim');
165 guards('wim', local('mum'));
166 const held = await AP.gateOutgoingFollow(wim, 'https://elders.example/users/nieuw');
167 assert.ok(held, 'niemand heeft er iets over besloten, dus vragen we');
168});
169
170test('de guardians kunnen de poort openzetten, en dan vraagt het kind niets meer', async () => {
171 const zoe = site('zoe');
172 guards('zoe', local('mum'));
173 db.prepare('UPDATE sites SET gate_following = 1 WHERE slug = ?').run('zoe');
174 const zoeSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get('zoe');
175 assert.equal(await AP.gateOutgoingFollow(zoeSite, 'https://elders.example/users/nieuw'), null,
176 'een kind dat erin gegroeid is hoeft niet eeuwig te blijven vragen');
177});
178
179test('en weer dicht is ook een besluit', async () => {
180 const zoeSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get('zoe');
181 db.prepare('UPDATE sites SET gate_following = 0 WHERE slug = ?').run('zoe');
182 const dicht = db.prepare('SELECT * FROM sites WHERE slug = ?').get('zoe');
183 assert.ok(await AP.gateOutgoingFollow(dicht, 'https://elders.example/users/weer'),
184 'terugdraaien kan: de poort is reversible');
185 assert.equal(zoeSite.gate_following, 1, '(en de oude rij was echt open)');
186});
187
188test('de twee richtingen tellen apart in het paneel', async () => {
189 const queues = await import('../src/services/guardianship/queues.js');
190 const rows = queues.wardGates('mum', local('kid'));
191 const namen = rows.map((r) => r.feature);
192 assert.ok(namen.includes('shaer:follows'), 'wie het kind wil volgen');
193 assert.ok(namen.includes('shaer:following'), 'en wie het kind wil volgen -- andersom');
194 const uit = rows.find((r) => r.feature === 'shaer:following');
195 assert.equal(uit.adjustable, true, 'deze mag verzet worden');
196 const inn = rows.find((r) => r.feature === 'shaer:follows');
197 assert.equal(inn.adjustable, false, 'en deze niet: §5.3 laat er geen ruimte voor');
198});
Note: See TracBrowser for help on using the repository browser.