source: Klonkt/test/guardianship.test.js@ 30d0e2c

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

Een handshake blijft een week open, en faalt daarna onder zijn eigen naam

§4.2 leunt erop dat het uitstel begrensd is: als het venster sluit met de
controle nog onbeslist, faalt de beslissing dicht. Alleen had een
guardianship-offer in Klonkt helemaal geen venster, dus "uitgesteld" was
"voor altijd".

Nu een week. Lang genoeg dat niemand wordt opgejaagd — er moeten een ward, een
kandidaat en alle bestaande guardians antwoorden, en dat zijn mensen — en kort
genoeg dat een vergeten aanbod niet een maand in de wachtrij van een kind staat
alsof het nog een keuze is.

Twee eindtoestanden, want het zijn twee verschillende feiten:
'expired' (niemand heeft geantwoord; zegt niets over de kandidaat) en
'unverified' (iedereen heeft geantwoord, maar de kandidaat was nooit op te
halen). Geen van beide is 'void': de partijen mag niet verteld worden dat de
kandidaat geweigerd is, want dat is niet gebeurd — er heeft alleen nooit iemand
kunnen kijken.

Vervallen gebeurt bij het lezen, zoals een lapse dat ook doet: geen sweeper die
niemand draait. En het lezen van de wachtrij is meteen het moment waarop een
uitgestelde commit opnieuw wordt geprobeerd (§4.2 SHOULD) — nodig, want de
laatste Accept kan al binnen zijn en dan port niemand er ooit nog aan. Niet
awaited: een poll toont wat nu waar is.

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

  • Property mode set to 100644
