source: Klonkt/test/guardianship.test.js@ 0261730

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

guardianship: §1 is flat in beide richtingen, ook bij accept

Het Offer-pad belet een ward om te offeren (a_ward_cannot_guard), maar het
Accept-pad kende geen spiegelbeeld: een actor die al wards heeft kon een
guardianship-Offer accepteren en werd zo ward én guardian tegelijk. §1 eist
flatheid in beide richtingen.

De fout zou zich bovendien niet melden. actorProps() kiest met een if/else
één rol en publiceert dan shaer:guardians terwijl shaer:isGuardian wegvalt:
lokaal blijven escalaties van de eigen wards binnenkomen, terwijl elke
remote §4-check de actor als malformed leest en laat vallen. Een ward die
denkt bewaakt te zijn en het niet is, stil aan beide kanten.

Alleen de accept van de ward kan die toestand maken, dus de kandidaat en de
bestaande guardians lopen er ongemoeid langs.

721 tests groen. Controleproef gedraaid: zonder de controle wordt de nieuwe
test rood en valt ook 'a guardian leaving sends an Undo (§3.2)' om, doordat
de actor dan werkelijk ward wordt.

Refs shaer-gk2; stable (1.6.0) heeft dezelfde omissie, zie shaer-pmo.

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

  • Property mode set to 100644
