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

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

Een voorstel krijgt een antwoord, een status en een zichtbare kring

Robin meldde drie dingen na de voorstelronde: er lijkt niets te gebeuren, de
status van een voorstel is nergens te volgen, en in /guardian zie je niet wie
de mede-guardians van een kind zijn. Onderzoek op beta wees de kern aan: de
ronde is aangekomen en geteld, maar elk voorstel was shaer:externalEmbeds, en
die stond al aan. Afspelen is nooit voorgesteld, en dat KON ook niet: de knop
"Afspelen voorstellen" verscheen alleen bij embeds === true, en voor een ward
op een andere server is die waarde onbekend, dus null, dus geen knop. Onbekend
is niet uit.

Drie reparaties, een per klacht.

Een: de lus sluit. De server van het kind beantwoordt de Offer die de
beslissing opende, terug naar de voorsteller: Accept als hij settelde op het
voorgestelde, Reject bij het tegendeel. Alleen de stem van het kind-account
telt als uitkomst; een vreemde die onze boeken wil sluiten wordt genegeerd.
Zonder dit kon het scherm van de voorsteller alleen maar eeuwig "wacht"
zeggen, wat er ook gebeurd was: de telling is het prive-grootboek van de
server van het kind.

Twee: de voorsteller houdt een eigen boek bij (ap_gated_sent) en het paneel
toont per ward de stand: wacht, aangenomen, afgewezen, of eerlijk verlopen als
het venster leegliep. Stilte na het venster is geen "loopt nog".

Drie: voor een ward op een andere server toont het paneel nu wel wie de
guardians zijn. Het lidmaatschap is publiek op het actordocument
(shaer:guardians, 2.1); de beschikbaarheid is en blijft het prive-grootboek
van hun server (3.6.1) en wordt alleen benoemd, niet getoond.

Changed files:
src/config/database.js

  • tabel ap_gated_sent (het boek van de voorsteller)
  • kolom proposer op ap_gated_offers, voor het antwoord naar huis

src/services/guardianship/gated.js

  • recordSent/recallSent/settleSent/listSent en sentStatus (puur, getest)
  • rememberGatedOffer onthoudt de voorsteller

src/services/guardianship/handshake.js

  • answerGatedProposer: het settle-antwoord naar de voorsteller
  • de inbox herkent dat antwoord en settelt het eigen boek; alleen het kind-account mag dat

src/routes/guardian.js

  • proposeGated schrijft het boek; dashboardState serveert per ward de voorstellen met status
  • GET /wards/guardians: lokaal met beschikbaarheid, remote de publieke lijst van hun actordocument

src/assets/js/guardian.js

  • de afspeel-knop verschijnt ook bij onbekende embeds-stand (de bug)
  • statusregels onder de instellingen-knoppen
  • remote guardians opgehaald bij het openen van het paneel

src/assets/css/guardian.css

  • g-prop-kleuren: de staat spreekt voor de woorden uit

src/services/i18n.js

  • prop_*- en panel_guards_far-strings, NL/EN/DE

test/gated-settings.test.js

  • de lus sluit: de voorsteller krijgt het antwoord, van alleen het kind
  • het boek: open, aangenomen, afgewezen, verlopen

remarks: 334 tests groen, server start op een schone database. De guardian-kant
(sound-fabrics) en de ward-kant (beta) hebben allebei deze commit nodig: de
knop en de status leven bij de guardian, het antwoord bij het kind.

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

  • Property mode set to 100644
