source: Klonkt/test/guardianship.test.js@ 1a2f206

main
Last change on this file since 1a2f206 was 6c152a5, checked in by Robin Genis <roboburr@…>, 6 weeks ago

De Undo bij het loslaten van een ward reist nu echt mee

Loslaten was een lokale delete. De guardian vergat het kind, terwijl de server
van het kind hem gewoon in shaer:guardians bleef noemen. De vorige commit zette
dat als waarschuwing in beeld; Robin noemt het terecht een bug, want FEP-633c
3.2 zegt gewoon dat een Undo van de Relationship de guardian eruit haalt.

Nu gaat er een Undo naar het kind en naar de andere guardians, met dezelfde
adressering als de Offer waarmee het begon (3.1.1), zodat geen kopie achterblijft
die denkt dat de band er nog is. De ontvangende kant haalt de guardian eruit,
maar alleen als de guardian zelf tekent: de variant waarbij het kind opzegt met
een mede-ondertekenende guardian heeft een tweede handtekening nodig en is niet
gebouwd, dus die wordt geweigerd in plaats van half uitgevoerd.

De laatste guardian kan niet meer alleen weglopen. 3.3 geldt zolang er meer dan
een over is; de set leegmaken is emancipatie (3.4) en daar gaat geen enkele
partij alleen over. Dat wordt geweigerd aan beide kanten, en de knop biedt in
dat geval alleen nog een nee aan in plaats van een ja die toch een fout geeft.

Een kind op DEZELFDE instance kreeg de Undo niet: een inbox op deze machine is
van deze machine niet over HTTP bereikbaar, en dat hoort ook niet. De commit-kant
lost dat al zo op dat elke instance schrijft wat hij host; het loslaten doet dat
nu ook. In de browser gevonden nadat de guardian-kant leeg was en de kant van het
kind nog niet.

Een guardian-app kan hetzelfde over C2S: een Undo naar de eigen outbox loopt
langs precies dezelfde functie als de knop in de PWA, zodat die twee niet uit
elkaar kunnen groeien.

Changed files:
src/services/guardianship/handshake.js

  • endGuardianship: bouwt en verstuurt de Undo, weigert emancipatie, en schrijft de kant van een lokaal gehost kind zelf
  • applyInboundUndo + dropGuardianFromWard: de ontvangende kant
  • handleOutbox accepteert Undo; handleInbox routeert hem

src/services/guardianship/index.js

  • endGuardianship en parseUndoRelationship geexporteerd

src/services/ActivityPubService.js

  • de guardianship-dispatch ziet Undo nu voordat de generieke Undo-tak hem opslokt met een 202
  • push voor een vertrokken guardian en een vertrokken mede-guardian

src/routes/guardian.js

  • /wards/remove loopt langs endGuardianship in plaats van een lokale delete
  • release-check meldt niet langer dat de Undo blijft liggen

src/assets/js/guardian.js

  • geen ja-knop meer als jij de laatste bent; een 409 wordt getoond in plaats van stil hertekend

src/services/i18n.js

  • de waarschuwing klopt weer, plus push-teksten in nl, en, de

test/guardianship.test.js

  • zes tests: de Undo werkt aan beide kanten, de laatste guardian wordt geweigerd, hij is idempotent, C2S loopt hetzelfde pad, een vreemde Undo verandert niets, en een kind op dezelfde instance wordt ook bijgewerkt terwijl er niets bezorgd is

remarks: end-to-end nagekeken in de browser: na het loslaten staat guard niet
meer in shaer:guardians van het actor-document van het kind, en een POST die de
knop omzeilt krijgt 409 would_emancipate.

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

  • Property mode set to 100644
File size: 12.1 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('only the candidate may offer (§3.1 fixed initiator)', async () => {
124 const r = await G.handleGuardianshipOutbox(parent, {
125 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
126 });
127 assert.equal(r.status, 403);
128 assert.equal(r.error, 'only_the_candidate_offers');
129});
130
131test('helpRequest props only ride direct notes', () => {
132 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
133 assert.equal(G.isHelpRequest({}), false);
134});
135
136// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
137// This used to be a local delete that never left the building: the guardian's
138// dashboard forgot the ward, while the ward's server kept listing them in
139// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
140// At this point in the file the kid has two guardians, parent and gran.
141
142test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
143 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
144
145 const r = await G.endGuardianship(gran, KID);
146 assert.equal(r.status, 202);
147 assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
148
149 // The ward's own actor document is the thing that had to change.
150 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
151 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
152 assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
153 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
154});
155
156test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
157 const r = await G.endGuardianship(parent, KID);
158 assert.equal(r.status, 409);
159 assert.equal(r.error, 'would_emancipate');
160 // Nothing moved on either side. Emptying shaer:guardians takes the flow of
161 // §3.4 (three consenting adults, or a majority plus two witnesses), never one
162 // party's click.
163 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
164 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
165});
166
167test('an Undo for a ward that is not yours is refused', async () => {
168 const r = await G.endGuardianship(gran, KID); // gran already left
169 assert.equal(r.status, 404);
170 assert.equal(r.error, 'not_my_ward');
171});
172
173test('the same Undo over C2S takes the same path', async () => {
174 // A Guardian app POSTs this to its own outbox; the dashboard button calls
175 // endGuardianship directly. One path, so the two cannot drift apart.
176 const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
177 const mine = await G.handleGuardianshipOutbox(gran, undo);
178 assert.equal(mine.status, 404, 'gran no longer guards the kid');
179
180 // And you cannot end someone else's relation by describing it.
181 const notMine = await G.handleGuardianshipOutbox(parent, undo);
182 assert.equal(notMine.status, 403);
183 assert.equal(notMine.error, 'not_your_relation');
184});
185
186test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
187 const before = G.listGuardians('kid').map((g) => g.other_uri);
188 await G.handleGuardianshipInbox(kid, {
189 actor: GRAN, // gran claims to end PARENT's relation
190 type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
191 });
192 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
193});
194
195test('a ward on this same instance is updated even though nothing is delivered', async () => {
196 // The browser found this: an inbox on this machine is not reachable over HTTP
197 // from this machine (nor should it be), so a co-located ward never receives
198 // the Undo. The guardian's side had dropped the ward while the ward's side
199 // still listed the guardian. Each instance must write what it hosts.
200 const kid2 = site('s4', 'kid2');
201 const g1 = site('s5', 'g1');
202 const g2 = site('s6', 'g2');
203 const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
204
205 const o1 = await G.handleGuardianshipOutbox(g1, {
206 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
207 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
208 const o2 = await G.handleGuardianshipOutbox(g2, {
209 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
210 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
211 await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
212 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
213
214 // Now deliver nothing at all, the way a loopback inbox behaves in practice.
215 const wired = {
216 selfId: A,
217 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
218 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
219 fetchActor: async () => null,
220 deliverTo: async () => ({ delivered: false }),
221 onEvent: null,
222 };
223 G.wireHandshake(wired);
224 const r = await G.endGuardianship(g2, KID2);
225 assert.equal(r.status, 202);
226 assert.equal(r.delivered, false, 'nothing went over the wire');
227 assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
228 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
229 assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
230
231 // Even undelivered, it must not empty the set: that is still emancipation.
232 const last = await G.endGuardianship(g1, KID2);
233 assert.equal(last.status, 409);
234 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
235});
Note: See TracBrowser for help on using the repository browser.