File size: 20.0 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 guardian cannot be guarded either: §1 is flat in both directions', async () => {
124 // parent guards kid by now, and gran (a guardian, so free to offer) offers
125 // to guard parent. The offer itself is fine; only parent's accept would
126 // make one actor ward and guardian at once.
127 assert.ok(G.listWards('parent').length, 'parent already guards someone');
128 const off = await G.handleGuardianshipOutbox(gran, {
129 type: 'Offer', object: { type: 'Relationship', subject: ME, relationship: 'shaer:Guardian', object: GRAN },
130 });
131 assert.equal(off.status, 202, 'a guardian may still offer to guard');
132 const id = offerIdFrom(off);
133
134 const r = await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
135 assert.equal(r.status, 403);
136 assert.equal(r.error, 'a_guardian_cannot_be_guarded');
137
138 // Nothing recorded, and the actor document still reads as a pure guardian.
139 // Without the check actorProps() would have flipped it to a ward silently.
140 assert.deepEqual(G.listGuardians('parent'), []);
141 const doc = AP.buildActor('https://test.example', parent);
142 assert.equal(doc['shaer:isGuardian'], true);
143 assert.equal(doc['shaer:guardians'], undefined);
144
145 // The candidate and the existing guardians are untouched by the check, so
146 // the offer is refusable in the ordinary way rather than stuck.
147 await G.handleGuardianshipOutbox(parent, { type: 'Reject', object: id });
148});
149
150test('a candidate adopted between Offer and Accept is refused at commit (§4.2)', async () => {
151 // The case the §1 check above structurally cannot catch. Tess is free when
152 // she offers, so the Offer is legitimate and accepted. Only afterwards does
153 // she become a ward herself. An implementation that checks the candidate
154 // only when the Offer arrives would commit her anyway, and Sam would be left
155 // counting a guardian whose escalations get dropped (§4.1).
156 // Fresh actors throughout: the suite shares one database, so adopting Tess
157 // with an existing guardian would hand that guardian an extra ward and
158 // quietly change the arithmetic of the emancipation tests further down.
159 const tess = site('s10', 'tess');
160 const sam = site('s11', 'sam');
161 const ada = site('s12', 'ada');
162 const [TESS, SAM, ADA] = [A('tess'), A('sam'), A('ada')];
163
164 // 1. Tess offers to guard Sam while she is still free of guardians.
165 const off = await G.handleGuardianshipOutbox(tess, {
166 type: 'Offer', object: { type: 'Relationship', subject: SAM, relationship: 'shaer:Guardian', object: TESS },
167 });
168 assert.equal(off.status, 202, 'a free candidate may offer');
169 const id = off.id;
170 assert.deepEqual(G.listGuardians('sam'), [], 'nothing committed until Sam accepts');
171
172 // 2. Before Sam answers, Tess is adopted: she is now a ward herself.
173 const adopt = await G.handleGuardianshipOutbox(ada, {
174 type: 'Offer', object: { type: 'Relationship', subject: TESS, relationship: 'shaer:Guardian', object: ADA },
175 });
176 await G.handleGuardianshipOutbox(tess, { type: 'Accept', object: adopt.id });
177 assert.equal(G.listGuardians('tess').length, 1, 'Tess is a ward now');
178
179 // 3. Sam accepts. The tally is complete, so this WOULD commit.
180 const done = await G.handleGuardianshipOutbox(sam, { type: 'Accept', object: id });
181 assert.equal(done.committed, false, 'but a ward cannot serve as a guardian (§1)');
182 assert.equal(done.refused, 'not_a_teapot');
183
184 // The refusal is loud, not a silent skip: nothing recorded, offer voided.
185 assert.deepEqual(G.listGuardians('sam'), [], 'Sam gains no guardian');
186 assert.deepEqual(G.listWards('tess').map((w) => w.other_uri), [], 'and Tess gains no ward');
187 const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id);
188 assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging');
189});
190
191test('an Offer from a candidate that is already a ward is refused on arrival (§4.2)', async () => {
192 // The kind path. Nobody has accepted anything yet, so refusing here
193 // discloses nothing about anyone's position, and a candidate who is merely
194 // misconfigured gets told what is wrong while that is still all it means.
195 const viv = site('s13', 'viv'); // adopted first, then tries to guard
196 const zed = site('s14', 'zed'); // the would-be ward
197 const bo = site('s15', 'bo'); // adopts Viv
198 const [VIV, ZED, BO] = [A('viv'), A('zed'), A('bo')];
199
200 const adopt = await G.handleGuardianshipOutbox(bo, {
201 type: 'Offer', object: { type: 'Relationship', subject: VIV, relationship: 'shaer:Guardian', object: BO },
202 });
203 await G.handleGuardianshipOutbox(viv, { type: 'Accept', object: adopt.id });
204 assert.equal(G.listGuardians('viv').length, 1, 'Viv is a ward');
205
206 const handled = await G.handleGuardianshipInbox(zed, {
207 id: `${VIV}/offers/x1`, type: 'Offer', actor: VIV, to: [ZED],
208 object: { type: 'Relationship', subject: ZED, relationship: 'shaer:Guardian', object: VIV },
209 });
210 assert.equal(handled, true, 'the activity is handled — and handling it means refusing it');
211 assert.deepEqual(
212 G.offersCollection(`${ZED}/queues/offers`, 'zed', ZED).orderedItems, [],
213 'never stored, so it never sits in Zed\'s queue looking like a decision to make',
214 );
215 assert.deepEqual(G.listGuardians('zed'), []);
216});
217
218test('an unreadable candidate defers the commit, and the window names that failure (§4.2)', async () => {
219 const offers = await import('../src/services/guardianship/offers.js');
220 const noor = site('s16', 'noor');
221 const NOOR = A('noor');
222 const MARA = 'https://elders.example/users/mara'; // remote; fetchActor returns null here
223 const offerId = `${MARA}/offers/m1`;
224
225 // The Offer arrives and is STORED: unreachable is not malformed, and
226 // refusing on a failed fetch would let any outage block an adoption.
227 const taken = await G.handleGuardianshipInbox(noor, {
228 id: offerId, type: 'Offer', actor: MARA, to: [NOOR],
229 object: { type: 'Relationship', subject: NOOR, relationship: 'shaer:Guardian', object: MARA },
230 });
231 assert.equal(taken, true);
232
233 // Noor accepts. Free ward + candidate's own offer = the tally is complete,
234 // so this WOULD commit — except the candidate cannot be read.
235 const done = await G.handleGuardianshipOutbox(noor, { type: 'Accept', object: offerId });
236 assert.equal(done.committed, false, 'not committed: nobody verified the candidate');
237 assert.ok(!done.refused, 'and not refused either — that would blame a candidate nobody could look at');
238 assert.deepEqual(G.listGuardians('noor'), []);
239 assert.equal(offers.getOffer('noor', offerId).status, 'pending', 'deferred, not decided');
240
241 // A week later the §3.5 window closes and it fails closed — under its own
242 // name. Not 'void': the parties must not be told the candidate was refused.
243 const later = Date.now() + offers.OFFER_WINDOW_MS + 1000;
244 offers.expireIfDue('noor', offerId, later);
245 assert.equal(offers.getOffer('noor', offerId).status, 'unverified');
246
247 const q = G.offersCollection(`${NOOR}/queues/offers`, 'noor', NOOR).orderedItems;
248 assert.deepEqual(q.filter((o) => o.id === offerId), [], 'and it stops looking like a live choice');
249});
250
251test('a handshake nobody finished expires under a different name (§3.5)', async () => {
252 const offers = await import('../src/services/guardianship/offers.js');
253 const finn = site('s17', 'finn');
254 const iris = site('s18', 'iris');
255 const [FINN, IRIS] = [A('finn'), A('iris')];
256
257 const off = await G.handleGuardianshipOutbox(finn, {
258 type: 'Offer', object: { type: 'Relationship', subject: IRIS, relationship: 'shaer:Guardian', object: FINN },
259 });
260 // Iris never answers. Reading the queue after the window settles it.
261 G.offersCollection(`${IRIS}/queues/offers`, 'iris', IRIS); // still open now
262 assert.equal(offers.getOffer('iris', off.id).status, 'pending');
263 offers.listForParty('iris', IRIS, Date.now() + offers.OFFER_WINDOW_MS + 1000);
264 assert.equal(offers.getOffer('iris', off.id).status, 'expired',
265 'nobody answered — that says nothing about the candidate, so it is not "unverified"');
266});
267
268test('only the candidate may offer (§3.1 fixed initiator)', async () => {
269 const r = await G.handleGuardianshipOutbox(parent, {
270 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
271 });
272 assert.equal(r.status, 403);
273 assert.equal(r.error, 'only_the_candidate_offers');
274});
275
276test('helpRequest props only ride direct notes', () => {
277 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
278 assert.equal(G.isHelpRequest({}), false);
279});
280
281// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
282// This used to be a local delete that never left the building: the guardian's
283// dashboard forgot the ward, while the ward's server kept listing them in
284// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
285// At this point in the file the kid has two guardians, parent and gran.
286
287test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
288 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
289
290 const r = await G.endGuardianship(gran, KID);
291 assert.equal(r.status, 202);
292 assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
293
294 // The ward's own actor document is the thing that had to change.
295 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
296 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
297 assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
298 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
299});
300
301test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
302 const r = await G.endGuardianship(parent, KID);
303 assert.equal(r.status, 409);
304 assert.equal(r.error, 'would_emancipate');
305 // Nothing moved on either side. Emptying shaer:guardians takes the flow of
306 // §3.4 (three consenting adults, or a majority plus two witnesses), never one
307 // party's click.
308 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
309 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
310});
311
312test('an Undo for a ward that is not yours is refused', async () => {
313 const r = await G.endGuardianship(gran, KID); // gran already left
314 assert.equal(r.status, 404);
315 assert.equal(r.error, 'not_my_ward');
316});
317
318test('the same Undo over C2S takes the same path', async () => {
319 // A Guardian app POSTs this to its own outbox; the dashboard button calls
320 // endGuardianship directly. One path, so the two cannot drift apart.
321 const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
322 const mine = await G.handleGuardianshipOutbox(gran, undo);
323 assert.equal(mine.status, 404, 'gran no longer guards the kid');
324
325 // And you cannot end someone else's relation by describing it.
326 const notMine = await G.handleGuardianshipOutbox(parent, undo);
327 assert.equal(notMine.status, 403);
328 assert.equal(notMine.error, 'not_your_relation');
329});
330
331test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
332 const before = G.listGuardians('kid').map((g) => g.other_uri);
333 await G.handleGuardianshipInbox(kid, {
334 actor: GRAN, // gran claims to end PARENT's relation
335 type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
336 });
337 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
338});
339
340test('a ward on this same instance is updated even though nothing is delivered', async () => {
341 // The browser found this: an inbox on this machine is not reachable over HTTP
342 // from this machine (nor should it be), so a co-located ward never receives
343 // the Undo. The guardian's side had dropped the ward while the ward's side
344 // still listed the guardian. Each instance must write what it hosts.
345 const kid2 = site('s4', 'kid2');
346 const g1 = site('s5', 'g1');
347 const g2 = site('s6', 'g2');
348 const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
349
350 const o1 = await G.handleGuardianshipOutbox(g1, {
351 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
352 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
353 const o2 = await G.handleGuardianshipOutbox(g2, {
354 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
355 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
356 await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
357 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
358
359 // Now deliver nothing at all, the way a loopback inbox behaves in practice.
360 const wired = {
361 selfId: A,
362 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
363 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
364 fetchActor: async () => null,
365 deliverTo: async () => ({ delivered: false }),
366 onEvent: null,
367 };
368 G.wireHandshake(wired);
369 const r = await G.endGuardianship(g2, KID2);
370 assert.equal(r.status, 202);
371 assert.equal(r.delivered, false, 'nothing went over the wire');
372 assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
373 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
374 assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
375
376 // Even undelivered, it must not empty the set: that is still emancipation.
377 const last = await G.endGuardianship(g1, KID2);
378 assert.equal(last.status, 409);
379 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
380});
Note: See TracBrowser for help on using the repository browser.