source: Klonkt/test/gated-settings.test.js@ 6100ce9

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

Een dichte poort mag zeggen dat hij dicht is

Robin tikte op een videokaart in de app en er gebeurde niets. Geen speler, geen
melding, geen reactie. De speler was niet stuk: de afspeel-poort staat voor dat
account dicht, dus de server stuurt geen playerUrl mee en de kaart is niet
aantikbaar. Correct gedrag, maar het leest als kapot.

De kaart zegt nu dat er iets achter de poort zit. timelineEmbed voegt
shaer:playable toe wanneer er wel een speler zou zijn maar de poort dicht is.
Dat kost niets: de kaart toont al een videothumbnail, dus dat er video achter
zit is geen nieuws. Het is een mededeling, nooit een ingang.

Het verschil dat dit maakt: "deze app is stuk" wordt "dit is niet aan mij". Een
kaart die een tik opslokt leert geen van beide. En het staat de guardians
nergens in de weg: niets hieraan zet iets aan.

Changed files:
src/services/ActivityPubService.js

  • timelineEmbed: shaer:playable bij een dichte poort met een speler erachter

test/gated-settings.test.js

  • een dichte poort geeft geen speler maar geeft dat wel toe
  • een nieuwsartikel is geen dichte poort, die belooft niets

remarks: 333 tests groen. De clientkant (het regeltje op de kaart) zit in de
app-repos.

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

  • Property mode set to 100644
File size: 11.5 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 // But the card says there IS something behind the gate, so the app can
165 // explain the silence instead of ignoring a tap. Robin tapped a video that
166 // could not answer and nothing happened, which reads as broken, not as shut.
167 assert.equal(shut['shaer:playable'], true, 'a shut gate still admits that a player exists');
168
169 const open = AP2.timelineEmbed(yt, { playback: true });
170 assert.match(open['shaer:playerUrl'], /^https:\/\/www\.youtube-nocookie\.com\/embed\/HetoL4XpHwY/,
171 'privacy-enhanced only: nocookie, no related videos');
172 assert.match(open['shaer:playerUrl'], /rel=0/);
173});
174
175test('a page we will not frame simply stays a thumbnail', async () => {
176 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
177 const page = JSON.stringify({ url: 'https://yougubrands.com/about', title: 'About', media: [] });
178 assert.equal(AP2.timelineEmbed(page, { playback: true })['shaer:playerUrl'], undefined);
179 // And no promise of one either: a news article is not a shut gate, it is
180 // simply not a video, and the app must not offer to ask for it.
181 assert.equal(AP2.timelineEmbed(page)['shaer:playable'], undefined);
182 assert.equal(AP2.playerUrlFor('https://nos.nl/artikel/1'), null);
183 // PeerTube is decentralised, so it is matched by shape, not by a host list.
184 assert.equal(AP2.playerUrlFor('https://tilvids.com/w/abc123def'), 'https://tilvids.com/videos/embed/abc123def');
185});
186
187test('playback needs the preview gate: you cannot play what you may not see', async () => {
188 const G2 = await import('../src/services/guardianship/index.js');
189 // Both auto (a ward): both shut.
190 assert.equal(G2.externalEmbedsAllowed(null, true), false);
191 assert.equal(G2.externalPlaybackAllowed(null, true), false);
192 // Guardians opened previews only: playback stays a separate decision.
193 assert.equal(G2.externalEmbedsAllowed(1, true), true);
194 assert.equal(G2.externalPlaybackAllowed(null, true), false);
195 // An adult account has nothing gated.
196 assert.equal(G2.externalPlaybackAllowed(null, false), true);
197});
Note: See TracBrowser for help on using the repository browser.