source: Klonkt/test/fep1580-migration.test.js@ fbfd7a1

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

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

  • Property mode set to 100644
File size: 16.2 KB
Line 
1// FEP-1580: je objecten verhuizen mee met een Move.
2//
3// FEP-7628 verhuist je VOLGERS en zegt zelf dat de inhoud een ander probleem is.
4// Dit is dat probleem. Twee rollen, en ze horen bij elkaar: de BRON geeft de
5// nieuwe instantie zijn eigen kijkrechten, de DOELkant haalt op en publiceert
6// een vertaaltabel zodat derden hun verwijzingen kunnen bijwerken.
7//
8// Het hele vertrouwen hangt aan één ding: `moved_to` staat er alleen als
9// moveAccount() een terugverwijzing in alsoKnownAs zag. Dat is tweezijdig
10// bewijs, en daarom is er geen tweede mechanisme (geen token, geen code) nodig.
11// Deze tests leggen precies vast hoever die sleutel reikt, want een sleutel die
12// te ver reikt geeft je hele geschiedenis aan de verkeerde.
13import { test, beforeEach } from 'node:test';
14import assert from 'node:assert/strict';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://nieuw.example';
18
19const dbMod = await import('../src/config/database.js');
20const db = dbMod.default;
21{
22 const stil = console.log;
23 console.log = () => {};
24 try { dbMod.initializeDatabase(); } finally { console.log = stil; }
25}
26const AP = await import('../src/services/ActivityPubService.js');
27const Mig = await import('../src/services/MigrationService.js');
28
29const BRON = 'https://oud.example/ap/users/robo';
30const IK = 'https://nieuw.example/ap/users/ik';
31
32function site({ aliases = [], movedTo = null } = {}) {
33 db.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
34 .run('u1', 'u1', 'u1@test', 'x', 'god');
35 db.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
36 .run('s1', 'ik', 'Mijn site', 'u1');
37 db.prepare('UPDATE sites SET ap_aliases = ?, moved_to = ? WHERE slug = ?')
38 .run(JSON.stringify(aliases), movedTo, 'ik');
39 return db.prepare('SELECT * FROM sites WHERE slug = ?').get('ik');
40}
41
42async function stil(fn) {
43 const w = console.warn; const l = console.log;
44 console.warn = () => {}; console.log = () => {};
45 try { return await fn(); } finally { console.warn = w; console.log = l; }
46}
47
48beforeEach(() => {
49 for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers']) {
50 try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ }
51 }
52});
53
54// ── BRONKANT: hoever reikt de sleutel ─────────────────────────────
55
56test('isMoveTarget geldt alleen voor precies de actor waar we heen gingen', () => {
57 site({ movedTo: BRON });
58 assert.equal(AP.isMoveTarget('ik', BRON), true);
59 assert.equal(AP.isMoveTarget('ik', BRON + '2'), false, 'een prefix is geen match');
60 assert.equal(AP.isMoveTarget('ik', 'https://oud.example/ap/users/iemand'), false, 'zelfde host is niet genoeg');
61 assert.equal(AP.isMoveTarget('ik', ''), false);
62 assert.equal(AP.isMoveTarget('ik', null), false);
63});
64
65test('zonder verhuizing opent de sleutel niets', () => {
66 site({ movedTo: null });
67 assert.equal(AP.isMoveTarget('ik', BRON), false);
68 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'public');
69});
70
71test('de doelinstantie krijgt de fan-only geschiedenis te zien', () => {
72 // Dit is de kern van de bronkant. Zonder deze tak haalt de nieuwe Klonkt
73 // alleen je publieke berichten op en blijft de rest achter op een domein
74 // dat je gaat opzeggen.
75 site({ movedTo: BRON });
76 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'friend');
77 const post = { fan_only: 1 };
78 assert.equal(AP.mayReadNote({ slug: 'ik' }, post, BRON), true);
79 assert.equal(AP.mayReadNote({ slug: 'ik' }, post, 'https://elders.example/users/x'), false,
80 'een vreemde blijft buiten, ook tijdens een verhuizing');
81});
82
83test('een geblokkeerde actor wint van de verhuizing', () => {
84 // De volgorde in outboxAudience is niet toevallig: een block is een gesloten
85 // deur, en die gaat niet open omdat er toevallig een verhuizing loopt. Zou
86 // iemand ooit moved_to naar een geblokkeerd account zetten, dan hoort de
87 // blokkade te winnen en niet andersom.
88 site({ movedTo: BRON });
89 db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES (?, ?, 'actor')").run('ik', BRON);
90 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'blocked');
91});
92
93test('direct-berichten gaan nooit over de lijn, ook niet bij een verhuizing', () => {
94 site({ movedTo: BRON });
95 const dm = { ap_visibility: 'direct' };
96 assert.equal(AP.mayReadNote({ slug: 'ik' }, dm, BRON), false,
97 'een DM is aan iemand gericht; die migreer je niet via een GET');
98});
99
100test('de outbox adresseert een fan-only post NIET als publiek', () => {
101 // Gevonden tijdens de end-to-end test van deze feature, maar het is een
102 // bestaande fout die er los van staat: outboxSlice haalde fan_only en
103 // ap_visibility niet op, dus buildNote zag post.fan_only === undefined en
104 // zette to: as:Public op ALLES. De post werd wel alleen aan vrienden
105 // geserveerd, maar met een publiek etiket erop, en dan mag een volger hem
106 // publiek boosten.
107 //
108 // Deze test hangt hier omdat de migratie erop leunt: de doelkant beslist aan
109 // de hand van dit adres of een bericht in de PUBLIEKE vertaaltabel mag.
110 const s = site();
111 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, fan_only, published_at)
112 VALUES ('pf', 's1', 'u1', 'fans', 'Fans', '<p>x</p>', 'published', 1, '2024-01-01T00:00:00Z')`).run();
113 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
114 VALUES ('pp', 's1', 'u1', 'open', 'Open', '<p>y</p>', 'published', '2024-01-02T00:00:00Z')`).run();
115
116 const { posts } = AP.outboxSlice('s1', { fanOnly: true, page: 1 });
117 const rij = (id) => posts.find((p) => p.id === id);
118 assert.ok(rij('pf'), 'de fan-only post hoort in de vrienden-outbox te zitten');
119 assert.equal(rij('pf').fan_only, 1, 'en zijn zichtbaarheid moet MEE uit de database komen');
120
121 const fan = AP.buildNote('https://nieuw.example', s, rij('pf'));
122 assert.equal(AP.noteVisibility(fan), 'followers',
123 'een fan-only post die as:Public heet mag een volger publiek boosten');
124 const open = AP.buildNote('https://nieuw.example', s, rij('pp'));
125 assert.equal(AP.noteVisibility(open), 'public', 'en een gewone post blijft gewoon publiek');
126});
127
128// ── DOELKANT: de collecties ───────────────────────────────────────
129
130test('de actor adverteert migration en moves, ook als er niets verhuisd is', () => {
131 // De FEP wijst hier apart op: zonder deze twee is "een verhuizing zonder
132 // objecten" niet te onderscheiden van "een server die dit niet kent".
133 const doc = AP.buildActor('https://nieuw.example', site());
134 assert.equal(doc.migration, `${IK}/migration`);
135 assert.equal(doc.moves, `${IK}/moves`);
136});
137
138test('een lege migration-collectie is geldig en zegt dat hij klaar is', () => {
139 const coll = Mig.buildMigration('https://nieuw.example', site());
140 assert.equal(coll.type, 'OrderedCollection');
141 assert.equal(coll.totalItems, 0);
142 assert.equal(coll.migrationComplete, true, 'niets te doen is ook klaar; anders blijven derden pollen');
143 assert.equal(coll.moves, `${IK}/moves`, 'de spec eist de verwijzing naar de moves-collectie');
144});
145
146test('de vertaaltabel mapt oud naar nieuw, nieuwste kopie eerst', () => {
147 const s = site();
148 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a`, sourceActor: BRON });
149 Mig.recordMigrated('ik', { origin: `${BRON}/notes/2`, target: `${IK}/notes/b`, sourceActor: BRON });
150 const coll = Mig.buildMigration('https://nieuw.example', s);
151 assert.equal(coll.totalItems, 2);
152 const items = coll.orderedItems || coll.items;
153 assert.equal(items[0].origin, `${BRON}/notes/2`, 'omgekeerd chronologisch op aanmaakmoment HIER');
154 assert.equal(items[0].type, 'Move');
155 assert.equal(items[0].target, `${IK}/notes/b`);
156});
157
158test('niet-publieke items staan niet in de publieke vertaaltabel', () => {
159 // Een lijst met de URIs van je fan-only posts is een lek, ook zonder inhoud:
160 // hij verraadt hoeveel er zijn en wanneer ze kwamen.
161 const s = site();
162 Mig.recordMigrated('ik', { origin: `${BRON}/notes/pub`, target: `${IK}/notes/a`, isPublic: true });
163 Mig.recordMigrated('ik', { origin: `${BRON}/notes/geheim`, target: `${IK}/notes/b`, isPublic: false });
164 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1);
165 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 2);
166 const publiek = JSON.stringify(Mig.buildMigration('https://nieuw.example', s));
167 assert.ok(!publiek.includes('geheim'), 'de URI zelf mag er ook niet in staan');
168});
169
170test('dezelfde origin twee keer levert een rij, geen dubbele', () => {
171 const s = site();
172 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
173 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
174 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1,
175 'een tweede ingest-ronde mag de tabel niet verdubbelen');
176});
177
178test('de moves-collectie bewaart het bron-actordocument erbij', () => {
179 // De spec: sluit de bron-actor in, zodat een lezer de herkomst kan nakijken
180 // ook als de bron onbereikbaar is. Dat is precies het geval waarvoor dit
181 // bestaat, dus een verwijzing naar de bron zou hier niets waard zijn.
182 const s = site();
183 const actorDoc = { id: BRON, type: 'Person', publicKey: { id: `${BRON}#main-key`, owner: BRON, publicKeyPem: 'x' } };
184 Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: { type: 'Move' }, actorDoc });
185 const coll = Mig.buildMoves('https://nieuw.example', s);
186 assert.equal(coll.totalItems, 1);
187 assert.equal(coll.orderedItems[0].origin, BRON);
188 assert.equal(coll.orderedItems[0].target, IK);
189 assert.equal(coll.orderedItems[0].actor.publicKey.publicKeyPem, 'x', 'ingesloten, niet als verwijzing');
190});
191
192test('er staat GEEN handtekening onder de moves-collectie', () => {
193 // Bewuste keuze zolang shaer-j1v0 (FEP-8b32) open staat. Een leeg of nep
194 // proof-veld is erger dan geen veld: een derde die het controleert wordt dan
195 // misleid. Deze test valt om zodra we 8b32 bouwen, en dat is de bedoeling.
196 const s = site();
197 Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: {} });
198 const coll = Mig.buildMoves('https://nieuw.example', s);
199 assert.equal(coll.proof, undefined,
200 'liever eerlijk niets dan een proof-veld dat niets bewijst (zie shaer-j1v0)');
201});
202
203// ── DOELKANT: de ingest ───────────────────────────────────────────
204
205// Een nep-bron, zodat dit zonder netwerk draait.
206function bronnetje({ movedTo = IK, items = [], blocked = null } = {}) {
207 const actor = {
208 id: BRON, type: 'Person', movedTo,
209 outbox: `${BRON}/outbox`,
210 ...(blocked ? { blocked: `${BRON}/blocked` } : {}),
211 };
212 const kaart = new Map([
213 [BRON, actor],
214 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: items }],
215 ...(blocked ? [[`${BRON}/blocked`, { type: 'OrderedCollection', orderedItems: blocked }]] : []),
216 ]);
217 // noteVisibility erbij: zonder deze telt alles als niet-publiek (fail-closed),
218 // en dan zou de test op de publieke vertaaltabel groen zijn om de verkeerde reden.
219 return {
220 getJson: async (_slug, url) => kaart.get(url) || null,
221 noteId: (b, id) => `${b}/ap/notes/${id}`,
222 noteVisibility: AP.noteVisibility,
223 };
224}
225
226function note(id, extra = {}) {
227 return {
228 type: 'Create',
229 object: {
230 id, type: 'Note', attributedTo: BRON, content: '<p>hallo</p>',
231 published: '2023-05-04T10:00:00Z', to: ['https://www.w3.org/ns/activitystreams#Public'],
232 ...extra,
233 },
234 };
235}
236
237test('de ingest weigert als de bron niet naar ONS wijst', async () => {
238 const s = site({ aliases: [BRON] });
239 const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ movedTo: 'https://iemandanders.example/users/x' }) }));
240 assert.equal(r.error, 'not_moved_here',
241 'anders kon je de geschiedenis opeisen van iedereen die toevallig verhuisde');
242});
243
244test('de ingest weigert zonder onze eigen terugverwijzing', async () => {
245 // Eén kant is een bewering, twee kanten is een afspraak. Dit is de aanval:
246 // iemand laat je een bron-adres invullen, die bron roept movedTo naar ons,
247 // en zonder deze controle trekken we andermans geschiedenis binnen als de
248 // onze. De bron wordt hier EXPLICIET meegegeven, want anders strandt hij al
249 // eerder op no_source en test je niets.
250 const s = site({ aliases: ['https://ietsanders.example/users/ik'] });
251 const r = await stil(() => Mig.ingestFromSource(s, { sourceUri: BRON, deps: bronnetje({}) }));
252 assert.equal(r.error, 'no_backreference');
253 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 0, 'en er is niets binnengekomen');
254});
255
256test('zonder alias en zonder opgegeven bron valt er niets op te halen', async () => {
257 const s = site({ aliases: [] });
258 const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({}) }));
259 assert.equal(r.error, 'no_source');
260});
261
262test('de ingest haalt de berichten op en houdt de oorspronkelijke datum', async () => {
263 const s = site({ aliases: [BRON] });
264 const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
265 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
266 assert.equal(r.posts, 2);
267 const rijen = db.prepare('SELECT slug, published_at, origin_server FROM posts WHERE site_id = ? ORDER BY slug').all('s1');
268 assert.equal(rijen.length, 2);
269 assert.equal(rijen[0].published_at, '2023-05-04T10:00:00Z',
270 'een verhuisd bericht is niet vandaag geschreven; de spec eist behoud van de timestamps');
271 assert.equal(rijen[0].origin_server, 'migrated');
272 // en de vertaaltabel is gevuld, want dat is het punt van de hele operatie
273 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 2);
274});
275
276test('de ingest zet de vlag open tijdens en dicht na afloop', async () => {
277 const s = site({ aliases: [BRON] });
278 await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ items: [note(`${BRON}/notes/1`)] }) }));
279 assert.equal(Mig.migrationComplete('ik'), true, 'pas na afloop mag een derde stoppen met kijken');
280});
281
282test('een tweede ronde slaat over wat er al is', async () => {
283 const s = site({ aliases: [BRON] });
284 const deps = bronnetje({ items: [note(`${BRON}/notes/1`)] });
285 await stil(() => Mig.ingestFromSource(s, { deps }));
286 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
287 assert.equal(r.posts, 0);
288 assert.equal(r.overgeslagen, 1);
289 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1, 'geen dubbele berichten');
290});
291
292test('blokkades komen eerst binnen', async () => {
293 // De spec zet dit expliciet vooraan: blokkades bepalen wie de rest te zien
294 // krijgt. Andersom importeer je je hele geschiedenis zichtbaar voor precies
295 // degene die je buiten wilde houden.
296 const s = site({ aliases: [BRON] });
297 const deps = bronnetje({ items: [note(`${BRON}/notes/1`)], blocked: ['https://elders.example/users/pest'] });
298 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
299 assert.equal(r.blocks, 1);
300 const b = db.prepare("SELECT target FROM ap_blocks WHERE slug = 'ik' AND kind = 'actor'").all();
301 assert.deepEqual(b.map((x) => x.target), ['https://elders.example/users/pest']);
302});
303
304test('de ingest neemt geen antwoorden en niets van een ander mee', async () => {
305 const s = site({ aliases: [BRON] });
306 const deps = bronnetje({
307 items: [
308 note(`${BRON}/notes/1`),
309 note(`${BRON}/notes/2`, { inReplyTo: 'https://elders.example/notes/9' }),
310 note(`${BRON}/notes/3`, { attributedTo: 'https://elders.example/users/ander' }),
311 ],
312 });
313 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
314 assert.equal(r.posts, 1, 'alleen je eigen toplevel-berichten verhuizen mee');
315});
316
317test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
318 const s = site({ aliases: [BRON] });
319 const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
320 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
321 assert.equal(r.posts, 1);
322 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
323 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
324});
Note: See TracBrowser for help on using the repository browser.