| 1 | // FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
|
|---|
| 2 | //
|
|---|
| 3 | // A ward's own follow used to go straight out; the guardians got a note
|
|---|
| 4 | // afterwards, which is informing, not gating — the door is already open by the
|
|---|
| 5 | // time the message lands. Now it waits, with two exceptions that are not
|
|---|
| 6 | // favours but the same decision already taken: the ward's own guardian, and
|
|---|
| 7 | // someone a guardian already admitted through the inbound gate.
|
|---|
| 8 | //
|
|---|
| 9 | // The mutual shortcut only counts followers who came through that gate. A
|
|---|
| 10 | // follower a free actor collected before it was ever a ward was never seen by
|
|---|
| 11 | // a guardian, so following them back is a new question. Rows that predate the
|
|---|
| 12 | // marker are grandfathered (Barts besluit, 3-8).
|
|---|
| 13 | import { test } from 'node:test';
|
|---|
| 14 | import assert from 'node:assert/strict';
|
|---|
| 15 |
|
|---|
| 16 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 17 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 18 |
|
|---|
| 19 | const dbMod = await import('../src/config/database.js');
|
|---|
| 20 | const db = dbMod.default;
|
|---|
| 21 | dbMod.initializeDatabase();
|
|---|
| 22 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 23 | const G = await import('../src/services/guardianship/index.js');
|
|---|
| 24 |
|
|---|
| 25 | const BASE = 'https://test.example';
|
|---|
| 26 | const local = (slug) => `${BASE}/ap/users/${slug}`;
|
|---|
| 27 | const STRANGER = 'https://elders.example/users/stranger';
|
|---|
| 28 | const PAL = 'https://elders.example/users/pal';
|
|---|
| 29 | const OLDPAL = 'https://elders.example/users/oldpal';
|
|---|
| 30 |
|
|---|
| 31 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 32 | .run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 33 | let n = 0;
|
|---|
| 34 | function site(slug) {
|
|---|
| 35 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)')
|
|---|
| 36 | .run(`s${++n}`, slug, slug, 'u1', n === 1 ? 1 : 0);
|
|---|
| 37 | return db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
|
|---|
| 38 | }
|
|---|
| 39 | const guards = (slug, other) =>
|
|---|
| 40 | db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status) VALUES (?, 'ward', ?, 'accepted')")
|
|---|
| 41 | .run(slug, other);
|
|---|
| 42 | const follower = (slug, uri, gateApproved) =>
|
|---|
| 43 | db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox, gate_approved) VALUES (?,?,?,?)')
|
|---|
| 44 | .run(slug, uri, `${uri}/inbox`, gateApproved ? 1 : 0);
|
|---|
| 45 |
|
|---|
| 46 | const kid = site('kid');
|
|---|
| 47 | site('mum');
|
|---|
| 48 | guards('kid', local('mum')); // kid is a ward, watched by mum
|
|---|
| 49 | follower('kid', PAL, true); // came through the §5.3 gate
|
|---|
| 50 | follower('kid', OLDPAL, false); // followed back when kid was still free
|
|---|
| 51 |
|
|---|
| 52 | const free = site('freebird'); // no guardians at all
|
|---|
| 53 |
|
|---|
| 54 | test('a free actor is not gated at all', async () => {
|
|---|
| 55 | assert.equal(await AP.gateOutgoingFollow(free, STRANGER), null,
|
|---|
| 56 | 'guardianship is the only thing that gates a follow; a free account keeps its own counsel');
|
|---|
| 57 | });
|
|---|
| 58 |
|
|---|
| 59 | test('a stranger has to wait for the guardians', async () => {
|
|---|
| 60 | const held = await AP.gateOutgoingFollow(kid, STRANGER);
|
|---|
| 61 | assert.ok(held, 'held, not sent');
|
|---|
| 62 | assert.equal(held.status, 'pending');
|
|---|
| 63 | assert.equal(held.target_uri, STRANGER);
|
|---|
| 64 | });
|
|---|
| 65 |
|
|---|
| 66 | test('following your own guardian needs nobody\'s permission', async () => {
|
|---|
| 67 | assert.equal(await AP.gateOutgoingFollow(kid, local('mum')), null,
|
|---|
| 68 | 'asking mum whether you may follow mum is not a question');
|
|---|
| 69 | });
|
|---|
| 70 |
|
|---|
| 71 | test('a follower the guardians already admitted may be followed back', async () => {
|
|---|
| 72 | assert.equal(await AP.gateOutgoingFollow(kid, PAL), null,
|
|---|
| 73 | 'a guardian said yes to this person by name; asking twice teaches people to stop reading');
|
|---|
| 74 | });
|
|---|
| 75 |
|
|---|
| 76 | test('but a follower from before the guardians existed is a fresh question', async () => {
|
|---|
| 77 | const held = await AP.gateOutgoingFollow(kid, OLDPAL);
|
|---|
| 78 | assert.ok(held, 'never went through the gate, so nobody ever vetted them');
|
|---|
| 79 | assert.equal(held.status, 'pending');
|
|---|
| 80 | });
|
|---|
| 81 |
|
|---|
| 82 | test('asking twice does not queue the same request twice', async () => {
|
|---|
| 83 | const again = await AP.gateOutgoingFollow(kid, STRANGER);
|
|---|
| 84 | assert.ok(again);
|
|---|
| 85 | assert.equal(G.outgoing.listForWard('kid').filter((o) => o.target_uri === STRANGER).length, 1);
|
|---|
| 86 | });
|
|---|
| 87 |
|
|---|
| 88 | test('the guardians see it in their own queue, apart from the inbound one', () => {
|
|---|
| 89 | const q = G.outgoingFollowsCollection(`${local('kid')}/queues/outgoing-follows`, 'kid', local('mum'));
|
|---|
| 90 | const mine = q.orderedItems.filter((o) => o['shaer:target'] === STRANGER);
|
|---|
| 91 | assert.equal(mine.length, 1);
|
|---|
| 92 | assert.equal(mine[0]['shaer:direction'], 'outgoing',
|
|---|
| 93 | 'a guardian must be able to tell "wants to follow your ward" from "your ward wants to follow"');
|
|---|
| 94 | assert.equal(mine[0]['shaer:myVote'], false);
|
|---|
| 95 | });
|
|---|
| 96 |
|
|---|
| 97 | test('a guardian approving lets it through from then on', async () => {
|
|---|
| 98 | const pending = G.outgoing.listForWard('kid').find((o) => o.target_uri === STRANGER);
|
|---|
| 99 | const r = G.outgoing.decide(pending.id, local('mum'), 'approve', [local('mum')]);
|
|---|
| 100 | assert.equal(r.outcome, 'approved');
|
|---|
| 101 | assert.equal(await AP.gateOutgoingFollow(kid, STRANGER), null,
|
|---|
| 102 | 'the row stays behind as the record, so an unfollow and refollow is not a second question');
|
|---|
| 103 | });
|
|---|
| 104 |
|
|---|
| 105 | test('a refusal is remembered too, and does not re-ask by re-tapping', async () => {
|
|---|
| 106 | const held = await AP.gateOutgoingFollow(kid, OLDPAL);
|
|---|
| 107 | const r = G.outgoing.decide(held.id, local('mum'), 'reject', [local('mum')]);
|
|---|
| 108 | assert.equal(r.outcome, 'rejected');
|
|---|
| 109 | const again = await AP.gateOutgoingFollow(kid, OLDPAL);
|
|---|
| 110 | assert.equal(again.status, 'denied', 'tapping follow again does not put it back in front of mum');
|
|---|
| 111 | });
|
|---|