source: Klonkt/test/gated-settings.test.js@ 6d5ce0c

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

Het doorgestuurde voorstel werd geweigerd: verkeerde afzender

Robin meldde dat stap 2 niet gebeurt, en beta's log zegt waarom:

[AP] guardianship Offer got 401 from https://boiert.eu/ap/users/opie/inbox
[AP] guardianship Offer got 401 from https://boiert.eu/ap/users/boiert/inbox

Het doorsturen werkt dus wel, maar de ontvangers weigeren het, en terecht. Ik
stuurde door met de voorsteller in actor terwijl de sleutel van het kind
ondertekent. Het lichaam beweerde de ene afzender, de handtekening bewees de
andere: signer mismatch, 401, precies wat die controle hoort te doen.

5.3 doet het al goed en had het voorbeeld moeten zijn: een gated follow wordt
doorgestuurd ALS HET KIND. Nu deze ook, met de voorsteller in shaer:proposer
zodat het scherm van de guardian nog steeds de juiste naam toont.

En de test die dit had moeten vangen: die controleerde DAT er doorgestuurd
werd, niet namens wie. Hij stond groen terwijl er niets aankwam. Nu controleert
hij de afzender en de proposer, en hij faalt op de oude code.

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

  • doorsturen met actor = het kind, plus shaer:proposer
  • de ontvanger leest de voorsteller uit shaer:proposer

src/routes/guardian.js

  • hetzelfde op het lokale pad: ondertekenen en beweren moeten kloppen

test/gated-settings.test.js

  • de afzender van het doorgestuurde voorstel is het kind; de proposer reist apart mee en komt op het scherm terecht

remarks: 322 tests groen. De 401's die in de bezorgwachtrij staan dragen nog de
oude vorm en blijven falen tot ze opgeven; een nieuw voorstel is de weg vooruit.

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

  • Property mode set to 100644
File size: 11.0 KB
Line 
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});
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});
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 fwd = sent.filter((x) => x.act.object && x.act.object['shaer:feature']);
119 assert.deepEqual(fwd.map((x) => x.to).sort(), [B, C].sort(), 'both other guardians must receive the proposal');
120 // And it must go out AS THE WARD, because the ward's key signs it. Sending
121 // it with the proposer still in `actor` is a signer mismatch: every receiver
122 // answers 401 and the proposal silently never arrives. Live proof, from
123 // beta's log: "guardianship Offer got 401 from boiert.eu/.../inbox". The
124 // first version of this test checked THAT a forward happened and not on
125 // whose behalf, so it passed while nothing worked.
126 for (const x of fwd) {
127 assert.equal(x.act.actor, WARD, 'the forward is signed by the ward, so it must say the ward');
128 assert.equal(x.act['shaer:proposer'], A, 'and it carries who actually proposed it');
129 }
130
131 // B receives its copy on its own server and can answer it.
132 database.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s10', 'gb', 'gb', 'u9');
133 database.prepare("INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('gb','guardian',?, 'accepted','o')").run(WARD);
134 const gbSite = database.prepare('SELECT * FROM sites WHERE slug = ?').get('gb');
135 // Exactly the shape the ward sends: actor = the ward, proposer alongside.
136 assert.equal(await G.handleGuardianshipInbox(gbSite, {
137 ...offer, actor: WARD, 'shaer:proposer': A, to: ['https://test.example/ap/users/gb'],
138 }), true);
139 const review = G.gated.listGatedReviews('gb')[0];
140 assert.ok(review, 'the guardian stores a copy it can answer');
141 assert.equal(review.proposer, A, 'the screen names who proposed it, not the ward that relayed it');
142 assert.equal(review.feature, 'shaer:externalEmbeds');
143 assert.equal(review.ward_uri, WARD);
144
145 // B's Accept reaches the ward: 2 of 3, the threshold, and the gate opens.
146 assert.equal(await G.handleGuardianshipInbox(site, { type: 'Accept', actor: B, object: 'https://a.test/gated/1' }), true);
147 assert.equal(database.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get('kid9').external_embeds, 1,
148 'two of three agreed, so the ward may see link previews');
149});
150
151// Playback is the heavier sibling of the preview (5.6): seeing that a video
152// exists is one decision, letting a third party's player run inside the app is
153// another. The hole this closes: the web Krant built the YouTube iframe from
154// the note's content on a path that never touched the gate, so a ward whose
155// guardians had allowed nothing still got the full player, while the app
156// showed nothing at all. The heavy thing open, the light thing shut.
157test('a player URL rides only when the playback gate is open', async () => {
158 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
159 const yt = JSON.stringify({ url: 'https://www.youtube.com/watch?v=HetoL4XpHwY', title: 'x', media: [] });
160
161 const shut = AP2.timelineEmbed(yt);
162 assert.ok(shut && shut.url, 'the card itself still travels');
163 assert.equal(shut['shaer:playerUrl'], undefined, 'no player without the gate');
164
165 const open = AP2.timelineEmbed(yt, { playback: true });
166 assert.match(open['shaer:playerUrl'], /^https:\/\/www\.youtube-nocookie\.com\/embed\/HetoL4XpHwY/,
167 'privacy-enhanced only: nocookie, no related videos');
168 assert.match(open['shaer:playerUrl'], /rel=0/);
169});
170
171test('a page we will not frame simply stays a thumbnail', async () => {
172 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
173 const page = JSON.stringify({ url: 'https://yougubrands.com/about', title: 'About', media: [] });
174 assert.equal(AP2.timelineEmbed(page, { playback: true })['shaer:playerUrl'], undefined);
175 assert.equal(AP2.playerUrlFor('https://nos.nl/artikel/1'), null);
176 // PeerTube is decentralised, so it is matched by shape, not by a host list.
177 assert.equal(AP2.playerUrlFor('https://tilvids.com/w/abc123def'), 'https://tilvids.com/videos/embed/abc123def');
178});
179
180test('playback needs the preview gate: you cannot play what you may not see', async () => {
181 const G2 = await import('../src/services/guardianship/index.js');
182 // Both auto (a ward): both shut.
183 assert.equal(G2.externalEmbedsAllowed(null, true), false);
184 assert.equal(G2.externalPlaybackAllowed(null, true), false);
185 // Guardians opened previews only: playback stays a separate decision.
186 assert.equal(G2.externalEmbedsAllowed(1, true), true);
187 assert.equal(G2.externalPlaybackAllowed(null, true), false);
188 // An adult account has nothing gated.
189 assert.equal(G2.externalPlaybackAllowed(null, false), true);
190});
Note: See TracBrowser for help on using the repository browser.