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

main
Last change on this file since cae8ded was 6d5ce0c, checked in by Robin <roboburr@…>, 6 weeks ago

Op deze machine gedraagt elke Klonkt zich alsof hij ergens anders staat

Robins regel, en de reden staat in de logs van deze week. Twee bugs kwamen uit
hetzelfde patroon: een tweede, lokale route die een kapotte externe route
verborg. De Undo bereikte een kind op dezelfde machine nooit, en het gated
voorstel was een maand stuk over de lijn terwijl de sluiproute de stem hier
direct opschreef en het dashboard er prima uitzag.

Samenlokatie is nu een kwestie van TRANSPORT, geen beslispad. deliverToActor
geeft een activiteit voor een lokale ontvanger door aan dezelfde inbox-handler
die de lijn zou bereiken, inclusief de controle of de ondertekenaar de afzender
is. Alles daarboven weet het verschil niet meer, en dus draait elke deployment
dezelfde code.

Directe berichten deden dat nog niet. Die zochten een inbox op en POSTten
erheen, dus een bericht aan een kind op deze machine ging naar onze eigen
hostnaam en terug, of nergens heen. Nu nemen ze dezelfde loopback.

En de kern van het probleem zat in de inbox zelf: "van onze eigen actor" werd
gelezen als "van wie dan ook op deze machine". Daardoor werd elk bericht tussen
twee sites op een instantie met een 202 aangenomen en daarna weggegooid: geen
vermelding, geen afwezigheid, geen hulpvraag. Buren zijn niet wij.

Daarmee konden twee met de hand geschreven sluiproutes weg: het lokaal
wegschrijven van een afwezigheid in de C2S-outbox en in de Guardian PWA. Die
bestonden alleen omdat de echte weg niet aankwam.

Changed files:
src/services/ActivityPubService.js

  • isLocalActor is nu "de eigenaar van deze inbox", niet "iemand op deze host"
  • localActor(): het actordocument van een site die wij hosten, uit onze eigen database in plaats van via een verzoek aan onszelf
  • de lokale sluiproute voor afwezigheid in de C2S-outbox is weg

src/services/guardianship/delivery.js

  • een lokale ontvanger krijgt het bericht via de loopback, de rest per inbox
  • een lokale ontvanger wordt lokaal opgezocht, dus hij valt niet stilletjes uit de ontvangerslijst als het verzoek aan onszelf mislukt

src/routes/guardian.js

  • /api/away schrijft niets meer zelf weg: het bericht doet het werk

src/services/guardianship/handshake.js

  • commentaar bijgewerkt bij de plekken die wel lokaal mogen schrijven

New file:
test/co-location.test.js

  • hetzelfde scenario twee keer, alles-lokaal en alles-extern, met de eis dat de eindtoestand gelijk is
  • een afwezigheid via de loopback en dezelfde brief van de lijn gelezen
  • de loopback weigert nog steeds een afzender die niet klopt
  • een bewaking die faalt zodra er een nieuwe lokale sluiproute in een beslispad verschijnt, met de legitieme uitzonderingen bij naam

remarks: 329 tests groen, en de server start. Twee dingen om te weten voor de
uitrol: sites op een instantie die elkaar volgen zien elkaars berichten nu wel
(dat is wat volgen betekent, maar het is zichtbaar anders), en gated follows
lopen voor een lokale guardian nog steeds via de gedeelde database. Die laatste
staat met naam en toenaam in de bewakingstest, zodat hij niet vergeten wordt.

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

  • Property mode set to 100644
File size: 13.2 KB
RevLine 
[6d5ce0c]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 applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts',
217 endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout',
218 proposeGated: 'reads the tally back for the screen, after delivering',
219 wardGuardianStatuses: 'availability of a ward we host: our own state, for our own screen',
220 wardGateSetting: 'the gate of a ward we host: our own column, for our own screen',
221 'route /wards/release-check': 'counts a ward\'s guardians: ours from the table, someone else\'s from their actor doc',
222 // Known second path, NOT blessed: a gated follow reaches a co-located
223 // guardian through the shared database instead of an Offer, and the answer
224 // travels back the same way. Listed so this guard keeps passing while it
225 // exists, not so it can be forgotten. Removing this line is the definition
226 // of that job being done.
227 wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
228 };
229 const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
230 // Only top-level declarations name a scope; an indented `const base = ...` is
231 // a local and would otherwise take the blame for its enclosing function. A
232 // route handler is an anonymous arrow, so it is named after its path.
233 const declares = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)|^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\()/;
234 const routes = /^router\.\w+\(\s*['"`]([^'"`]+)/;
235 for (const f of files) {
236 const lines = fs.readFileSync(f, 'utf8').split('\n');
237 let fn = '<top level>';
238 for (const [i, line] of lines.entries()) {
239 const d = line.match(declares);
240 const r = line.match(routes);
241 if (d) fn = d[1] || d[2];
242 else if (r) fn = `route ${r[1]}`;
243 // The idioms for "this URI is on this machine": ask the helper, or
244 // compare the URI against our own base.
245 if (!/localSlug\b|\.startsWith\(\s*(?:`\$\{base\}|base\b)/.test(line)) continue;
246 if (/^\s*(\*|\/\/)/.test(line)) continue; // a comment about it is fine
247 assert.ok(allowed[fn], `${f}:${i + 1} branches on co-location inside ${fn}(), which is not on the list:\n ${line.trim()}`);
248 }
249 }
250});
Note: See TracBrowser for help on using the repository browser.