File size: 18.6 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('an unreadable candidate defers the commit, and the window names that failure (§4.2)', async () => {
192 const offers = await import('../src/services/guardianship/offers.js');
193 const noor = site('s16', 'noor');
194 const NOOR = A('noor');
195 const MARA = 'https://elders.example/users/mara'; // remote; fetchActor returns null here
196 const offerId = `${MARA}/offers/m1`;
197
198 // The Offer arrives and is STORED: unreachable is not malformed, and
199 // refusing on a failed fetch would let any outage block an adoption.
200 const taken = await G.handleGuardianshipInbox(noor, {
201 id: offerId, type: 'Offer', actor: MARA, to: [NOOR],
202 object: { type: 'Relationship', subject: NOOR, relationship: 'shaer:Guardian', object: MARA },
203 });
204 assert.equal(taken, true);
205
206 // Noor accepts. Free ward + candidate's own offer = the tally is complete,
207 // so this WOULD commit — except the candidate cannot be read.
208 const done = await G.handleGuardianshipOutbox(noor, { type: 'Accept', object: offerId });
209 assert.equal(done.committed, false, 'not committed: nobody verified the candidate');
210 assert.ok(!done.refused, 'and not refused either — that would blame a candidate nobody could look at');
211 assert.deepEqual(G.listGuardians('noor'), []);
212 assert.equal(offers.getOffer('noor', offerId).status, 'pending', 'deferred, not decided');
213
214 // A week later the §3.5 window closes and it fails closed — under its own
215 // name. Not 'void': the parties must not be told the candidate was refused.
216 const later = Date.now() + offers.OFFER_WINDOW_MS + 1000;
217 offers.expireIfDue('noor', offerId, later);
218 assert.equal(offers.getOffer('noor', offerId).status, 'unverified');
219
220 const q = G.offersCollection(`${NOOR}/queues/offers`, 'noor', NOOR).orderedItems;
221 assert.deepEqual(q.filter((o) => o.id === offerId), [], 'and it stops looking like a live choice');
222});
223
224test('a handshake nobody finished expires under a different name (§3.5)', async () => {
225 const offers = await import('../src/services/guardianship/offers.js');
226 const finn = site('s17', 'finn');
227 const iris = site('s18', 'iris');
228 const [FINN, IRIS] = [A('finn'), A('iris')];
229
230 const off = await G.handleGuardianshipOutbox(finn, {
231 type: 'Offer', object: { type: 'Relationship', subject: IRIS, relationship: 'shaer:Guardian', object: FINN },
232 });
233 // Iris never answers. Reading the queue after the window settles it.
234 G.offersCollection(`${IRIS}/queues/offers`, 'iris', IRIS); // still open now
235 assert.equal(offers.getOffer('iris', off.id).status, 'pending');
236 offers.listForParty('iris', IRIS, Date.now() + offers.OFFER_WINDOW_MS + 1000);
237 assert.equal(offers.getOffer('iris', off.id).status, 'expired',
238 'nobody answered — that says nothing about the candidate, so it is not "unverified"');
239});
240
241test('only the candidate may offer (§3.1 fixed initiator)', async () => {
242 const r = await G.handleGuardianshipOutbox(parent, {
243 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
244 });
245 assert.equal(r.status, 403);
246 assert.equal(r.error, 'only_the_candidate_offers');
247});
248
249test('helpRequest props only ride direct notes', () => {
250 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
251 assert.equal(G.isHelpRequest({}), false);
252});
253
254// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
255// This used to be a local delete that never left the building: the guardian's
256// dashboard forgot the ward, while the ward's server kept listing them in
257// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
258// At this point in the file the kid has two guardians, parent and gran.
259
260test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
261 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
262
263 const r = await G.endGuardianship(gran, KID);
264 assert.equal(r.status, 202);
265 assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
266
267 // The ward's own actor document is the thing that had to change.
268 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
269 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
270 assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
271 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
272});
273
274test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
275 const r = await G.endGuardianship(parent, KID);
276 assert.equal(r.status, 409);
277 assert.equal(r.error, 'would_emancipate');
278 // Nothing moved on either side. Emptying shaer:guardians takes the flow of
279 // §3.4 (three consenting adults, or a majority plus two witnesses), never one
280 // party's click.
281 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
282 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
283});
284
285test('an Undo for a ward that is not yours is refused', async () => {
286 const r = await G.endGuardianship(gran, KID); // gran already left
287 assert.equal(r.status, 404);
288 assert.equal(r.error, 'not_my_ward');
289});
290
291test('the same Undo over C2S takes the same path', async () => {
292 // A Guardian app POSTs this to its own outbox; the dashboard button calls
293 // endGuardianship directly. One path, so the two cannot drift apart.
294 const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
295 const mine = await G.handleGuardianshipOutbox(gran, undo);
296 assert.equal(mine.status, 404, 'gran no longer guards the kid');
297
298 // And you cannot end someone else's relation by describing it.
299 const notMine = await G.handleGuardianshipOutbox(parent, undo);
300 assert.equal(notMine.status, 403);
301 assert.equal(notMine.error, 'not_your_relation');
302});
303
304test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
305 const before = G.listGuardians('kid').map((g) => g.other_uri);
306 await G.handleGuardianshipInbox(kid, {
307 actor: GRAN, // gran claims to end PARENT's relation
308 type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
309 });
310 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
311});
312
313test('a ward on this same instance is updated even though nothing is delivered', async () => {
314 // The browser found this: an inbox on this machine is not reachable over HTTP
315 // from this machine (nor should it be), so a co-located ward never receives
316 // the Undo. The guardian's side had dropped the ward while the ward's side
317 // still listed the guardian. Each instance must write what it hosts.
318 const kid2 = site('s4', 'kid2');
319 const g1 = site('s5', 'g1');
320 const g2 = site('s6', 'g2');
321 const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
322
323 const o1 = await G.handleGuardianshipOutbox(g1, {
324 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
325 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
326 const o2 = await G.handleGuardianshipOutbox(g2, {
327 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
328 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
329 await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
330 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
331
332 // Now deliver nothing at all, the way a loopback inbox behaves in practice.
333 const wired = {
334 selfId: A,
335 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
336 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
337 fetchActor: async () => null,
338 deliverTo: async () => ({ delivered: false }),
339 onEvent: null,
340 };
341 G.wireHandshake(wired);
342 const r = await G.endGuardianship(g2, KID2);
343 assert.equal(r.status, 202);
344 assert.equal(r.delivered, false, 'nothing went over the wire');
345 assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
346 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
347 assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
348
349 // Even undelivered, it must not empty the set: that is still emancipation.
350 const last = await G.endGuardianship(g1, KID2);
351 assert.equal(last.status, 409);
352 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
353});
Note: See TracBrowser for help on using the repository browser.