| 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.
|
|---|
| 3 | import { test } from 'node:test';
|
|---|
| 4 | import assert from 'node:assert/strict';
|
|---|
| 5 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 6 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 7 | const dbMod = await import('../src/config/database.js');
|
|---|
| 8 | dbMod.initializeDatabase();
|
|---|
| 9 | const { tallyGatedSetting, thresholdFor, featureColumn } = await import('../src/services/guardianship/gated.js');
|
|---|
| 10 |
|
|---|
| 11 | const G3 = ['https://a/g1', 'https://b/g2', 'https://c/g3']; // three, on three servers
|
|---|
| 12 | const vote = (uri, value) => ({ guardian_uri: uri, value });
|
|---|
| 13 |
|
|---|
| 14 | test('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 |
|
|---|
| 21 | test('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 |
|
|---|
| 26 | test('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 |
|
|---|
| 31 | test('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 |
|
|---|
| 36 | test('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 |
|
|---|
| 43 | test('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 |
|
|---|
| 48 | test('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 |
|
|---|
| 52 | test('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 |
|
|---|
| 58 | test('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.
|
|---|
| 68 | test('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 |
|
|---|
| 75 | test('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.
|
|---|
| 90 | test('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 |
|
|---|
| 161 | test("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.
|
|---|
| 204 | test('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 |
|
|---|
| 222 | test('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 |
|
|---|
| 234 | test('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 | });
|
|---|