source: Klonkt/test/archive-import.test.js@ 58cfe5f

main
Last change on this file since 58cfe5f was 58cfe5f, checked in by roboburr <roboburr@…>, 5 weeks ago

De importer (shaer-pmr)

ArchiveImportService leest het formaat terug, plus een CLI
(scripts/import-archive.mjs) met droogloop en --overwrite. De vier eisen uit het
document staan expliciet in de code:

VERSIE EERST een hogere onbekende formatVersion wordt in zijn GEHEEL

geweigerd; half lezen ziet eruit alsof het gelukt is

IDENTITEIT gelijke origin -> AP-ids behouden, anders nieuwe ids met een

waarschuwing. De importer stelt dat zelf vast; een verkeerd
antwoord publiceert objecten onder een id dat je niet beheert

NIETS STILS ontbrekende media worden geteld en gemeld, en een archief dat

zijn eigen bestand mist is een APARTE melding -- dat is een
kapot archief, geen verdwenen media

GEEN UITZENDING geen Update de fediverse in

Botsingsregel, gedocumenteerd en niet per post gegokt: een bestaande post met
hetzelfde id of dezelfde slug wordt OVERGESLAGEN. Dat maakt de import idempotent
en zorgt dat je nooit per ongeluk vernietigt wat er al staat. --overwrite doet
het wel, expliciet.

Media gaan terug naar hun oorspronkelijke pad onder /media, want de content van
de post wijst daarheen; dat pad is site-relatief en werkt dus ook op een ander
domein. Checksum wordt gecontroleerd voordat er iets wordt weggeschreven, en een
pad buiten MEDIA_ROOT nooit. Gehoste audio krijgt zijn media- en audio_tracks-rij
terug, anders valt [[track:]] op niets terug -- daarvoor draagt shaer:audio nu
een shaer:media-verwijzing, die in het formaat ontbrak.

readable/ wordt nooit gelezen, en daar is een test voor: vul het met onzin en er
verandert niets.

De zip-lezer kent ook deflate. Een lezer die alleen onze eigen store-only zip
aankan is geen uitwisselformaat maar een prive-doosje.

DERDE VONDST UIT ECHTE DATA vandaag: bij de rondgang op beta bleek poll_json.closed
weg te vallen. Een poll die je vroegtijdig sluit met een einddatum in de toekomst
stond na een herstel weer open. AS2 kent daar "closed" voor op een Question; nu
beide kanten op.

Rondgang op echte beta-data: 4 posts, 3 media, 1 antwoordenarchief, ids behouden,
en na de fix geen enkel kolomverschil meer. 19 tests. Suite 512/512.

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

  • Property mode set to 100644
