| 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.
|
|---|
| 4 | import { test } from 'node:test';
|
|---|
| 5 | import assert from 'node:assert/strict';
|
|---|
| 6 |
|
|---|
| 7 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 8 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 9 |
|
|---|
| 10 | const dbMod = await import('../src/config/database.js');
|
|---|
| 11 | const db = dbMod.default;
|
|---|
| 12 | dbMod.initializeDatabase();
|
|---|
| 13 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 14 | const G = await import('../src/services/guardianship/index.js');
|
|---|
| 15 |
|
|---|
| 16 | function 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 | }
|
|---|
| 20 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 21 | const parent = site('s1', 'parent'); // first guardian-candidate
|
|---|
| 22 | const kid = site('s2', 'kid'); // ward
|
|---|
| 23 | const gran = site('s3', 'gran'); // second guardian-candidate (co-approver later)
|
|---|
| 24 | const A = (slug) => `https://test.example/ap/users/${slug}`;
|
|---|
| 25 | const [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).
|
|---|
| 29 | G.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 |
|
|---|
| 43 | const offerIdFrom = (r) => r.id;
|
|---|
| 44 |
|
|---|
| 45 | test('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 |
|
|---|
| 80 | test('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 |
|
|---|
| 103 | test('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 |
|
|---|
| 115 | test('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 |
|
|---|
| 123 | test('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 |
|
|---|
| 150 | test('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 |
|
|---|
| 191 | test('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 |
|
|---|
| 218 | test('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 |
|
|---|
| 251 | test('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 |
|
|---|
| 268 | test('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 |
|
|---|
| 276 | test('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 |
|
|---|
| 287 | test('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 |
|
|---|
| 301 | test('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 |
|
|---|
| 312 | test('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 |
|
|---|
| 318 | test('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 |
|
|---|
| 331 | test('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 |
|
|---|
| 340 | test('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 | });
|
|---|