source: Klonkt/test/co-location.test.js@ f889429

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

FEP-633c §4.2: een ward wordt geen guardian, ook niet stiekem

Klonkt weigerde een Offer van een bekende ward (a_ward_cannot_guard), maar
controleerde de kandidaat daarna nooit meer. Wie vrij was bij het aanbod en
daarna zelf geadopteerd werd, kwam er alsnog doorheen: de ward telde een
guardian wiens escalaties bij aflevering worden weggegooid (§4.1). De commit is
de onomkeerbare stap, dus daar moet het houden.

maybeCommit controleert nu de kandidaat vlak voor het schrijven. Drie
uitkomsten, en de derde is geen falen van de controle maar het niet kunnen
uitvoeren ervan: 'ok' commit, 'malformed' weigert luid, 'unverified' doet geen
van beide. Niet voiden bij onbereikbaar, want dan sloopt één hik een
meerpartijen-adoptie; niet committen ook niet, want dan leg je een guardian
vast die niemand gecontroleerd heeft. Het aanbod blijft staan.

De weigering is luid: het aanbod wordt void, niets wordt vastgelegd, en de
handelende partij stuurt een Reject (met shaer:notATeapot als reden). Een
Reject is wat §3 al kent, dus een server die nog nooit van §4 gehoord heeft
ruimt zijn kopie gewoon op.

Een lokale kandidaat wordt in onze eigen tabel opgezocht in plaats van bij
onszelf opgehaald. De co-location-guard in de tests ving dat ik daarnaast nog
een tweede lokale tak in een beslispad had gezet, voor het versturen van de
Reject; die is weg. De Reject vertrekt nu gewoon van wie er aan het handelen
was, zonder te vragen wie waar woont.

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

  • Property mode set to 100644
