source: Klonkt/test/gated-settings.test.js@ e27b8db

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

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

  • Property mode set to 100644
File size: 10.1 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});
[e27b8db]136
137// Playback is the heavier sibling of the preview (5.6): seeing that a video
138// exists is one decision, letting a third party's player run inside the app is
139// another. The hole this closes: the web Krant built the YouTube iframe from
140// the note's content on a path that never touched the gate, so a ward whose
141// guardians had allowed nothing still got the full player, while the app
142// showed nothing at all. The heavy thing open, the light thing shut.
143test('a player URL rides only when the playback gate is open', async () => {
144 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
145 const yt = JSON.stringify({ url: 'https://www.youtube.com/watch?v=HetoL4XpHwY', title: 'x', media: [] });
146
147 const shut = AP2.timelineEmbed(yt);
148 assert.ok(shut && shut.url, 'the card itself still travels');
149 assert.equal(shut['shaer:playerUrl'], undefined, 'no player without the gate');
150
151 const open = AP2.timelineEmbed(yt, { playback: true });
152 assert.match(open['shaer:playerUrl'], /^https:\/\/www\.youtube-nocookie\.com\/embed\/HetoL4XpHwY/,
153 'privacy-enhanced only: nocookie, no related videos');
154 assert.match(open['shaer:playerUrl'], /rel=0/);
155});
156
157test('a page we will not frame simply stays a thumbnail', async () => {
158 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
159 const page = JSON.stringify({ url: 'https://yougubrands.com/about', title: 'About', media: [] });
160 assert.equal(AP2.timelineEmbed(page, { playback: true })['shaer:playerUrl'], undefined);
161 assert.equal(AP2.playerUrlFor('https://nos.nl/artikel/1'), null);
162 // PeerTube is decentralised, so it is matched by shape, not by a host list.
163 assert.equal(AP2.playerUrlFor('https://tilvids.com/w/abc123def'), 'https://tilvids.com/videos/embed/abc123def');
164});
165
166test('playback needs the preview gate: you cannot play what you may not see', async () => {
167 const G2 = await import('../src/services/guardianship/index.js');
168 // Both auto (a ward): both shut.
169 assert.equal(G2.externalEmbedsAllowed(null, true), false);
170 assert.equal(G2.externalPlaybackAllowed(null, true), false);
171 // Guardians opened previews only: playback stays a separate decision.
172 assert.equal(G2.externalEmbedsAllowed(1, true), true);
173 assert.equal(G2.externalPlaybackAllowed(null, true), false);
174 // An adult account has nothing gated.
175 assert.equal(G2.externalPlaybackAllowed(null, false), true);
176});
Note: See TracBrowser for help on using the repository browser.