source: Klonkt/test/archive-export.test.js@ 0db68bd

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

De exporter (shaer-1a6)

ArchiveExportService bouwt het archief uit docs/EXPORT-FORMAT.md, plus een CLI
(scripts/export-archive.mjs) met een droogloop.

Alleen de eigen posts van de site, inclusief concepten. Geen sleutel, sessie,
wachtwoordhash of DM van een ander -- dit is nadrukkelijk niet de storage-zip uit
shaer-190t.

Media worden gesleuteld op de sha256 van hun bytes, zodat twee exports dezelfde
namen opleveren en dubbelen vanzelf samenvallen. Een verdwenen bestand krijgt de
derde staat uit het formaat -- 'missing', met de oorspronkelijke plek erbij -- en
wordt geteld en gemeld, nooit stil weggelaten.

Zip zonder nieuwe afhankelijkheid: store-only, gesorteerde ingangen, vaste mtime.
Zonder compressie is byte-gelijkheid geen kwestie van vertrouwen in de
instellingen van een bibliotheek. Gecontroleerd dat gewone unzip hem uitpakt en
dat de bytes er identiek uitkomen; een zelfgeschreven zip die alleen zichzelf kan
lezen is geen uitwisselformaat.

Twee dingen kwamen pas boven bij het draaien tegen echte data (beta):

  • een audio-bijlage in c2s_attachments draagt naast url ook een POSTER, en die viel er stil uit. Nu meegenomen; media op beta gingen daarmee van 2 naar 3.
  • het [[track:]]-pad raakt op beta geen enkele rij (nul audio_tracks), dus dat heeft nu een eigen test in plaats van de aanname dat het meeliep.

Een verzonnen pad in oude inhoud kan geen willekeurig bestand van de schijf het
archief in trekken: media-URL's worden binnen MEDIA_ROOT gehouden en een URL naar
een andere host wordt nooit van onze eigen schijf gelezen.

De reproduceerbaarheidseis in het formaat sloot exportedAt niet uit en sprak
zichzelf daarmee tegen; dat is rechtgezet.

15 tests, waaronder de acceptatietest uit het document: de moeilijkste post die
Klonkt kan maken (poll, bijlagen, quote, content warning, betaald). Suite 493/493.

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

  • Property mode set to 100644
File size: 10.9 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 concept gaat gewoon mee', () => {
178 post('concept', { status: 'draft', published_at: null });
179 const o = lees(AX.buildArchive('me'), 'posts/concept.json');
180 assert.equal(o['shaer:status'], 'draft');
181});
182
183test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
184 const a = AX.buildArchive('me', { exportedAt: 'X' });
185 const b = AX.buildArchive('me', { exportedAt: 'Y' });
186 assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
187 for (const pad of a.files.keys()) {
188 if (pad === 'manifest.json') continue;
189 assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
190 }
191 assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
192});
193
194test('en de zip die eruit komt ook', () => {
195 const a = AX.buildArchive('me', { exportedAt: 'X' });
196 const b = AX.buildArchive('me', { exportedAt: 'X' });
197 assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
198});
199
200test('de zip is door unzip te lezen', async () => {
201 // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
202 const uit = AX.buildArchive('me');
203 const zip = path.join(MEDIA, 'test.zip');
204 fs.writeFileSync(zip, AX.zipArchive(uit.files));
205 const { execFileSync } = await import('child_process');
206 let lijst;
207 try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
208 catch (e) { if (e.code === 'ENOENT') return; throw e; } // geen unzip op deze machine
209 assert.match(lijst, /manifest\.json/);
210 assert.match(lijst, /posts\/zwaar\.json/);
211});
212
213test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
214 const m = AX.buildArchive('me').manifest;
215 assert.equal(m.origin, 'https://klonkt.test');
216 assert.equal(m.formatVersion, 1);
217 assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
218});
219
220test('posts van een ANDERE site komen er niet in', () => {
221 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
222 db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
223 .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
224 assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
225});
226
227test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.