source: Klonkt/test/archive-export.test.js@ 6248497

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

Export en import kiezen de juiste instance, en tijdstempels zijn echt UTC

Twee fouten, allebei op productie gevonden en allebei van het soort dat SUCCES
meldt.

## 1. Het script opende de verkeerde database

Op een split install deelt elke Klonkt de code in /opt/klonkt maar staat zijn data
onder /var/lib/klonkt/<slug>/ met een eigen .env. De scripts lazen die .env niet,
dus database.js viel terug op storage/database.sqlite IN DE CODE-MAP. Op een
server die ooit de enkelvoudige opzet draaide ligt daar een achtergebleven oude
database.

Waargenomen: export-archive.mjs boiert schreef een archief van 444 bytes met
"posts: 0" en meldde dat het gelukt was, terwijl het in een oude lege database
keek. liz en sood gaven "onbekende site". Het script raakte die oude database
ook nog aan (boot-migraties), terwijl het alleen hoort te lezen.

Nu leest scripts/instance-env.mjs de .env van de instance (standaard
/var/lib/klonkt/<slug>/.env, of --data-root / --env) VOORDAT de service geladen
wordt -- database.js opent de database namelijk bij import. De uitvoer noemt
voortaan welke .env en welke database gebruikt zijn.

En zonder PUBLIC_BASE_URL stopt hij nu HARD in plaats van het als bijzin te
melden: zonder origin krijgt het archief een lege origin, en dan maakt een import
nieuwe AP-ids. Dan is het een kopie van de tekst en geen herstel -- precies de
belofte waar het formaat om draait.

Voor de importer weegt dit zwaarder dan voor de exporter: in de verkeerde
database schrijven draai je niet terug.

## 2. Tijdstempels schoven met de tijdzone van de machine

Vraag van Bart: normaliseert de export wel naar UTC? Nee.

SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar zonder
zone. Date.parse leest die vorm als LOKALE tijd. Op een machine in Amsterdam komt
daar 10:56:10Z uit: twee uur verschoven, in elk archief.

Geen enkele test kon dat zien, want de testmachine draait op UTC. En mijn eigen
rondgang-vergelijking plakte er zelf een Z achter, waarmee ik er precies overheen
keek.

toISO leest een zoneloze vorm nu expliciet als UTC. Nieuwe test die alleen onder
een niet-UTC zone iets bewijst; gecontroleerd dat hij onder Europe/Amsterdam
omvalt zonder de fix. Hele suite 577/577 onder zowel UTC als Europe/Amsterdam.

  • Property mode set to 100644
