| 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 | });
|
|---|