source: Klonkt/test/outgoing-follow-gate.test.js@ fa33214

main
Last change on this file since fa33214 was fa33214, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

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

  • Property mode set to 100644
File size: 5.2 KB
Line 
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).
13import { test } from 'node:test';
14import assert from 'node:assert/strict';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://test.example';
18
19const dbMod = await import('../src/config/database.js');
20const db = dbMod.default;
21dbMod.initializeDatabase();
22const AP = (await import('../src/services/ActivityPubService.js')).default;
23const G = await import('../src/services/guardianship/index.js');
24
25const BASE = 'https://test.example';
26const local = (slug) => `${BASE}/ap/users/${slug}`;
27const STRANGER = 'https://elders.example/users/stranger';
28const PAL = 'https://elders.example/users/pal';
29const OLDPAL = 'https://elders.example/users/oldpal';
30
31db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
32 .run('u1', 'u1', 'u1@test', 'x', 'god');
33let n = 0;
34function 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}
39const guards = (slug, other) =>
40 db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status) VALUES (?, 'ward', ?, 'accepted')")
41 .run(slug, other);
42const 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
46const kid = site('kid');
47site('mum');
48guards('kid', local('mum')); // kid is a ward, watched by mum
49follower('kid', PAL, true); // came through the §5.3 gate
50follower('kid', OLDPAL, false); // followed back when kid was still free
51
52const free = site('freebird'); // no guardians at all
53
54test('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
59test('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
66test('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
71test('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
76test('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
82test('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
88test('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
97test('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
105test('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});
Note: See TracBrowser for help on using the repository browser.