File size: 13.3 KB
Line 
1// Everything on this machine behaves as if every Klonkt were somewhere else.
2//
3// Co-location is a TRANSPORT detail: a delivery to a local actor is looped back
4// into the same inbox handler instead of crossing a socket, and nothing above
5// that line knows the difference. The rule exists because two bugs in one week
6// came from a second, local-only path hiding a broken remote one:
7// - the Undo never reached a co-located ward (you cannot HTTP your own inbox);
8// - the gated proposal was broken over the wire, while the local shortcut
9// recorded the vote directly and looked perfectly fine on the dashboard.
10//
11// So the scenario below runs TWICE, all-local and all-remote, and both runs must
12// land in the same place. A shortcut that decides something for a local party
13// shows up here as a difference between the two.
14import { test } from 'node:test';
15import assert from 'node:assert/strict';
16import fs from 'fs';
17
18process.env.DATABASE_PATH = ':memory:';
19process.env.PUBLIC_BASE_URL = 'https://test.example';
20
21const dbMod = await import('../src/config/database.js');
22const db = dbMod.default;
23dbMod.initializeDatabase();
24const AP = (await import('../src/services/ActivityPubService.js')).default;
25const G = await import('../src/services/guardianship/index.js');
26
27const BASE = 'https://test.example';
28const local = (slug) => `${BASE}/ap/users/${slug}`;
29const remote = (name) => `https://${name}.test/u/${name}`;
30
31db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
32 .run('u1', 'u1', 'u1@t', 'x', 'god');
33let n = 0;
34function site(slug) {
35 db.prepare('INSERT OR IGNORE 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 guardianship = (slug, role, other) =>
40 db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id)
41 VALUES (?,?,?,'accepted','o')`).run(slug, role, other);
42
43/**
44 * One ward with three guardians, in a chosen topology. `local` puts all three
45 * on this instance, `remote` puts them elsewhere. The ward is local either way:
46 * the ward's server is the one under test, because it is the one that tallies.
47 */
48function world(prefix, topology) {
49 const wardSlug = `${prefix}ward`;
50 const wardSite = site(wardSlug);
51 const guardians = ['a', 'b', 'c'].map((g) => {
52 if (topology !== 'local') return remote(`${prefix}${g}`);
53 site(`${prefix}${g}`); // a real site, so the loopback lands somewhere
54 guardianship(`${prefix}${g}`, 'guardian', local(wardSlug));
55 return local(`${prefix}${g}`);
56 });
57 for (const g of guardians) guardianship(wardSlug, 'ward', g);
58 return { wardSlug, wardSite, guardians, topology };
59}
60
61// Deliveries are recorded and then handed to the real deliverToActor, so a
62// local recipient travels the same loopback the deployment uses. That is the
63// point: the test drives the road production drives.
64const sent = [];
65G.wireHandshake({
66 selfId: (slug) => local(slug),
67 localSlug: (u) => (u && u.startsWith(`${BASE}/ap/users/`) ? u.split('/').pop() : null),
68 deriveHandle: (u) => `@${String(u).split('/').pop()}`,
69 fetchActor: async (u) => ({ id: u, inbox: `${u}/inbox` }),
70 deliverTo: async (fromSite, toUri, activity) => {
71 sent.push({ from: fromSite.slug, to: toUri, activity });
72 return AP.deliverToActor(fromSite, toUri, activity);
73 },
74 onEvent: null,
75});
76
77/** What the ward's server holds about one gated decision. */
78function gatedState(wardSlug, feature) {
79 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
80 .all(wardSlug, feature);
81 return {
82 voted: votes.length,
83 yes: votes.filter((v) => v.value === 1).length,
84 setting: db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(wardSlug).external_embeds,
85 };
86}
87
88/**
89 * Guardian A proposes link previews. The ward's server records A's own answer
90 * (§3.1's one-step clause) and forwards to B and C. Then B agrees, and two of
91 * three settles it.
92 */
93async function proposeAndSettle({ wardSlug, wardSite, guardians }) {
94 const [A, B] = guardians;
95 const offerId = `${A}/gated/1`;
96 const offer = G.gated.buildGatedOffer(offerId, A, local(wardSlug), 'shaer:externalEmbeds', true);
97 await G.handleGuardianshipInbox(wardSite, offer);
98 const afterPropose = gatedState(wardSlug, 'shaer:externalEmbeds');
99 await G.handleGuardianshipInbox(wardSite, { id: `${B}/accept/1`, type: 'Accept', actor: B, object: offerId });
100 return { afterPropose, afterSecond: gatedState(wardSlug, 'shaer:externalEmbeds') };
101}
102
103const localRun = await proposeAndSettle(world('loc', 'local'));
104const remoteRun = await proposeAndSettle(world('rem', 'remote'));
105
106test('a gated decision reaches the same state whether the guardians are local or remote', () => {
107 assert.deepEqual(localRun.afterPropose, remoteRun.afterPropose,
108 'the tally after one proposal must not depend on where the guardians live');
109 assert.deepEqual(localRun.afterSecond, remoteRun.afterSecond,
110 'nor the outcome after the second answer');
111 // And the shared state is the RIGHT one, so "identical" cannot be satisfied
112 // by both sides being equally broken.
113 assert.equal(localRun.afterPropose.voted, 1, 'one voice after the proposal');
114 assert.equal(localRun.afterPropose.setting, null, 'one voice does not open a gate');
115 assert.equal(localRun.afterSecond.setting, 1, 'two of three does');
116 assert.equal(localRun.afterSecond.voted, 0, 'and the settled decision is cleared');
117});
118
119test('the other guardians are told, local ones no less than remote ones', () => {
120 const fwd = sent.filter((x) => x.activity.object && x.activity.object['shaer:feature']);
121 assert.equal(fwd.filter((x) => x.to.startsWith(BASE)).length, 2,
122 'a guardian on this machine is forwarded to, not skipped for being nearby');
123 assert.equal(fwd.filter((x) => !x.to.startsWith(BASE)).length, 2, 'and so is one elsewhere');
124 for (const x of fwd) {
125 // The forward is signed by the ward's key, so the body must name the ward.
126 // Its absence is what made boiert.eu answer 401 to every forward.
127 assert.match(x.activity.actor, /ward$/, 'the ward relays it under its own name');
128 assert.ok(x.activity['shaer:proposer'], 'with the proposer carried alongside');
129 }
130});
131
132test('a local guardian ends up with a review it can actually answer', () => {
133 // The loopback is only worth having if it produces the same effect on the
134 // receiving side as an HTTP delivery would: a stored proposal, on the
135 // guardian's own dashboard.
136 for (const g of ['a', 'b', 'c'].slice(1)) {
137 const reviews = G.gated.listGatedReviews(`loc${g}`);
138 assert.equal(reviews.length, 1, `guardian loc${g} holds the forwarded proposal`);
139 assert.equal(reviews[0].feature, 'shaer:externalEmbeds');
140 assert.equal(reviews[0].proposer, local('loca'), 'and can see who proposed it');
141 }
142});
143
144// ── §3.6.1 away: the other activity that has to cross the same gap ──
145// A guardian declaring itself away rides a direct note. Direct notes did NOT
146// take the loopback: they resolved an inbox and POSTed to it, so a note to a
147// ward on this machine went out to our own hostname and back, or nowhere. Two
148// hand-written shortcuts existed to paper over it (one in the C2S outbox, one
149// in the Guardian PWA route), which is the pattern this file is about.
150const awaySite = site('awguard');
151const awayWard = site('awward');
152guardianship('awward', 'ward', local('awguard'));
153guardianship('awguard', 'guardian', local('awward'));
154const AWAY_UNTIL = Date.now() + 9 * 24 * 3600 * 1000;
155const awayNote = await AP.deliverDirectNote(awaySite, {
156 recipients: [local('awward')], text: 'even weg', awayUntil: AWAY_UNTIL,
157});
158
159test('a guardian on this machine can declare itself away to a ward on this machine', () => {
160 assert.ok(awayNote && awayNote.id, 'the note was built');
161 assert.equal(awayNote.delivered, 1, 'and it was delivered, not silently dropped for being local');
162 assert.equal(G.availability.effective('awward', local('awguard'), Date.now()), 'away',
163 'the ward server recorded the absence, through the inbox handler like anyone else');
164});
165
166test('the same note read from the wire produces the same state for a remote guardian', () => {
167 // The body the loopback carried is the body an HTTP POST would carry, so
168 // replaying it from a remote actor must land in exactly the same place. This
169 // is what makes the two paths one path rather than two that agree today.
170 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(awayNote.id);
171 const note = AP.buildReplyNote(BASE, awaySite, row);
172 assert.equal(note['shaer:away'], true, 'shaer:away rides on the note itself (§3.6.1)');
173 assert.ok(note.endTime, 'with an end: an absence without one is dropped, never guessed');
174
175 const rw = site('awward2');
176 const far = remote('farg');
177 guardianship('awward2', 'ward', far);
178 // The same note, re-addressed to the second ward and sent by a guardian
179 // elsewhere: the mention tag is how a recipient recognises itself, so it
180 // travels along. Nothing else about the body changes.
181 const readdressed = JSON.parse(JSON.stringify(note).split(local('awward')).join(local('awward2')));
182 const create = { type: 'Create', actor: far, to: [local('awward2')], object: { ...readdressed, id: `${note.id}#2`, attributedTo: far } };
183 return AP.handleInbox(
184 { body: create, ip: '1.2.3.4', protocol: 'https', get: () => 'test.example', headers: {} },
185 rw.slug, { id: far },
186 ).then(() => {
187 assert.equal(G.availability.effective('awward2', far, Date.now()),
188 G.availability.effective('awward', local('awguard'), Date.now()),
189 'a remote guardian and a co-located one leave the ward in the same state');
190 });
191});
192
193test('the loopback still checks the signer against the actor', async () => {
194 // The loopback hands the inbox a verified signer instead of a signature. If
195 // that were taken on faith, a local delivery would be the one place where a
196 // forged actor passes. It is not: the same check runs.
197 site('mmward'); const b = site('mmguard');
198 const status = await AP.handleInbox(
199 { body: { type: 'Offer', actor: local('someone-else'), object: {} }, ip: 'loopback', protocol: 'https', get: () => 'test.example', headers: {} },
200 b.slug,
201 { id: local('mmward') }, // signed as mmward, body claims someone else
202 );
203 assert.equal(status, 401, 'signer mismatch is refused on the loopback too');
204});
205
206test('no guardianship decision takes a shortcut for a local party', () => {
207 // A guard, not a proof. Every place that asks "is this actor one of ours?"
208 // must sit in a function that is allowed to ask: one that WRITES what this
209 // instance hosts after a decision, or READS local state for display. Never
210 // one that decides instead of delivering.
211 //
212 // Adding a name here should feel like a decision. If a new function needs a
213 // local branch in a decision path, that is the bug, not this list.
214 const allowed = {
215 existingGuardiansOf: 'reads our own guardian list instead of fetching our own actor doc',
216 candidateFitness: '§4.2: same question, same source — is this candidate a ward? Our table, not a self-fetch',
217 applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts',
218 endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout',
219 proposeGated: 'reads the tally back for the screen, after delivering',
220 wardGuardianStatuses: 'availability of a ward we host: our own state, for our own screen',
221 wardGateSetting: 'the gate of a ward we host: our own column, for our own screen',
222 'route /wards/release-check': 'counts a ward\'s guardians: ours from the table, someone else\'s from their actor doc',
223 // Known second path, NOT blessed: a gated follow reaches a co-located
224 // guardian through the shared database instead of an Offer, and the answer
225 // travels back the same way. Listed so this guard keeps passing while it
226 // exists, not so it can be forgotten. Removing this line is the definition
227 // of that job being done.
228 wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
229 };
230 const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
231 // Only top-level declarations name a scope; an indented `const base = ...` is
232 // a local and would otherwise take the blame for its enclosing function. A
233 // route handler is an anonymous arrow, so it is named after its path.
234 const declares = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)|^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\()/;
235 const routes = /^router\.\w+\(\s*['"`]([^'"`]+)/;
236 for (const f of files) {
237 const lines = fs.readFileSync(f, 'utf8').split('\n');
238 let fn = '<top level>';
239 for (const [i, line] of lines.entries()) {
240 const d = line.match(declares);
241 const r = line.match(routes);
242 if (d) fn = d[1] || d[2];
243 else if (r) fn = `route ${r[1]}`;
244 // The idioms for "this URI is on this machine": ask the helper, or
245 // compare the URI against our own base.
246 if (!/localSlug\b|\.startsWith\(\s*(?:`\$\{base\}|base\b)/.test(line)) continue;
247 if (/^\s*(\*|\/\/)/.test(line)) continue; // a comment about it is fine
248 assert.ok(allowed[fn], `${f}:${i + 1} branches on co-location inside ${fn}(), which is not on the list:\n ${line.trim()}`);
249 }
250 }
251});
Note: See TracBrowser for help on using the repository browser.