source: Klonkt/test/guardianship.test.js@ 69bd747

main
Last change on this file since 69bd747 was 69bd747, checked in by Bart <bart@…>, 5 weeks ago

§4.2 bijgewerkt: wie wat te horen krijgt is niet voor iedereen hetzelfde

De herziene §4.2 maakt twee dingen expliciet die de implementatie van vanmiddag
verkeerd deed.

Eén: de kandidaat krijgt een KALE Reject. Commit is de laatste stap van §3.1,
dus een weigering die zichzelf als technisch aankondigt verklapt meteen dat alle
menselijke partijen al hadden geaccepteerd en alleen het protocol nog bezwaar
maakte. Bij een betwiste guardianship is dat precies wat een partij niet hoort
te weten. De ward en de bestaande guardians krijgen de reden wél: zij zijn
partij, de toestand komt uit publieke data (§2.1), en een stille void laat een
ward geloven dat een adoptie doorging die niet doorging.

Twee: dezelfde controle draait nu ook als de Offer binnenkomt. Daar heeft nog
niemand geaccepteerd, dus daar mag de reden gewoon mee — en een kandidaat die
alleen maar verkeerd geconfigureerd staat komt daar achter op het moment dat
dat nog alles is wat het betekent. De controle bij commit blijft verplicht als
vangnet voor wie tussendoor van toestand verandert.

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

  • Property mode set to 100644