File size: 14.7 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 // And the loop CLOSES: the ward's server answers the Offer that opened the
151 // decision, back to the proposer. Without this the proposer's screen can
152 // only ever say "waiting", forever, whatever actually happened: the tally is
153 // the ward server's private ledger and nobody else may read it.
154 const answer = sent.find((x) => x.to === A && (x.act.type === 'Accept' || x.act.type === 'Reject'));
155 assert.ok(answer, 'the proposer is told the outcome');
156 assert.equal(answer.act.type, 'Accept', 'it settled on what A proposed');
157 assert.equal(answer.act.actor, WARD, 'and only the ward may say so');
158 assert.equal(answer.act.object, 'https://a.test/gated/1', 'referencing the offer it answers');
159});
160
161test("the proposer's own book: sent, answered, or honestly expired", async () => {
162 const dbMod3 = await import('../src/config/database.js');
163 const database = dbMod3.default;
164 const G = await import('../src/services/guardianship/index.js');
165 const WARD = 'https://kids.example/ap/users/maan';
166 const ME = 'https://test.example/ap/users/oma1';
167 database.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u11', 'u11', 'u11@t', 'x', 'god');
168 database.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s11', 'oma1', 'oma1', 'u11');
169 database.prepare("INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('oma1','guardian',?, 'accepted','o')").run(WARD);
170 const site = database.prepare('SELECT * FROM sites WHERE slug = ?').get('oma1');
171
172 // The route records what it sent; here we do what the route does.
173 G.gated.recordSent(`${ME}/gated/p1`, 'oma1', WARD, 'shaer:externalPlayback', true);
174 assert.equal(G.gated.sentStatus(G.gated.recallSent(`${ME}/gated/p1`), Date.now()), 'open');
175
176 // A stranger claiming an outcome is not one: only the ward's voice counts.
177 assert.equal(await G.handleGuardianshipInbox(site, { type: 'Accept', actor: 'https://evil.test/u/x', object: `${ME}/gated/p1` }), false);
178 assert.equal(G.gated.recallSent(`${ME}/gated/p1`).status, 'open', 'still open: a stranger cannot close our books');
179
180 // The ward's server answers: the row settles.
181 assert.equal(await G.handleGuardianshipInbox(site, { type: 'Accept', actor: WARD, object: `${ME}/gated/p1` }), true);
182 assert.equal(G.gated.recallSent(`${ME}/gated/p1`).status, 'accepted');
183 assert.equal(G.gated.sentStatus(G.gated.recallSent(`${ME}/gated/p1`), Date.now()), 'accepted');
184
185 // A Reject is an answer too, and lands as one.
186 G.gated.recordSent(`${ME}/gated/p2`, 'oma1', WARD, 'shaer:externalEmbeds', false);
187 assert.equal(await G.handleGuardianshipInbox(site, { type: 'Reject', actor: WARD, object: `${ME}/gated/p2` }), true);
188 assert.equal(G.gated.sentStatus(G.gated.recallSent(`${ME}/gated/p2`), Date.now()), 'rejected');
189
190 // Silence past the window is not "still running": it is over, and the
191 // screen must say so instead of promising forever.
192 G.gated.recordSent(`${ME}/gated/p3`, 'oma1', WARD, 'shaer:externalEmbeds', true);
193 const row = G.gated.recallSent(`${ME}/gated/p3`);
194 assert.equal(G.gated.sentStatus(row, Date.now()), 'open');
195 assert.equal(G.gated.sentStatus(row, Date.now() + 25 * 60 * 60 * 1000), 'expired');
196});
197
198// Playback is the heavier sibling of the preview (5.6): seeing that a video
199// exists is one decision, letting a third party's player run inside the app is
200// another. The hole this closes: the web Krant built the YouTube iframe from
201// the note's content on a path that never touched the gate, so a ward whose
202// guardians had allowed nothing still got the full player, while the app
203// showed nothing at all. The heavy thing open, the light thing shut.
204test('a player URL rides only when the playback gate is open', async () => {
205 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
206 const yt = JSON.stringify({ url: 'https://www.youtube.com/watch?v=HetoL4XpHwY', title: 'x', media: [] });
207
208 const shut = AP2.timelineEmbed(yt);
209 assert.ok(shut && shut.url, 'the card itself still travels');
210 assert.equal(shut['shaer:playerUrl'], undefined, 'no player without the gate');
211 // But the card says there IS something behind the gate, so the app can
212 // explain the silence instead of ignoring a tap. Robin tapped a video that
213 // could not answer and nothing happened, which reads as broken, not as shut.
214 assert.equal(shut['shaer:playable'], true, 'a shut gate still admits that a player exists');
215
216 const open = AP2.timelineEmbed(yt, { playback: true });
217 assert.match(open['shaer:playerUrl'], /^https:\/\/www\.youtube-nocookie\.com\/embed\/HetoL4XpHwY/,
218 'privacy-enhanced only: nocookie, no related videos');
219 assert.match(open['shaer:playerUrl'], /rel=0/);
220});
221
222test('a page we will not frame simply stays a thumbnail', async () => {
223 const AP2 = (await import('../src/services/ActivityPubService.js')).default;
224 const page = JSON.stringify({ url: 'https://yougubrands.com/about', title: 'About', media: [] });
225 assert.equal(AP2.timelineEmbed(page, { playback: true })['shaer:playerUrl'], undefined);
226 // And no promise of one either: a news article is not a shut gate, it is
227 // simply not a video, and the app must not offer to ask for it.
228 assert.equal(AP2.timelineEmbed(page)['shaer:playable'], undefined);
229 assert.equal(AP2.playerUrlFor('https://nos.nl/artikel/1'), null);
230 // PeerTube is decentralised, so it is matched by shape, not by a host list.
231 assert.equal(AP2.playerUrlFor('https://tilvids.com/w/abc123def'), 'https://tilvids.com/videos/embed/abc123def');
232});
233
234test('playback needs the preview gate: you cannot play what you may not see', async () => {
235 const G2 = await import('../src/services/guardianship/index.js');
236 // Both auto (a ward): both shut.
237 assert.equal(G2.externalEmbedsAllowed(null, true), false);
238 assert.equal(G2.externalPlaybackAllowed(null, true), false);
239 // Guardians opened previews only: playback stays a separate decision.
240 assert.equal(G2.externalEmbedsAllowed(1, true), true);
241 assert.equal(G2.externalPlaybackAllowed(null, true), false);
242 // An adult account has nothing gated.
243 assert.equal(G2.externalPlaybackAllowed(null, false), true);
244});
Note: See TracBrowser for help on using the repository browser.