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

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

FEP-633c §4.2: een ward wordt geen guardian, ook niet stiekem

Klonkt weigerde een Offer van een bekende ward (a_ward_cannot_guard), maar
controleerde de kandidaat daarna nooit meer. Wie vrij was bij het aanbod en
daarna zelf geadopteerd werd, kwam er alsnog doorheen: de ward telde een
guardian wiens escalaties bij aflevering worden weggegooid (§4.1). De commit is
de onomkeerbare stap, dus daar moet het houden.

maybeCommit controleert nu de kandidaat vlak voor het schrijven. Drie
uitkomsten, en de derde is geen falen van de controle maar het niet kunnen
uitvoeren ervan: 'ok' commit, 'malformed' weigert luid, 'unverified' doet geen
van beide. Niet voiden bij onbereikbaar, want dan sloopt één hik een
meerpartijen-adoptie; niet committen ook niet, want dan leg je een guardian
vast die niemand gecontroleerd heeft. Het aanbod blijft staan.

De weigering is luid: het aanbod wordt void, niets wordt vastgelegd, en de
handelende partij stuurt een Reject (met shaer:notATeapot als reden). Een
Reject is wat §3 al kent, dus een server die nog nooit van §4 gehoord heeft
ruimt zijn kopie gewoon op.

Een lokale kandidaat wordt in onze eigen tabel opgezocht in plaats van bij
onszelf opgehaald. De co-location-guard in de tests ving dat ik daarnaast nog
een tweede lokale tak in een beslispad had gezet, voor het versturen van de
Reject; die is weg. De Reject vertrekt nu gewoon van wie er aan het handelen
was, zonder te vragen wie waar woont.

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

  • Property mode set to 100644
File size: 14.5 KB
RevLine 
[780a7c6]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.
[e61c289]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
[780a7c6]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}
[e61c289]20db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
[780a7c6]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).
[e61c289]29G.wireHandshake({
[780a7c6]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 },
[e61c289]40 onEvent: null,
41});
42
[780a7c6]43const offerIdFrom = (r) => r.id;
[e61c289]44
[3ffbedd]45test('first guardian: a free ward commits on its own single accept', async () => {
[780a7c6]46 const off = await G.handleGuardianshipOutbox(parent, {
47 type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
[e61c289]48 });
[780a7c6]49 assert.equal(off.status, 202);
50 const id = offerIdFrom(off);
[e61c289]51
[3ffbedd]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.
[780a7c6]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);
[3ffbedd]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
[780a7c6]60
[3ffbedd]61 // Not committed until the ward agrees.
[780a7c6]62 assert.deepEqual(G.listGuardians('kid'), []);
63
[3ffbedd]64 // The kid accepts → free ward, no existing guardian to co-approve → commit.
65 const done = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
[780a7c6]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]);
[fcd6964]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');
[780a7c6]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);
[e61c289]78});
79
[780a7c6]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 },
[e61c289]84 });
[780a7c6]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]);
[e61c289]90
[3ffbedd]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 });
[780a7c6]95 assert.equal(early.committed, false);
96 assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
[e61c289]97
[780a7c6]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());
[e61c289]101});
102
[780a7c6]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);
[e61c289]113});
114
[780a7c6]115test('a ward cannot become a guardian (§1)', async () => {
[e61c289]116 const r = await G.handleGuardianshipOutbox(kid, {
[780a7c6]117 type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
[e61c289]118 });
119 assert.equal(r.status, 403);
120 assert.equal(r.error, 'a_ward_cannot_guard');
121});
122
[5327324]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
[780a7c6]164test('only the candidate may offer (§3.1 fixed initiator)', async () => {
165 const r = await G.handleGuardianshipOutbox(parent, {
166 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
167 });
168 assert.equal(r.status, 403);
169 assert.equal(r.error, 'only_the_candidate_offers');
170});
171
[e61c289]172test('helpRequest props only ride direct notes', () => {
173 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
174 assert.equal(G.isHelpRequest({}), false);
175});
[6c152a5]176
177// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
178// This used to be a local delete that never left the building: the guardian's
179// dashboard forgot the ward, while the ward's server kept listing them in
180// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
181// At this point in the file the kid has two guardians, parent and gran.
182
183test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
184 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
185
186 const r = await G.endGuardianship(gran, KID);
187 assert.equal(r.status, 202);
188 assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
189
190 // The ward's own actor document is the thing that had to change.
191 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
192 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
193 assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
194 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
195});
196
197test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
198 const r = await G.endGuardianship(parent, KID);
199 assert.equal(r.status, 409);
200 assert.equal(r.error, 'would_emancipate');
201 // Nothing moved on either side. Emptying shaer:guardians takes the flow of
202 // §3.4 (three consenting adults, or a majority plus two witnesses), never one
203 // party's click.
204 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
205 assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
206});
207
208test('an Undo for a ward that is not yours is refused', async () => {
209 const r = await G.endGuardianship(gran, KID); // gran already left
210 assert.equal(r.status, 404);
211 assert.equal(r.error, 'not_my_ward');
212});
213
214test('the same Undo over C2S takes the same path', async () => {
215 // A Guardian app POSTs this to its own outbox; the dashboard button calls
216 // endGuardianship directly. One path, so the two cannot drift apart.
217 const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
218 const mine = await G.handleGuardianshipOutbox(gran, undo);
219 assert.equal(mine.status, 404, 'gran no longer guards the kid');
220
221 // And you cannot end someone else's relation by describing it.
222 const notMine = await G.handleGuardianshipOutbox(parent, undo);
223 assert.equal(notMine.status, 403);
224 assert.equal(notMine.error, 'not_your_relation');
225});
226
227test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
228 const before = G.listGuardians('kid').map((g) => g.other_uri);
229 await G.handleGuardianshipInbox(kid, {
230 actor: GRAN, // gran claims to end PARENT's relation
231 type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
232 });
233 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
234});
235
236test('a ward on this same instance is updated even though nothing is delivered', async () => {
237 // The browser found this: an inbox on this machine is not reachable over HTTP
238 // from this machine (nor should it be), so a co-located ward never receives
239 // the Undo. The guardian's side had dropped the ward while the ward's side
240 // still listed the guardian. Each instance must write what it hosts.
241 const kid2 = site('s4', 'kid2');
242 const g1 = site('s5', 'g1');
243 const g2 = site('s6', 'g2');
244 const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
245
246 const o1 = await G.handleGuardianshipOutbox(g1, {
247 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
248 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
249 const o2 = await G.handleGuardianshipOutbox(g2, {
250 type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
251 await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
252 await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
253 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
254
255 // Now deliver nothing at all, the way a loopback inbox behaves in practice.
256 const wired = {
257 selfId: A,
258 localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
259 deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
260 fetchActor: async () => null,
261 deliverTo: async () => ({ delivered: false }),
262 onEvent: null,
263 };
264 G.wireHandshake(wired);
265 const r = await G.endGuardianship(g2, KID2);
266 assert.equal(r.status, 202);
267 assert.equal(r.delivered, false, 'nothing went over the wire');
268 assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
269 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
270 assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
271
272 // Even undelivered, it must not empty the set: that is still emancipation.
273 const last = await G.endGuardianship(g1, KID2);
274 assert.equal(last.status, 409);
275 assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
276});
Note: See TracBrowser for help on using the repository browser.