File size: 15.9 KB
Line 
1// The guardianship module (FEP-633c) — the multi-party handshake (§3).
2// Everyone lives on one in-memory instance here, so the handshake copies all
3// converge locally; that also exercises the "multiple local parties" routing.
4import { test } from 'node:test';
5import assert from 'node:assert/strict';
6
7process.env.DATABASE_PATH = ':memory:';
8process.env.PUBLIC_BASE_URL = 'https://test.example';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const AP = (await import('../src/services/ActivityPubService.js')).default;
14const G = await import('../src/services/guardianship/index.js');
15
16function site(id, slug) {
17 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
18 return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
19}
20db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
21const parent = site('s1', 'parent'); // first guardian-candidate
22const kid = site('s2', 'kid'); // ward
23const gran = site('s3', 'gran'); // second guardian-candidate (co-approver later)
24const A = (slug) => `https://test.example/ap/users/${slug}`;
25const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')];
26
27// No network: the handshake delivers by feeding each activity straight into the
28// inbound handler of every addressed local party (what real S2S would do).
29G.wireHandshake({
30 selfId: A,
31 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
32 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
33 fetchActor: async () => null,
34 deliverTo: async (fromSite, toUri, activity) => {
35 const slug = toUri.split('/').pop();
36 const s = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
37 if (s) await G.handleGuardianshipInbox(s, activity);
38 return { delivered: true };
39 },
40 onEvent: null,
41});
42
43const offerIdFrom = (r) => r.id;
44
45test('first guardian: a free ward commits on its own single accept', async () => {
46 const off = await G.handleGuardianshipOutbox(parent, {
47 type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
48 });
49 assert.equal(off.status, 202);
50 const id = offerIdFrom(off);
51
52 // The kid sees the offer needing its accept; the candidate already agreed
53 // (the Offer is the candidate's accept), so it just waits.
54 const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID).orderedItems;
55 assert.equal(kidQ.length, 1);
56 assert.equal(kidQ[0]['shaer:needsMyAccept'], true);
57 assert.deepEqual(kidQ[0]['shaer:acceptedBy'], [ME]); // candidate accepted via the offer
58 const parentQ0 = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems;
59 assert.equal(parentQ0[0]['shaer:needsMyAccept'], false); // candidate already agreed
60
61 // Not committed until the ward agrees.
62 assert.deepEqual(G.listGuardians('kid'), []);
63
64 // The kid accepts → free ward, no existing guardian to co-approve → commit.
65 const done = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
66 assert.equal(done.committed, true);
67 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
68 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
69 // other_handle is the display @handle (not the escalation inbox handle).
70 assert.equal(G.listGuardians('kid')[0].other_handle, '@parent@test.example');
71 assert.equal(G.listWards('parent')[0].other_handle, '@kid@test.example');
72
73 // The ward actor now names its guardian; parent reads as guardian (§2).
74 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
75 assert.equal(AP.buildActor('https://test.example', parent)['shaer:isGuardian'], true);
76 // §1 mutual exclusion: the ward is not also a guardian.
77 assert.equal(AP.buildActor('https://test.example', kid)['shaer:isGuardian'], undefined);
78});
79
80test('second guardian needs the EXISTING guardian to co-accept (§3.1.2)', async () => {
81 // Gran offers to also guard the kid (who already has parent).
82 const off = await G.handleGuardianshipOutbox(gran, {
83 type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN },
84 });
85 const id = offerIdFrom(off);
86 // The existing guardian (parent) is a party and must accept.
87 const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
88 assert.ok(parentQ, 'parent sees the co-guardianship offer');
89 assert.deepEqual(parentQ['shaer:existingGuardians'], [ME]);
90
91 // The kid accepts, but now it IS a ward: NOT committed, because the existing
92 // guardian (parent) has not co-accepted (§3.1.2). The candidate (gran) already
93 // agreed via the offer, so no separate gran accept is needed.
94 const early = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
95 assert.equal(early.committed, false);
96 assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
97
98 // The existing guardian co-accepts → tally complete → commit.
99 await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
100 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [GRAN, ME].sort());
101});
102
103test('a single Reject from a required party voids the offer (§3.2)', async () => {
104 // parent offers to guard gran (who is free).
105 const off = await G.handleGuardianshipOutbox(parent, {
106 type: 'Offer', object: { type: 'Relationship', subject: GRAN, relationship: 'shaer:Guardian', object: ME },
107 });
108 const id = offerIdFrom(off);
109 await G.handleGuardianshipOutbox(gran, { type: 'Reject', object: id });
110 const q = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
111 assert.equal(q, undefined, 'voided offer leaves the queue');
112 assert.equal(G.listWards('parent').some((w) => w.other_uri === GRAN), false);
113});
114
115test('a ward cannot become a guardian (§1)', async () => {
116 const r = await G.handleGuardianshipOutbox(kid, {
117 type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
118 });
119 assert.equal(r.status, 403);
120 assert.equal(r.error, 'a_ward_cannot_guard');
121});
122
123test('a candidate adopted between Offer and Accept is refused at commit (§4.2)', async () => {
124 // The case the §1 check above structurally cannot catch. Tess is free when
125 // she offers, so the Offer is legitimate and accepted. Only afterwards does
126 // she become a ward herself. An implementation that checks the candidate
127 // only when the Offer arrives would commit her anyway, and Sam would be left
128 // counting a guardian whose escalations get dropped (§4.1).
129 // Fresh actors throughout: the suite shares one database, so adopting Tess
130 // with an existing guardian would hand that guardian an extra ward and
131 // quietly change the arithmetic of the emancipation tests further down.
132 const tess = site('s10', 'tess');
133 const sam = site('s11', 'sam');
134 const ada = site('s12', 'ada');
135 const [TESS, SAM, ADA] = [A('tess'), A('sam'), A('ada')];
136
137 // 1. Tess offers to guard Sam while she is still free of guardians.
138 const off = await G.handleGuardianshipOutbox(tess, {
139 type: 'Offer', object: { type: 'Relationship', subject: SAM, relationship: 'shaer:Guardian', object: TESS },
140 });
141 assert.equal(off.status, 202, 'a free candidate may offer');
142 const id = off.id;
143 assert.deepEqual(G.listGuardians('sam'), [], 'nothing committed until Sam accepts');
144
145 // 2. Before Sam answers, Tess is adopted: she is now a ward herself.
146 const adopt = await G.handleGuardianshipOutbox(ada, {
147 type: 'Offer', object: { type: 'Relationship', subject: TESS, relationship: 'shaer:Guardian', object: ADA },
148 });
149 await G.handleGuardianshipOutbox(tess, { type: 'Accept', object: adopt.id });
150 assert.equal(G.listGuardians('tess').length, 1, 'Tess is a ward now');
151
152 // 3. Sam accepts. The tally is complete, so this WOULD commit.
153 const done = await G.handleGuardianshipOutbox(sam, { type: 'Accept', object: id });
154 assert.equal(done.committed, false, 'but a ward cannot serve as a guardian (§1)');
155 assert.equal(done.refused, 'not_a_teapot');
156
157 // The refusal is loud, not a silent skip: nothing recorded, offer voided.
158 assert.deepEqual(G.listGuardians('sam'), [], 'Sam gains no guardian');
159 assert.deepEqual(G.listWards('tess').map((w) => w.other_uri), [], 'and Tess gains no ward');
160 const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id);
161 assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging');
162});
163
164test('an Offer from a candidate that is already a ward is refused on arrival (§4.2)', async () => {
165 // The kind path. Nobody has accepted anything yet, so refusing here
166 // discloses nothing about anyone's position, and a candidate who is merely
167 // misconfigured gets told what is wrong while that is still all it means.
168 const viv = site('s13', 'viv'); // adopted first, then tries to guard
169 const zed = site('s14', 'zed'); // the would-be ward
170 const bo = site('s15', 'bo'); // adopts Viv
171 const [VIV, ZED, BO] = [A('viv'), A('zed'), A('bo')];
172
173 const adopt = await G.handleGuardianshipOutbox(bo, {
174 type: 'Offer', object: { type: 'Relationship', subject: VIV, relationship: 'shaer:Guardian', object: BO },
175 });
176 await G.handleGuardianshipOutbox(viv, { type: 'Accept', object: adopt.id });
177 assert.equal(G.listGuardians('viv').length, 1, 'Viv is a ward');
178
179 const handled = await G.handleGuardianshipInbox(zed, {
180 id: `${VIV}/offers/x1`, type: 'Offer', actor: VIV, to: [ZED],
181 object: { type: 'Relationship', subject: ZED, relationship: 'shaer:Guardian', object: VIV },
182 });
183 assert.equal(handled, true, 'the activity is handled — and handling it means refusing it');
184 assert.deepEqual(
185 G.offersCollection(`${ZED}/queues/offers`, 'zed', ZED).orderedItems, [],
186 'never stored, so it never sits in Zed\'s queue looking like a decision to make',
187 );
188 assert.deepEqual(G.listGuardians('zed'), []);
189});
190
191test('only the candidate may offer (§3.1 fixed initiator)', async () => {
192 const r = await G.handleGuardianshipOutbox(parent, {
193 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
194 });
195 assert.equal(r.status, 403);
196 assert.equal(r.error, 'only_the_candidate_offers');
197});
198
199test('helpRequest props only ride direct notes', () => {
200 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
201 assert.equal(G.isHelpRequest({}), false);
202});
203
204// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
205// This used to be a local delete that never left the building: the guardian's
206// dashboard forgot the ward, while the ward's server kept listing them in
207// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
208// At this point in the file the kid has two guardians, parent and gran.
209
210test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
211 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
212
213 const r = await G.endGuardianship(gran, KID);
214 assert.equal(r.status, 202);
215 assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
216
217 // The ward's own actor document is the thing that had to change.
218 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
219 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
220 assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
221 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
222});
223
224test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
225 const r = await G.endGuardianship(parent, KID);
226 assert.equal(r.status, 409);
227 assert.equal(r.error, 'would_emancipate');
228 // Nothing moved on either side. Emptying shaer:guardians takes the flow of
229 // §3.4 (three consenting adults, or a majority plus two witnesses), never one
230 // party's click.
231 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
232 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
233});
234
235test('an Undo for a ward that is not yours is refused', async () => {
236 const r = await G.endGuardianship(gran, KID); // gran already left
237 assert.equal(r.status, 404);
238 assert.equal(r.error, 'not_my_ward');
239});
240
241test('the same Undo over C2S takes the same path', async () => {
242 // A Guardian app POSTs this to its own outbox; the dashboard button calls
243 // endGuardianship directly. One path, so the two cannot drift apart.
244 const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
245 const mine = await G.handleGuardianshipOutbox(gran, undo);
246 assert.equal(mine.status, 404, 'gran no longer guards the kid');
247
248 // And you cannot end someone else's relation by describing it.
249 const notMine = await G.handleGuardianshipOutbox(parent, undo);
250 assert.equal(notMine.status, 403);
251 assert.equal(notMine.error, 'not_your_relation');
252});
253
254test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
255 const before = G.listGuardians('kid').map((g) => g.other_uri);
256 await G.handleGuardianshipInbox(kid, {
257 actor: GRAN, // gran claims to end PARENT's relation
258 type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
259 });
260 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
261});
262
263test('a ward on this same instance is updated even though nothing is delivered', async () => {
264 // The browser found this: an inbox on this machine is not reachable over HTTP
265 // from this machine (nor should it be), so a co-located ward never receives
266 // the Undo. The guardian's side had dropped the ward while the ward's side
267 // still listed the guardian. Each instance must write what it hosts.
268 const kid2 = site('s4', 'kid2');
269 const g1 = site('s5', 'g1');
270 const g2 = site('s6', 'g2');
271 const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
272
273 const o1 = await G.handleGuardianshipOutbox(g1, {
274 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
275 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
276 const o2 = await G.handleGuardianshipOutbox(g2, {
277 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
278 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
279 await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
280 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
281
282 // Now deliver nothing at all, the way a loopback inbox behaves in practice.
283 const wired = {
284 selfId: A,
285 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
286 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
287 fetchActor: async () => null,
288 deliverTo: async () => ({ delivered: false }),
289 onEvent: null,
290 };
291 G.wireHandshake(wired);
292 const r = await G.endGuardianship(g2, KID2);
293 assert.equal(r.status, 202);
294 assert.equal(r.delivered, false, 'nothing went over the wire');
295 assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
296 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
297 assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
298
299 // Even undelivered, it must not empty the set: that is still emancipation.
300 const last = await G.endGuardianship(g1, KID2);
301 assert.equal(last.status, 409);
302 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
303});
Note: See TracBrowser for help on using the repository browser.