File size: 15.1 KB
Line 
1// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
2//
3// De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan
4// maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een
5// exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten
6// zijn samen niets waard.
7//
8// Run: npm test
9
10import { test } from 'node:test';
11import assert from 'node:assert/strict';
12import fs from 'fs';
13import os from 'os';
14import path from 'path';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
18const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-'));
19process.env.MEDIA_PATH = MEDIA;
20
21const dbMod = await import('../src/config/database.js');
22const db = dbMod.default;
23dbMod.initializeDatabase();
24const AX = await import('../src/services/ArchiveExportService.js');
25const AI = await import('../src/services/ArchiveImportService.js');
26
27db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
28 .run('u1', 'u1', 'u1@test', 'x', 'god');
29db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
30
31fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
32const PLAATJE = Buffer.from('bytes-van-de-cover');
33fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
34
35db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at,
36 created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor,
37 poll_json, language, tags, pinned, view_count)
38 VALUES ('zwaar','s1','zware-post','u1','Een zware post',
39 '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">','de teaser','published',
40 '2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
41 'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
42 '{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
43 'nl','muziek, code',1,42)`).run();
44db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
45 VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','<p>hoi</p>','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`)
46 .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
47
48const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' });
49const leeg = () => {
50 db.prepare('DELETE FROM posts').run();
51 db.prepare('DELETE FROM ap_interactions').run();
52 try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ }
53};
54
55test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => {
56 leeg();
57 const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
58 assert.equal(r.posts, 1);
59 assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven');
60
61 const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar');
62 assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar');
63 assert.equal(p.title, 'Een zware post');
64 assert.equal(p.slug, 'zware-post');
65 assert.match(p.content, /<p>body<\/p>/);
66 assert.equal(p.excerpt, 'de teaser');
67 assert.equal(p.content_warning, 'spoiler');
68 assert.equal(p.nsfw, 1);
69 assert.equal(p.paid, 1);
70 assert.equal(p.paid_min_cents, 500);
71 assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
72 assert.equal(p.language, 'nl');
73 assert.equal(p.tags, 'muziek, code');
74 assert.equal(p.pinned, 1);
75 assert.equal(p.view_count, 42);
76 assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
77});
78
79test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
80 const op = path.join(MEDIA, '2026', 'cover.jpg');
81 assert.ok(fs.existsSync(op), 'anders is de <img> in de body een dode verwijzing');
82 assert.deepEqual(fs.readFileSync(op), PLAATJE);
83});
84
85test('de antwoorden komen terug als archief', () => {
86 const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
87 assert.equal(r.length, 1);
88 assert.equal(r[0].actor_name, 'Anna');
89 assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
90});
91
92test('twee keer importeren levert geen dubbele posts op', () => {
93 const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
94 const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
95 assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
96 assert.equal(r.posts, 0);
97 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
98});
99
100test('overschrijven gebeurt alleen als je erom vraagt', () => {
101 db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
102 AI.importArchive(ARCHIEF.files, { slug: 'me' });
103 assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
104 'zonder --overwrite blijft wat er staat staan');
105 const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
106 assert.equal(r.overschreven, 1);
107 assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
108});
109
110test('een droogloop schrijft niets', () => {
111 leeg();
112 const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
113 assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
114 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
115 assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
116});
117
118test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
119 // Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
120 const files = new Map(ARCHIEF.files);
121 const m = JSON.parse(files.get('manifest.json').toString());
122 m.formatVersion = 99;
123 files.set('manifest.json', Buffer.from(JSON.stringify(m)));
124 assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
125 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
126});
127
128test('een andere origin levert NIEUWE ids op, met een waarschuwing', () => {
129 // Oude ids houden op een ander domein zou objecten publiceren onder een id dat
130 // je niet beheert -- andere servers halen dat daar op, en het is bovendien een
131 // vervalsingsoppervlak.
132 leeg();
133 const files = new Map(ARCHIEF.files);
134 const m = JSON.parse(files.get('manifest.json').toString());
135 m.origin = 'https://ergens-anders.test';
136 files.set('manifest.json', Buffer.from(JSON.stringify(m)));
137 const r = AI.importArchive(files, { slug: 'me' });
138 assert.equal(r.idsBehouden, false);
139 assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
140 assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 0);
141 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1, 'wel geimporteerd, met een nieuw id');
142});
143
144test('ontbrekende media worden geteld en gemeld', () => {
145 leeg();
146 const files = new Map(ARCHIEF.files);
147 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
148 const o = JSON.parse(files.get(pad).toString());
149 o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: 'https://oud.test/media/weg.jpg', 'shaer:availability': 'missing', 'shaer:originalUrl': 'https://oud.test/media/weg.jpg' }];
150 files.set(pad, Buffer.from(JSON.stringify(o)));
151 const r = AI.importArchive(files, { slug: 'me' });
152 assert.equal(r.mediaMissing, 1);
153 assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
154});
155
156test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
157 // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
158 // media. Die twee op een hoop gooien verbergt een echt probleem.
159 leeg();
160 const files = new Map(ARCHIEF.files);
161 for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
162 const r = AI.importArchive(files, { slug: 'me' });
163 assert.equal(r.mediaMissing, 0);
164 assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
165});
166
167test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
168 leeg();
169 const files = new Map(ARCHIEF.files);
170 const k = [...files.keys()].find((x) => x.startsWith('media/'));
171 files.set(k, Buffer.from('iets heel anders'));
172 const r = AI.importArchive(files, { slug: 'me' });
173 assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
174 assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
175});
176
177test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
178 leeg();
179 const files = new Map(ARCHIEF.files);
180 const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
181 const c = JSON.parse(files.get(pad).toString());
182 delete c['shaer:archive'];
183 files.set(pad, Buffer.from(JSON.stringify(c)));
184 const r = AI.importArchive(files, { slug: 'me' });
185 assert.equal(r.replies, 0);
186 assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
187});
188
189test('een pad buiten de media-map wordt niet geschreven', () => {
190 leeg();
191 const files = new Map(ARCHIEF.files);
192 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
193 const o = JSON.parse(files.get(pad).toString());
194 const mk = [...files.keys()].find((k) => k.startsWith('media/'));
195 o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
196 files.set(pad, Buffer.from(JSON.stringify(o)));
197 AI.importArchive(files, { slug: 'me' });
198 assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
199});
200
201test('readable/ wordt nooit gelezen', () => {
202 // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
203 // worden gaan uiteenlopen.
204 leeg();
205 const files = new Map(ARCHIEF.files);
206 for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
207 const r = AI.importArchive(files, { slug: 'me' });
208 assert.equal(r.posts, 1);
209 assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
210});
211
212test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
213 const zip = path.join(MEDIA, 'rond.zip');
214 fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
215 const uit = AI.readArchive(zip);
216 assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
217 for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
218});
219
220test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
221 // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
222 // geen uitwisselformaat maar een privé-doosje.
223 const zlib = await import('zlib');
224 const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
225 const naam = Buffer.from('manifest.json', 'utf8');
226 const comp = zlib.deflateRawSync(inhoud);
227 const crc = (() => {
228 let c = -1;
229 for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
230 return (c ^ -1) >>> 0;
231 })();
232 const lh = Buffer.alloc(30);
233 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
234 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
235 lh.writeUInt16LE(naam.length, 26);
236 const ch = Buffer.alloc(46);
237 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
238 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
239 ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
240 const eocd = Buffer.alloc(22);
241 eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
242 eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
243 const zip = path.join(MEDIA, 'deflate.zip');
244 fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
245 assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
246});
247
248test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
249 leeg();
250 db.prepare('DELETE FROM audio_tracks').run();
251 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
252 const WAV = Buffer.from('nep-audio-bytes');
253 fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
254 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
255 .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
256 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
257 VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
258 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
259 VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
260
261 const arch = AX.buildArchive('me', { exportedAt: 'X' });
262 db.prepare('DELETE FROM posts').run();
263 db.prepare('DELETE FROM audio_tracks').run();
264 db.prepare('DELETE FROM media').run();
265 fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
266
267 AI.importArchive(arch.files, { slug: 'me' });
268 const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
269 assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
270 assert.equal(t.title, 'Kanonnen');
271 assert.equal(t.license, 'CC BY');
272 const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
273 assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
274 assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
275});
276
277test('een vroegtijdig gesloten poll blijft gesloten', () => {
278 // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
279 // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
280 // herstel weer open. AS2 kent hiervoor `closed` op een Question.
281 leeg();
282 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
283 VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
284 '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
285 const arch = AX.buildArchive('me', { exportedAt: 'X' });
286 assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
287 db.prepare('DELETE FROM posts').run();
288 AI.importArchive(arch.files, { slug: 'me' });
289 const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
290 assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
291 assert.equal(d.endTime, '2030-01-01T00:00:00Z');
292});
293
294test('zonder manifest is het geen archief', () => {
295 const files = new Map(ARCHIEF.files);
296 files.delete('manifest.json');
297 assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
298});
299
300test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.