source: Klonkt/test/gated-settings.test.js@ 88d7c8f

main
Last change on this file since 88d7c8f was 88d7c8f, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

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

  • Property mode set to 100644
File size: 7.8 KB
RevLine 
[65abc85]1// FEP-633c §5.6 + §3.5: a gated setting is decided by the guardians together,
2// by threshold within a window, and it has to work across servers.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5process.env.DATABASE_PATH = ':memory:';
6process.env.PUBLIC_BASE_URL = 'https://test.example';
7const dbMod = await import('../src/config/database.js');
8dbMod.initializeDatabase();
9const { tallyGatedSetting, thresholdFor, featureColumn } = await import('../src/services/guardianship/gated.js');
10
11const G3 = ['https://a/g1', 'https://b/g2', 'https://c/g3']; // three, on three servers
12const vote = (uri, value) => ({ guardian_uri: uri, value });
13
14test('the threshold is a strict majority', () => {
15 assert.equal(thresholdFor(1), 1);
16 assert.equal(thresholdFor(2), 2);
17 assert.equal(thresholdFor(3), 2);
18 assert.equal(thresholdFor(4), 3);
19});
20
21test('it settles the moment the majority is there, without waiting for the rest', () => {
22 const r = tallyGatedSetting([vote(G3[0], true), vote(G3[1], true)], G3, 1000);
23 assert.deepEqual(r, { state: 'settled', value: true });
24});
25
26test('one guardian alone does not decide for the others', () => {
27 const r = tallyGatedSetting([vote(G3[0], true)], G3, 1000);
28 assert.equal(r.state, 'open', 'a single voice is not the guardians as a group');
29});
30
31test('it also settles early when the majority has become unreachable', () => {
32 const r = tallyGatedSetting([vote(G3[0], false), vote(G3[1], false)], G3, 1000);
33 assert.deepEqual(r, { state: 'settled', value: false }, 'two against is itself a majority');
34});
35
36test('an undecided decision fails closed at the deadline', () => {
37 const open = tallyGatedSetting([vote(G3[0], true)], G3, 1000);
38 assert.equal(open.state, 'open');
39 const late = tallyGatedSetting([vote(G3[0], true)], G3, 25 * 60 * 60 * 1000);
40 assert.equal(late.state, 'expired', 'silence is an answer once the window closes');
41});
42
43test('answers from outside the snapshotted set are ignored', () => {
44 const r = tallyGatedSetting([vote(G3[0], true), vote('https://x/stranger', true)], G3, 1000);
45 assert.equal(r.state, 'open', 'a stranger cannot make up the majority');
46});
47
48test('a ward with no guardians has nobody to decide, so nothing is granted', () => {
49 assert.equal(tallyGatedSetting([vote('https://a/g1', true)], [], 1000).state, 'expired');
50});
51
52test('a guardian changing its mind replaces its own answer, it does not add one', () => {
53 // The store keys on (slug, feature, guardian), so the tally sees one per guardian.
54 const r = tallyGatedSetting([vote(G3[0], true), vote(G3[0], false), vote(G3[1], false)], G3, 1000);
55 assert.deepEqual(r, { state: 'settled', value: false });
56});
57
58test('unknown features are refused, never guessed onto a column', () => {
59 assert.equal(featureColumn('shaer:externalEmbeds'), 'external_embeds');
60 assert.equal(featureColumn('shaer:somethingElse'), null);
61 assert.equal(featureColumn('external_embeds = 1; DROP TABLE sites'), null);
62});
[2708282]63
64// Guard against the mistake that made the button do nothing: the route called
65// AP.deliverTo, which existed only as a key in the guardianship deps object and
66// not as an export. It threw a TypeError, Express answered 500, and the button
67// silently reset. A grep hit is not an export.
68test('the guardian route can reach every ActivityPub helper it calls', async () => {
69 const AP = (await import('../src/services/ActivityPubService.js')).default;
70 for (const fn of ['actorId', 'deliverToActor', 'followActor', 'backfillFromOutbox', 'getTimeline']) {
71 assert.equal(typeof AP[fn], 'function', `AP.${fn} must be exported, the guardian route calls it`);
72 }
73});
74
75test('a gated-setting Offer carries ward, feature and value', async () => {
76 const { buildGatedOffer, parseGatedSetting } = await import('../src/services/guardianship/gated.js');
77 const o = buildGatedOffer('https://a/gated/1', 'https://a/g1', 'https://b/ward', 'shaer:externalEmbeds', true);
78 assert.equal(o.type, 'Offer');
79 assert.deepEqual(o.to, ['https://b/ward'], 'addressed to the ward server, which tallies');
80 const parsed = parseGatedSetting(o.object);
81 assert.deepEqual(parsed, { ward: 'https://b/ward', feature: 'shaer:externalEmbeds', value: true });
82 assert.equal(parseGatedSetting({ type: 'Relationship' }), null, 'a different Offer is not ours');
83});
[88d7c8f]84
85// The leg that was missing, found on the live fleet: a proposal addressed to
86// the ward's server reached only the proposer and the ward. The two guardians
87// on other servers never learned it existed, so the threshold of two could
88// never be met and every proposal expired unanswered. Link previews for beta
89// stayed off not because anyone objected, but because nobody could answer.
90test('the ward forwards a proposal to the other guardians, or nobody can answer', async () => {
91 const dbMod2 = await import('../src/config/database.js');
92 const database = dbMod2.default;
93 const G = await import('../src/services/guardianship/index.js');
94 const WARD = 'https://test.example/ap/users/kid9';
95 const [A, B, C] = ['https://a.test/u/a', 'https://b.test/u/b', 'https://c.test/u/c'];
96 database.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u9', 'u9', 'u9@t', 'x', 'god');
97 database.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s9', 'kid9', 'kid9', 'u9');
98 for (const g of [A, B, C]) {
99 database.prepare("INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid9','ward',?, 'accepted','o')").run(g);
100 }
101 const sent = [];
102 G.wireHandshake({
103 selfId: (slug) => `https://test.example/ap/users/${slug}`,
104 localSlug: (u) => (u.startsWith('https://test.example/ap/users/') ? u.split('/').pop() : null),
105 deriveHandle: (u) => '@' + u.split('/').pop(),
106 fetchActor: async (u) => ({ id: u, inbox: `${u}/inbox` }),
107 deliverTo: async (s, to, act) => { sent.push({ to, act }); return { delivered: true }; },
108 onEvent: null,
109 });
110 const site = database.prepare('SELECT * FROM sites WHERE slug = ?').get('kid9');
111
112 // A proposes. The ward records A's own vote (1 of 3, threshold 2: open).
113 const offer = G.gated.buildGatedOffer('https://a.test/gated/1', A, WARD, 'shaer:externalEmbeds', true);
114 assert.equal(await G.handleGuardianshipInbox(site, { ...offer, actor: A }), true);
115 assert.equal(database.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get('kid9').external_embeds, null, 'one voice is not a majority');
116
117 // The forward: B and C are told, A is not asked twice.
118 const told = sent.filter((x) => x.act.object && x.act.object['shaer:feature']).map((x) => x.to).sort();
119 assert.deepEqual(told, [B, C].sort(), 'both other guardians must receive the proposal');
120
121 // B receives its copy on its own server and can answer it.
122 database.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s10', 'gb', 'gb', 'u9');
123 database.prepare("INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('gb','guardian',?, 'accepted','o')").run(WARD);
124 const gbSite = database.prepare('SELECT * FROM sites WHERE slug = ?').get('gb');
125 assert.equal(await G.handleGuardianshipInbox(gbSite, { ...offer, actor: A, to: ['https://test.example/ap/users/gb'] }), true);
126 const review = G.gated.listGatedReviews('gb')[0];
127 assert.ok(review, 'the guardian stores a copy it can answer');
128 assert.equal(review.feature, 'shaer:externalEmbeds');
129 assert.equal(review.ward_uri, WARD);
130
131 // B's Accept reaches the ward: 2 of 3, the threshold, and the gate opens.
132 assert.equal(await G.handleGuardianshipInbox(site, { type: 'Accept', actor: B, object: 'https://a.test/gated/1' }), true);
133 assert.equal(database.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get('kid9').external_embeds, 1,
134 'two of three agreed, so the ward may see link previews');
135});
Note: See TracBrowser for help on using the repository browser.