| 1 | // The guardianship module (FEP-633c): relations, actor props, the adoption
|
|---|
| 2 | // handshake and the dashboard queues. Pins the module's public surface so the
|
|---|
| 3 | // Shaer clients' contract stays stable.
|
|---|
| 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 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 17 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'parent', 'Parent', 'u1', 1);
|
|---|
| 18 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s2', 'kid', 'Kid', 'u1', 0);
|
|---|
| 19 | const parent = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
|
|---|
| 20 | const kid = db.prepare('SELECT * FROM sites WHERE id = ?').get('s2');
|
|---|
| 21 | const ME = 'https://test.example/ap/users/parent';
|
|---|
| 22 | const KID = 'https://test.example/ap/users/kid';
|
|---|
| 23 |
|
|---|
| 24 | // No network in tests: the handshake delivers via this stub.
|
|---|
| 25 | const sent = [];
|
|---|
| 26 | G.wireHandshake({
|
|---|
| 27 | selfId: (slug) => `https://test.example/ap/users/${slug}`,
|
|---|
| 28 | deliverTo: async (site, uri, activity) => { sent.push({ from: site.slug, to: uri, activity }); return true; },
|
|---|
| 29 | deriveHandle: (uri) => '@' + String(uri).split('/').pop() + '@test.example',
|
|---|
| 30 | onEvent: null,
|
|---|
| 31 | });
|
|---|
| 32 |
|
|---|
| 33 | test('actor doc advertises shaer:queues (and blocked stays)', () => {
|
|---|
| 34 | const actor = AP.buildActor('https://test.example', parent);
|
|---|
| 35 | assert.equal(actor.blocked, `${ME}/blocked`);
|
|---|
| 36 | assert.deepEqual(actor['shaer:queues'], {
|
|---|
| 37 | offers: `${ME}/queues/offers`,
|
|---|
| 38 | follows: `${ME}/queues/follows`,
|
|---|
| 39 | wards: `${ME}/queues/wards`,
|
|---|
| 40 | });
|
|---|
| 41 | assert.equal(actor['shaer:isGuardian'], undefined); // no wards yet
|
|---|
| 42 | });
|
|---|
| 43 |
|
|---|
| 44 | test('C2S Offer from the candidate records + delivers (FEP-633c 3)', async () => {
|
|---|
| 45 | const r = await G.handleGuardianshipOutbox(parent, {
|
|---|
| 46 | type: 'Offer',
|
|---|
| 47 | object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
|
|---|
| 48 | });
|
|---|
| 49 | assert.equal(r.status, 202);
|
|---|
| 50 | assert.equal(sent.length, 1);
|
|---|
| 51 | assert.equal(sent[0].to, KID);
|
|---|
| 52 | assert.equal(sent[0].activity.type, 'Offer');
|
|---|
| 53 | const wards = G.listWards('parent');
|
|---|
| 54 | assert.equal(wards.length, 1);
|
|---|
| 55 | assert.equal(wards[0].status, 'offered');
|
|---|
| 56 | // The guardian-to-be now reads as guardian; the actor doc follows.
|
|---|
| 57 | const actor = AP.buildActor('https://test.example', parent);
|
|---|
| 58 | assert.equal(actor['shaer:isGuardian'], true);
|
|---|
| 59 | });
|
|---|
| 60 |
|
|---|
| 61 | test('only the candidate may offer', async () => {
|
|---|
| 62 | const r = await G.handleGuardianshipOutbox(parent, {
|
|---|
| 63 | type: 'Offer',
|
|---|
| 64 | object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: 'https://elders.test/u/x' },
|
|---|
| 65 | });
|
|---|
| 66 | assert.equal(r.status, 403);
|
|---|
| 67 | });
|
|---|
| 68 |
|
|---|
| 69 | test('inbound Offer parks in the ward queue; C2S Accept commits both ends', async () => {
|
|---|
| 70 | // The kid's side receives the offer S2S.
|
|---|
| 71 | const offerId = sent[0].activity.id;
|
|---|
| 72 | const consumed = await G.handleGuardianshipInbox(kid, {
|
|---|
| 73 | id: offerId, type: 'Offer', actor: ME,
|
|---|
| 74 | object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
|
|---|
| 75 | });
|
|---|
| 76 | assert.equal(consumed, true);
|
|---|
| 77 | assert.equal(G.listOffers('kid').length, 1);
|
|---|
| 78 |
|
|---|
| 79 | // The kid accepts over C2S; the answer travels to the guardian.
|
|---|
| 80 | const r = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: offerId });
|
|---|
| 81 | assert.equal(r.status, 202);
|
|---|
| 82 | assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
|
|---|
| 83 |
|
|---|
| 84 | // The guardian's side hears the Accept S2S and commits.
|
|---|
| 85 | const ok = await G.handleGuardianshipInbox(parent, { type: 'Accept', actor: KID, object: offerId });
|
|---|
| 86 | assert.equal(ok, true);
|
|---|
| 87 | const wards = G.listWards('parent').filter((w) => w.status === 'accepted');
|
|---|
| 88 | assert.deepEqual(wards.map((w) => w.other_uri), [KID]);
|
|---|
| 89 |
|
|---|
| 90 | // The ward's actor doc now names its guardian (FEP-633c 2.1).
|
|---|
| 91 | const actor = AP.buildActor('https://test.example', kid);
|
|---|
| 92 | assert.deepEqual(actor['shaer:guardians'], [ME]);
|
|---|
| 93 | });
|
|---|
| 94 |
|
|---|
| 95 | test('queues serve the daemon contract shapes', () => {
|
|---|
| 96 | const wardsQ = G.wardsCollection(`${ME}/queues/wards`, 'parent');
|
|---|
| 97 | assert.equal(wardsQ.type, 'OrderedCollection');
|
|---|
| 98 | assert.equal(wardsQ.totalItems, 1);
|
|---|
| 99 | assert.equal(wardsQ.orderedItems[0].id, KID);
|
|---|
| 100 | const followsQ = G.followsCollection(`${ME}/queues/follows`);
|
|---|
| 101 | assert.deepEqual(followsQ.orderedItems, []);
|
|---|
| 102 | const offersQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME);
|
|---|
| 103 | assert.equal(offersQ.type, 'OrderedCollection'); // empty again after the accept
|
|---|
| 104 | assert.equal(offersQ.totalItems, 0);
|
|---|
| 105 | });
|
|---|
| 106 |
|
|---|
| 107 | test('a ward cannot become a guardian (FEP-633c 1)', async () => {
|
|---|
| 108 | const r = await G.handleGuardianshipOutbox(kid, {
|
|---|
| 109 | type: 'Offer',
|
|---|
| 110 | object: { type: 'Relationship', subject: 'https://other.test/u/y', relationship: 'shaer:Guardian', object: KID },
|
|---|
| 111 | });
|
|---|
| 112 | assert.equal(r.status, 403);
|
|---|
| 113 | assert.equal(r.error, 'a_ward_cannot_guard');
|
|---|
| 114 | });
|
|---|
| 115 |
|
|---|
| 116 | test('helpRequest props only ride direct notes', () => {
|
|---|
| 117 | assert.deepEqual(G.helpRequestProps({ visibility: 'direct', help_request: 1 }), { 'shaer:helpRequest': true });
|
|---|
| 118 | assert.deepEqual(G.helpRequestProps({ visibility: 'public', help_request: 1 }), {});
|
|---|
| 119 | assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
|
|---|
| 120 | assert.equal(G.isHelpRequest({}), false);
|
|---|
| 121 | });
|
|---|