File size: 11.6 KB
Line 
1// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
2//
3// De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan
4// maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een
5// content warning en betaalde toegang. Die staat hieronder als eerste.
6//
7// Run: npm test
8
9import { test } from 'node:test';
10import assert from 'node:assert/strict';
11import fs from 'fs';
12import os from 'os';
13import path from 'path';
14import crypto from 'crypto';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
18
19// De media-map moet bestaan voordat paths.js hem uitleest.
20const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-'));
21process.env.MEDIA_PATH = MEDIA;
22
23const dbMod = await import('../src/config/database.js');
24const db = dbMod.default;
25dbMod.initializeDatabase();
26const AX = await import('../src/services/ArchiveExportService.js');
27
28db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
29 .run('u1', 'u1', 'u1@test', 'x', 'god');
30db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
31
32fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
33const PLAATJE = Buffer.from('dit-zijn-de-bytes-van-een-plaatje');
34fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
35const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex');
36
37function post(id, extra = {}) {
38 const kol = {
39 id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '<p>inhoud</p>',
40 status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00',
41 updated_at: '2026-08-01 10:00:00', ...extra,
42 };
43 const namen = Object.keys(kol);
44 db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`)
45 .run(...namen.map((n) => kol[n]));
46}
47
48const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8'));
49
50test('de moeilijkste post overleeft de export heel', () => {
51 post('zwaar', {
52 title: 'Een zware post',
53 content: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">',
54 content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
55 quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
56 poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
57 language: 'nl', tags: 'muziek, code',
58 });
59 const uit = AX.buildArchive('me');
60 const o = lees(uit, 'posts/zwaar.json');
61
62 assert.equal(o.type, 'Question', 'een poll exporteert als Question');
63 assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
64 assert.equal(o.endTime, '2026-08-09T10:00:00Z');
65 assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
66 assert.equal(o.sensitive, true);
67 assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
68 assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
69 assert.equal(o['shaer:paidMinCents'], 500);
70 assert.deepEqual(o.contentMap, { nl: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">' });
71 assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
72});
73
74test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
75 // buildNote() federeert bewust alleen een teaser. Zou de export die projectie
76 // volgen, dan archiveer je je eigen betaalde post als samenvatting.
77 const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
78 assert.match(o.content, /<p>body<\/p>/);
79 assert.equal(o['shaer:paid'], true);
80});
81
82test('de titel staat in name, niet in de content gebakken', () => {
83 // Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
84 // daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
85 const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
86 assert.equal(o.name, 'Een zware post');
87 assert.equal(o.type, 'Question');
88 assert.doesNotMatch(o.content, /<strong>Een zware post<\/strong>/);
89});
90
91test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
92 const uit = AX.buildArchive('me');
93 const o = lees(uit, 'posts/zwaar.json');
94 const a = o.attachment[0];
95 assert.equal(a['shaer:availability'], 'included');
96 assert.equal(a['shaer:sha256'], PLAATJE_HASH);
97 assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
98 assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
99 assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
100});
101
102test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
103 // De derde mediastaat. Een lege medialijst zou een leugen zijn en een
104 // verwijzing naar een ontbrekend bestand een kapot archief.
105 post('kwijt', { content: '<p>x</p><img src="/media/2026/weg.jpg">' });
106 const uit = AX.buildArchive('me');
107 const a = lees(uit, 'posts/kwijt.json').attachment[0];
108 assert.equal(a['shaer:availability'], 'missing');
109 assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
110 assert.equal(uit.counts.mediaMissing, 1);
111 assert.equal(uit.missing[0].post, 'kwijt');
112});
113
114test('een pad buiten de media-map wordt niet gevolgd', () => {
115 // Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
116 // bestand van de schijf het archief in.
117 post('sluipweg', { content: '<p>x</p><img src="/media/../../../etc/passwd">' });
118 const uit = AX.buildArchive('me');
119 const a = lees(uit, 'posts/sluipweg.json').attachment[0];
120 assert.equal(a['shaer:availability'], 'missing');
121});
122
123test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
124 db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
125 VALUES ('reply', 'zwaar', ?, ?, 'Anna', '<p>hoi</p>', '2026-08-02 10:00:00')`)
126 .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
127 const uit = AX.buildArchive('me');
128 const c = lees(uit, 'replies/zwaar.json');
129 assert.equal(c['shaer:archive'], true, 'hierop keert een importer: nooit opnieuw bezorgen');
130 assert.equal(c.totalItems, 1);
131 assert.equal(c.orderedItems[0].id, 'https://mstdn.social/users/anna/statuses/9');
132 assert.equal(uit.counts.replies, 1);
133});
134
135test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
136 // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
137 // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
138 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
139 const WAV = Buffer.from('RIFF-nep-audio');
140 fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
141 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
142 .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
143 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license, link_spotify)
144 VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY','https://open.spotify.com/track/x')`).run();
145 post('metaudio', { content: '<p>luister</p>[[track:tr1]]' });
146
147 const uit = AX.buildArchive('me');
148 const o = lees(uit, 'posts/metaudio.json');
149 const a = o.attachment[0];
150 assert.equal(a['shaer:availability'], 'included');
151 assert.equal(a.mediaType, 'audio/mpeg');
152 assert.deepEqual(uit.files.get(a.url), WAV);
153 assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
154 const t = o['shaer:audio'][0];
155 assert.equal(t.name, 'Kanonnen');
156 assert.equal(t.artist, 'Robin');
157 assert.equal(t.license, 'CC BY');
158 assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
159});
160
161test('de poster van een audio-bijlage gaat mee', () => {
162 // Gevonden bij de export van echte beta-data: c2s_attachments draagt naast url
163 // ook een poster, en die viel er stil uit.
164 fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a'), Buffer.from('audio'));
165 fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a.poster.webp'), Buffer.from('poster'));
166 post('metposter', {
167 c2s_attachments: JSON.stringify([{
168 url: '/media/2026/opname.m4a', mediaType: 'audio/mp4', name: 'opname.m4a',
169 poster: '/media/2026/opname.m4a.poster.webp',
170 }]),
171 });
172 const o = lees(AX.buildArchive('me'), 'posts/metposter.json');
173 assert.equal(o.attachment.length, 2, 'de opname en de poster');
174 assert.ok(o.attachment.every((a) => a['shaer:availability'] === 'included'));
175});
176
177test('een SQL-tijdstempel wordt als UTC gelezen, niet als lokale tijd', () => {
178 // SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone erbij. Date.parse leest
179 // die vorm als LOKALE tijd, en dan schuift elk tijdstempel in het archief mee
180 // met de machine die de export draait -- in Amsterdam twee uur.
181 //
182 // Deze test is alleen zinvol onder een NIET-UTC tijdzone; draai hem daarom ook
183 // eens als: TZ=Europe/Amsterdam node --test test/archive-export.test.js
184 post('tijdstip', { published_at: '2026-07-01 12:56:10', created_at: '2026-07-01 12:56:10' });
185 const o = lees(AX.buildArchive('me'), 'posts/tijdstip.json');
186 assert.equal(o.published, '2026-07-01T12:56:10.000Z',
187 'het moment uit de database is UTC en hoort dat te blijven');
188});
189
190test('een concept gaat gewoon mee', () => {
191 post('concept', { status: 'draft', published_at: null });
192 const o = lees(AX.buildArchive('me'), 'posts/concept.json');
193 assert.equal(o['shaer:status'], 'draft');
194});
195
196test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
197 const a = AX.buildArchive('me', { exportedAt: 'X' });
198 const b = AX.buildArchive('me', { exportedAt: 'Y' });
199 assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
200 for (const pad of a.files.keys()) {
201 if (pad === 'manifest.json') continue;
202 assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
203 }
204 assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
205});
206
207test('en de zip die eruit komt ook', () => {
208 const a = AX.buildArchive('me', { exportedAt: 'X' });
209 const b = AX.buildArchive('me', { exportedAt: 'X' });
210 assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
211});
212
213test('de zip is door unzip te lezen', async () => {
214 // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
215 const uit = AX.buildArchive('me');
216 const zip = path.join(MEDIA, 'test.zip');
217 fs.writeFileSync(zip, AX.zipArchive(uit.files));
218 const { execFileSync } = await import('child_process');
219 let lijst;
220 try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
221 catch (e) { if (e.code === 'ENOENT') return; throw e; } // geen unzip op deze machine
222 assert.match(lijst, /manifest\.json/);
223 assert.match(lijst, /posts\/zwaar\.json/);
224});
225
226test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
227 const m = AX.buildArchive('me').manifest;
228 assert.equal(m.origin, 'https://klonkt.test');
229 assert.equal(m.formatVersion, 1);
230 assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
231});
232
233test('posts van een ANDERE site komen er niet in', () => {
234 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
235 db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
236 .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
237 assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
238});
239
240test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.