| 1 | // Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
|
|---|
| 2 | // These tests mirror the daemon's (routes.rs + availability.rs) one for one,
|
|---|
| 3 | // same names where possible, so a drift between the two backends shows up as
|
|---|
| 4 | // a failing test with the same words on both sides (shaer-6d9).
|
|---|
| 5 | import { test } from 'node:test';
|
|---|
| 6 | import assert from 'node:assert/strict';
|
|---|
| 7 |
|
|---|
| 8 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 9 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 10 |
|
|---|
| 11 | const dbMod = await import('../src/config/database.js');
|
|---|
| 12 | const db = dbMod.default;
|
|---|
| 13 | dbMod.initializeDatabase();
|
|---|
| 14 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 15 | const G = await import('../src/services/guardianship/index.js');
|
|---|
| 16 | const A = G.availability;
|
|---|
| 17 |
|
|---|
| 18 | const DAY = 24 * 3600 * 1000;
|
|---|
| 19 | // The handshake and the queue functions read the wall clock (unlike the
|
|---|
| 20 | // daemon, whose Clock is pinnable), so these tests BACKDATE the evidence
|
|---|
| 21 | // instead of advancing time: T0 lies eight days in the past, which puts
|
|---|
| 22 | // "now" at the wall clock for everything that reads it itself.
|
|---|
| 23 | const T0 = Date.now() - 8 * DAY;
|
|---|
| 24 | const uri = (slug) => `https://test.example/ap/users/${slug}`;
|
|---|
| 25 |
|
|---|
| 26 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 27 | function site(id, slug) {
|
|---|
| 28 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
|
|---|
| 29 | return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
|
|---|
| 30 | }
|
|---|
| 31 | // kid3 guarded by g1, g2, g3 (all remote, the ordinary case).
|
|---|
| 32 | const kid3 = site('s1', 'kid3');
|
|---|
| 33 | const [G1, G2, G3] = ['https://a.test/u/g1', 'https://b.test/u/g2', 'https://c.test/u/g3'];
|
|---|
| 34 | for (const g of [G1, G2, G3]) {
|
|---|
| 35 | db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id)
|
|---|
| 36 | VALUES ('kid3','ward',?,?, 'accepted','o1')`).run(g, '@' + g.split('/').pop());
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | // The handshake drives the lapse over the same deps the guardianship tests use.
|
|---|
| 40 | const delivered = [];
|
|---|
| 41 | G.wireHandshake({
|
|---|
| 42 | selfId: uri,
|
|---|
| 43 | localSlug: (u) => (u.startsWith('https://test.example/ap/users/') ? u.split('/').pop() : null),
|
|---|
| 44 | deriveHandle: (u) => '@' + u.split('/').pop(),
|
|---|
| 45 | fetchActor: async () => null,
|
|---|
| 46 | deliverTo: async (fromSite, toUri, activity) => { delivered.push({ toUri, activity }); return { delivered: true }; },
|
|---|
| 47 | onEvent: null,
|
|---|
| 48 | });
|
|---|
| 49 |
|
|---|
| 50 | /** Three follow decisions address every guardian; g1 and g2 answer, g3 stays
|
|---|
| 51 | * silent; the clock passes the request TTL. */
|
|---|
| 52 | function makeG3Dormant() {
|
|---|
| 53 | db.prepare('DELETE FROM ap_attention_requests').run();
|
|---|
| 54 | db.prepare('DELETE FROM ap_guardian_attention').run();
|
|---|
| 55 | db.prepare('DELETE FROM ap_lapses').run();
|
|---|
| 56 | for (let i = 0; i < 3; i++) {
|
|---|
| 57 | for (const g of [G1, G2, G3]) A.recordRequest('kid3', g, `follow-${i}`, T0 + i * 60_000);
|
|---|
| 58 | }
|
|---|
| 59 | A.oneAnswer(G1, T0 + 3 * 60_000);
|
|---|
| 60 | A.oneAnswer(G2, T0 + 3 * 60_000);
|
|---|
| 61 | return Date.now(); // the requests are now past the 7-day TTL
|
|---|
| 62 | }
|
|---|
| 63 |
|
|---|
| 64 | test('asked nothing never dormant: calendar time alone is no evidence', () => {
|
|---|
| 65 | assert.equal(A.observe('kid3', G1, T0 + 1_000_000 * DAY), false);
|
|---|
| 66 | assert.equal(A.effective('kid3', G1, T0 + 1_000_000 * DAY), 'active');
|
|---|
| 67 | });
|
|---|
| 68 |
|
|---|
| 69 | test('silence on addressed requests makes dormant, and the set shrinks', () => {
|
|---|
| 70 | const now = makeG3Dormant();
|
|---|
| 71 | const avail = A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 72 | assert.deepEqual(avail, [G1, G2], 'the set shrank to the two who answered');
|
|---|
| 73 | assert.equal(A.effective('kid3', G3, now), 'dormant');
|
|---|
| 74 | });
|
|---|
| 75 |
|
|---|
| 76 | test('the dormancy promotion fires the notification duty, once', () => {
|
|---|
| 77 | let notices = 0;
|
|---|
| 78 | G.wireAvailability({ onDormant: () => { notices++; } });
|
|---|
| 79 | const now = makeG3Dormant();
|
|---|
| 80 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 81 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 82 | assert.equal(notices, 1, 'marking dormant MUST notify (3.6.2), on the transition itself');
|
|---|
| 83 | G.wireAvailability({});
|
|---|
| 84 | });
|
|---|
| 85 |
|
|---|
| 86 | test('one answer restores everything', () => {
|
|---|
| 87 | const now = makeG3Dormant();
|
|---|
| 88 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 89 | const ev = A.oneAnswer(G3, now);
|
|---|
| 90 | assert.deepEqual(ev.restored, ['kid3']);
|
|---|
| 91 | assert.equal(A.effective('kid3', G3, now), 'active');
|
|---|
| 92 | assert.equal(A.misses('kid3', G3, now), 0, 'slate clean, no mark');
|
|---|
| 93 | });
|
|---|
| 94 |
|
|---|
| 95 | test('away has an end, expires silently, and is never evidence', () => {
|
|---|
| 96 | db.prepare('DELETE FROM ap_attention_requests').run();
|
|---|
| 97 | db.prepare('DELETE FROM ap_guardian_attention').run();
|
|---|
| 98 | A.declareAway('kid3', G2, T0 + 10 * DAY);
|
|---|
| 99 | assert.equal(A.effective('kid3', G2, T0 + 5 * DAY), 'away');
|
|---|
| 100 | assert.equal(A.awayUntil('kid3', G2, T0 + 5 * DAY), T0 + 10 * DAY);
|
|---|
| 101 | // Requests during the absence are not recorded (3.6.1).
|
|---|
| 102 | for (let i = 0; i < 5; i++) A.recordRequest('kid3', G2, `req-${i}`, T0 + DAY);
|
|---|
| 103 | assert.equal(A.observe('kid3', G2, T0 + 20 * DAY), false);
|
|---|
| 104 | assert.equal(A.effective('kid3', G2, T0 + 20 * DAY), 'active', 'the away expired silently, no dormancy left behind');
|
|---|
| 105 | });
|
|---|
| 106 |
|
|---|
| 107 | test('declaring away while dormant is an answer', () => {
|
|---|
| 108 | const now = makeG3Dormant();
|
|---|
| 109 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 110 | A.declareAway('kid3', G3, now + 3 * DAY);
|
|---|
| 111 | assert.equal(A.effective('kid3', G3, now + DAY), 'away');
|
|---|
| 112 | assert.equal(A.effective('kid3', G3, now + 3 * DAY), 'active');
|
|---|
| 113 | });
|
|---|
| 114 |
|
|---|
| 115 | test('a lapse opens only against a dormant guardian, and never empties', async () => {
|
|---|
| 116 | db.prepare('DELETE FROM ap_attention_requests').run();
|
|---|
| 117 | db.prepare('DELETE FROM ap_guardian_attention').run();
|
|---|
| 118 | // Both active: refused.
|
|---|
| 119 | const r = A.openLapse({ id: 'l-x', wardSlug: 'kid3', wardUri: uri('kid3'), target: G3, openedBy: G1, now: T0 });
|
|---|
| 120 | assert.equal(r.error, 'not_dormant');
|
|---|
| 121 | // A sole guardian can never lapse: that would be emancipation (3.4).
|
|---|
| 122 | const solo = site('s2', 'kid1');
|
|---|
| 123 | db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid1','ward',?, 'accepted','o1')`).run(G1);
|
|---|
| 124 | const r2 = A.openLapse({ id: 'l-y', wardSlug: 'kid1', wardUri: uri('kid1'), target: G1, openedBy: G1, now: T0 });
|
|---|
| 125 | assert.equal(r2.error, 'would_emancipate');
|
|---|
| 126 | assert.ok(solo, 'fixture site exists');
|
|---|
| 127 | });
|
|---|
| 128 |
|
|---|
| 129 | test('lapse full flow: release in absentia, over the S2S wire', async () => {
|
|---|
| 130 | const now = makeG3Dormant();
|
|---|
| 131 | A.availableSet('kid3', [G1, G2, G3], now); // promote g3
|
|---|
| 132 |
|
|---|
| 133 | // The proposing guardian's server delivers an Offer of shaer:Lapse.
|
|---|
| 134 | const consumed = await G.handleGuardianshipInbox(kid3, {
|
|---|
| 135 | id: 'https://a.test/lapses/1', type: 'Offer', actor: G1, to: [uri('kid3')],
|
|---|
| 136 | object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
|
|---|
| 137 | });
|
|---|
| 138 | assert.equal(consumed, true);
|
|---|
| 139 | const row = A.getLapse('https://a.test/lapses/1');
|
|---|
| 140 | assert.ok(row, 'the ward server opened the lapse');
|
|---|
| 141 | assert.deepEqual(JSON.parse(row.set_json), [G1, G2], 'the available set, target excluded');
|
|---|
| 142 | assert.ok(delivered.some((d) => d.toUri === G3), 'the target is notified in protocol (3.6.2)');
|
|---|
| 143 |
|
|---|
| 144 | // The second guardian agrees: 2 of 2, threshold met, but an irreversible
|
|---|
| 145 | // decision never settles early (3.5).
|
|---|
| 146 | await G.handleGuardianshipInbox(kid3, { type: 'Accept', actor: G2, object: 'https://a.test/lapses/1' });
|
|---|
| 147 | assert.equal(A.settleLapse('https://a.test/lapses/1', now + DAY).outcome, 'open', 'the window still runs');
|
|---|
| 148 | assert.equal(G.listGuardians('kid3').length, 3);
|
|---|
| 149 |
|
|---|
| 150 | // The window closes: released in absentia. The lapse opened at the wall
|
|---|
| 151 | // clock (inside the handshake), so the margin of a day absorbs the skew.
|
|---|
| 152 | const after = Date.now() + A.LAPSE_WINDOW_MS + DAY;
|
|---|
| 153 | const settled = A.settleLapse('https://a.test/lapses/1', after);
|
|---|
| 154 | assert.equal(settled.outcome, 'completed');
|
|---|
| 155 | assert.equal(settled.applied, true);
|
|---|
| 156 | assert.deepEqual(G.listGuardians('kid3').map((g) => g.other_uri), [G1, G2]);
|
|---|
| 157 | // Settling twice never applies twice.
|
|---|
| 158 | assert.equal(A.settleLapse('https://a.test/lapses/1', after).applied, true);
|
|---|
| 159 | assert.equal(G.listGuardians('kid3').length, 2);
|
|---|
| 160 |
|
|---|
| 161 | // Restore the fixture for the remaining tests.
|
|---|
| 162 | db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid3','ward',?, 'accepted','o1')`).run(G3);
|
|---|
| 163 | });
|
|---|
| 164 |
|
|---|
| 165 | test('one answer cancels a running lapse and restores the target', async () => {
|
|---|
| 166 | const now = makeG3Dormant();
|
|---|
| 167 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 168 | await G.handleGuardianshipInbox(kid3, {
|
|---|
| 169 | id: 'https://a.test/lapses/2', type: 'Offer', actor: G1, to: [uri('kid3')],
|
|---|
| 170 | object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
|
|---|
| 171 | });
|
|---|
| 172 | // The target shows a sign of life: anything at all.
|
|---|
| 173 | const ev = A.oneAnswer(G3, now + DAY);
|
|---|
| 174 | assert.equal(ev.cancelledLapses.length, 1);
|
|---|
| 175 | assert.equal(A.lapseOutcome(A.getLapse('https://a.test/lapses/2'), now + A.LAPSE_WINDOW_MS + DAY), 'cancelled');
|
|---|
| 176 | assert.equal(A.settleLapse('https://a.test/lapses/2', now + A.LAPSE_WINDOW_MS + DAY).applied, false);
|
|---|
| 177 | assert.equal(G.listGuardians('kid3').length, 3, 'one answer cancelled the lapse');
|
|---|
| 178 | assert.equal(A.effective('kid3', G3, now + DAY), 'active', 'and restored the guardian');
|
|---|
| 179 | });
|
|---|
| 180 |
|
|---|
| 181 | test('a stranger cannot make up the majority', async () => {
|
|---|
| 182 | const now = makeG3Dormant();
|
|---|
| 183 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 184 | await G.handleGuardianshipInbox(kid3, {
|
|---|
| 185 | id: 'https://a.test/lapses/3', type: 'Offer', actor: G1, to: [uri('kid3')],
|
|---|
| 186 | object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
|
|---|
| 187 | });
|
|---|
| 188 | const r = A.lapseVote('https://a.test/lapses/3', 'https://evil.test/u/stranger', true, now);
|
|---|
| 189 | assert.equal(r.error, 'not_in_set');
|
|---|
| 190 | const t = A.lapseVote('https://a.test/lapses/3', G3, true, now);
|
|---|
| 191 | assert.equal(t.error, 'not_in_set', 'the target is not in the set either');
|
|---|
| 192 | });
|
|---|
| 193 |
|
|---|
| 194 | test('the offers queue carries the lapse, the same shape the daemon serves', async () => {
|
|---|
| 195 | const now = makeG3Dormant();
|
|---|
| 196 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 197 | await G.handleGuardianshipInbox(kid3, {
|
|---|
| 198 | id: 'https://a.test/lapses/4', type: 'Offer', actor: G1, to: [uri('kid3')],
|
|---|
| 199 | object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
|
|---|
| 200 | });
|
|---|
| 201 | const col = G.offersCollection(`${uri('kid3')}/queues/offers`, 'kid3', uri('kid3'));
|
|---|
| 202 | const item = col.orderedItems.find((i) => i.id === 'https://a.test/lapses/4');
|
|---|
| 203 | assert.ok(item, 'the ward sees the running lapse');
|
|---|
| 204 | assert.equal(item.object.type, 'shaer:Lapse');
|
|---|
| 205 | assert.equal(item['shaer:threshold'], 2);
|
|---|
| 206 | assert.equal(item['shaer:outcome'], 'open');
|
|---|
| 207 | });
|
|---|
| 208 |
|
|---|
| 209 | test('the guardians queue serves availability, never the public actor doc', () => {
|
|---|
| 210 | const now = makeG3Dormant();
|
|---|
| 211 | A.availableSet('kid3', [G1, G2, G3], now);
|
|---|
| 212 | A.declareAway('kid3', G2, now + 5 * DAY);
|
|---|
| 213 | const col = G.guardiansCollection(`${uri('kid3')}/queues/guardians`, 'kid3');
|
|---|
| 214 | const by = Object.fromEntries(col.orderedItems.map((i) => [i.id, i]));
|
|---|
| 215 | assert.equal(by[G1]['shaer:availability'], 'active');
|
|---|
| 216 | assert.equal(by[G2]['shaer:availability'], 'away');
|
|---|
| 217 | assert.equal(by[G3]['shaer:availability'], 'dormant');
|
|---|
| 218 | // The PUBLIC actor document says nothing about any of this (3.6.1).
|
|---|
| 219 | const doc = AP.buildActor('https://test.example', kid3);
|
|---|
| 220 | assert.ok(!JSON.stringify(doc).match(/away|dormant|availability/i), 'availability is timing intelligence and stays out of the public doc');
|
|---|
| 221 | });
|
|---|
| 222 |
|
|---|
| 223 | test('the away note emits shaer:away with a plain AS2 endTime', () => {
|
|---|
| 224 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, visibility, to_actors, away_until, created_at)
|
|---|
| 225 | VALUES ('aw1','kid3','',NULL,NULL,'https://b.test/u/g2','@g2','<p>weg</p>','direct','["https://b.test/u/g2"]',?,CURRENT_TIMESTAMP)`).run(T0 + 10 * DAY);
|
|---|
| 226 | const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get('aw1');
|
|---|
| 227 | const note = AP.buildReplyNote('https://test.example', kid3, row);
|
|---|
| 228 | assert.equal(note['shaer:away'], true);
|
|---|
| 229 | assert.equal(note.endTime, new Date(T0 + 10 * DAY).toISOString());
|
|---|
| 230 | assert.equal(A.parseEndTime(note.endTime), T0 + 10 * DAY, 'and it round-trips through the parser');
|
|---|
| 231 | });
|
|---|
| 232 |
|
|---|
| 233 | test('the gated tally runs over the available set (§3.5)', () => {
|
|---|
| 234 | // The arithmetic problem this section exists for: five guardians of whom
|
|---|
| 235 | // two are gone. Over the full set the threshold is 3 and the two absentees
|
|---|
| 236 | // block every decision by silence alone; over the available set (3) the
|
|---|
| 237 | // threshold is 2 and the living can still decide.
|
|---|
| 238 | site('s3', 'kid5');
|
|---|
| 239 | const gs = [1, 2, 3, 4, 5].map((n) => `https://g${n}.test/u/g`);
|
|---|
| 240 | for (const g of gs) {
|
|---|
| 241 | db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid5','ward',?, 'accepted','o1')`).run(g);
|
|---|
| 242 | }
|
|---|
| 243 | for (let i = 0; i < 3; i++) for (const g of [gs[3], gs[4]]) A.recordRequest('kid5', g, `req-${i}`, T0);
|
|---|
| 244 | const now = T0 + 8 * DAY;
|
|---|
| 245 | assert.deepEqual(A.availableSet('kid5', gs, now), gs.slice(0, 3));
|
|---|
| 246 |
|
|---|
| 247 | const r1 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[0], true);
|
|---|
| 248 | assert.equal(r1.state, 'open');
|
|---|
| 249 | assert.equal(r1.need, 2, 'threshold over the available set of 3, not 3 of 5');
|
|---|
| 250 | const r2 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[1], true);
|
|---|
| 251 | assert.equal(r2.state, 'settled');
|
|---|
| 252 | assert.equal(r2.value, true, 'two living guardians decide; the absent no longer freeze the ward');
|
|---|
| 253 | });
|
|---|