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

main
Last change on this file was 20ae7b0, checked in by roboburr <roboburr@…>, 5 weeks ago

De samenlokatie-bewaker kijkt naar de hele module, niet naar twee bestanden

Bij Fase 2 (4c1e327) voegde ik een sluiproute toe -- followsCollection vult de
wachtrij voor een ward op DEZE instance rechtstreeks uit ap_pending_follows -- en
die kwam geruisloos door de bewaker heen. Niet omdat de regel niet gold, maar
omdat de bewaker twee bestandsnamen scande en mijn code in een derde stond.

Het commentaar erboven belooft "elke plek die vraagt of deze actor van ons is".
Dat werd afgemeten aan handshake.js en routes/guardian.js. Nu aan de hele
guardianship-module plus die route, en dat is wat er stond.

Over de hele module gescand komt er precies EEN nieuwe treffer bovendrijven: die
van mij. De rest stond al op de lijst. Toegevoegd met dezelfde eerlijke TODO als
wardSlugsOf, en met de aantekening dat de twee samen horen te verdwijnen --
het is dezelfde sluiproute op een tweede plek, geen tweede probleem.

Gecontroleerd dat de verbrede bewaker echt bijt: haal die ene regel uit de lijst
en hij wijst queues.js:70 aan met naam en al.

  • Property mode set to 100644
File size: 14.0 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 // Tweede plek van diezelfde sluiproute, aangekomen met Fase 2 (shaer-jdb):
230 // followsCollection vult de wachtrij voor een ward op DEZE instance uit
231 // ap_pending_follows. Hoort mee te verdwijnen met wardSlugsOf hierboven --
232 // niet apart, want het is een sluiproute en geen tweede probleem.
233 slugOf: 'TODO: idem, nu ook in de follows-wachtrij (shaer-h6u)',
234 };
235 // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke
236 // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten
237 // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er
238 // geruisloos doorheen kwam. Dat is precies hoe deze er kwam.
239 const files = [
240 ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js'))
241 .map((f) => `src/services/guardianship/${f}`),
242 'src/routes/guardian.js',
243 ];
244 // Only top-level declarations name a scope; an indented `const base = ...` is
245 // a local and would otherwise take the blame for its enclosing function. A
246 // route handler is an anonymous arrow, so it is named after its path.
247 const declares = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)|^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\()/;
248 const routes = /^router\.\w+\(\s*['"`]([^'"`]+)/;
249 for (const f of files) {
250 const lines = fs.readFileSync(f, 'utf8').split('\n');
251 let fn = '<top level>';
252 for (const [i, line] of lines.entries()) {
253 const d = line.match(declares);
254 const r = line.match(routes);
255 if (d) fn = d[1] || d[2];
256 else if (r) fn = `route ${r[1]}`;
257 // The idioms for "this URI is on this machine": ask the helper, or
258 // compare the URI against our own base.
259 if (!/localSlug\b|\.startsWith\(\s*(?:`\$\{base\}|base\b)/.test(line)) continue;
260 if (/^\s*(\*|\/\/)/.test(line)) continue; // a comment about it is fine
261 assert.ok(allowed[fn], `${f}:${i + 1} branches on co-location inside ${fn}(), which is not on the list:\n ${line.trim()}`);
262 }
263 }
264});
Note: See TracBrowser for help on using the repository browser.