source: Klonkt/test/guardianship.test.js@ 3ffbedd

main
Last change on this file since 3ffbedd was 3ffbedd, checked in by Robin Genis <roboburr@…>, 7 weeks ago

Guardianship: eerste guardian committeert op de enkele ward-accept

Het offer ís de instemming van de kandidaat. Een vrij account mag in één keer
een guardian aannemen: er is nog geen tweede guardian om mee te akkoorderen.
Pas als het een ward is, treedt de co-approval in werking.

Concreet: de kandidaat-accept wordt nu impliciet vastgelegd bij het offer, op
elke kopie (C2S bij het versturen, S2S bij ontvangst). Zo committeert de eerste
guardian zodra de ward accepteert; een tweede guardian erbij vereist nog steeds
dat een bestaande guardian mee-accepteert (§3.1.2). Geen aparte "voltooien"-stap
voor de kandidaat meer.

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

  • Offer legt de kandidaat-accept vast (C2S + S2S)

test/guardianship.test.js

  • eerste guardian: commit op enkele ward-accept; tweede: co-approval

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

  • Property mode set to 100644
File size: 6.5 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
70 // The ward actor now names its guardian; parent reads as guardian (§2).
71 assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
72 assert.equal(AP.buildActor('https://test.example', parent)['shaer:isGuardian'], true);
73 // §1 mutual exclusion: the ward is not also a guardian.
74 assert.equal(AP.buildActor('https://test.example', kid)['shaer:isGuardian'], undefined);
75});
76
77test('second guardian needs the EXISTING guardian to co-accept (§3.1.2)', async () => {
78 // Gran offers to also guard the kid (who already has parent).
79 const off = await G.handleGuardianshipOutbox(gran, {
80 type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN },
81 });
82 const id = offerIdFrom(off);
83 // The existing guardian (parent) is a party and must accept.
84 const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
85 assert.ok(parentQ, 'parent sees the co-guardianship offer');
86 assert.deepEqual(parentQ['shaer:existingGuardians'], [ME]);
87
88 // The kid accepts, but now it IS a ward: NOT committed, because the existing
89 // guardian (parent) has not co-accepted (§3.1.2). The candidate (gran) already
90 // agreed via the offer, so no separate gran accept is needed.
91 const early = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
92 assert.equal(early.committed, false);
93 assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
94
95 // The existing guardian co-accepts → tally complete → commit.
96 await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
97 assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [GRAN, ME].sort());
98});
99
100test('a single Reject from a required party voids the offer (§3.2)', async () => {
101 // parent offers to guard gran (who is free).
102 const off = await G.handleGuardianshipOutbox(parent, {
103 type: 'Offer', object: { type: 'Relationship', subject: GRAN, relationship: 'shaer:Guardian', object: ME },
104 });
105 const id = offerIdFrom(off);
106 await G.handleGuardianshipOutbox(gran, { type: 'Reject', object: id });
107 const q = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
108 assert.equal(q, undefined, 'voided offer leaves the queue');
109 assert.equal(G.listWards('parent').some((w) => w.other_uri === GRAN), false);
110});
111
112test('a ward cannot become a guardian (§1)', async () => {
113 const r = await G.handleGuardianshipOutbox(kid, {
114 type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
115 });
116 assert.equal(r.status, 403);
117 assert.equal(r.error, 'a_ward_cannot_guard');
118});
119
120test('only the candidate may offer (§3.1 fixed initiator)', async () => {
121 const r = await G.handleGuardianshipOutbox(parent, {
122 type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
123 });
124 assert.equal(r.status, 403);
125 assert.equal(r.error, 'only_the_candidate_offers');
126});
127
128test('helpRequest props only ride direct notes', () => {
129 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
130 assert.equal(G.isHelpRequest({}), false);
131});
Note: See TracBrowser for help on using the repository browser.