| 1 | // Een betaalde post lekt zijn inhoud nergens (klonkt-demo-aki).
|
|---|
| 2 | //
|
|---|
| 3 | // Aanleiding (Bart, 15-8): https://boiert.eu/we-should-explore-at-least-something
|
|---|
| 4 | // stond met 4555 tekens VOLLEDIG in de publieke outbox -- onbetekend op te
|
|---|
| 5 | // halen. Die post bleek niet als `paid` gemarkeerd: "PAID" stond alleen in de
|
|---|
| 6 | // waarschuwingstekst, en dat is tekst, geen poort.
|
|---|
| 7 | //
|
|---|
| 8 | // Dat de redactie op EEN plek hangt (de vroege return in buildNote) maakt hem
|
|---|
| 9 | // kwetsbaar: elk pad dat post.content serveert zonder buildNote lekt meteen, en
|
|---|
| 10 | // zonder foutmelding. Deze tests leggen vast dat geen enkele weg dat doet.
|
|---|
| 11 | import { test } from 'node:test';
|
|---|
| 12 | import assert from 'node:assert/strict';
|
|---|
| 13 |
|
|---|
| 14 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 15 | process.env.PUBLIC_BASE_URL = 'https://ons.test';
|
|---|
| 16 |
|
|---|
| 17 | const dbMod = await import('../src/config/database.js');
|
|---|
| 18 | const db = dbMod.default;
|
|---|
| 19 | dbMod.initializeDatabase();
|
|---|
| 20 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 21 |
|
|---|
| 22 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 23 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 24 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)')
|
|---|
| 25 | .run('s1', 'dev', 'Dev', 'u1');
|
|---|
| 26 | const site = () => db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 27 |
|
|---|
| 28 | const GEHEIM = 'DIT IS DE BETAALDE INHOUD DIE NERGENS MAG STAAN';
|
|---|
| 29 |
|
|---|
| 30 | function maak({ id, slug, paid = 0, fan = 0, nsfw = 0, cw = null }) {
|
|---|
| 31 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content,
|
|---|
| 32 | status, published_at, paid, fan_only, nsfw, content_warning)
|
|---|
| 33 | VALUES (?,?,?,?,?,?,?,'published',?,?,?,?,?)`)
|
|---|
| 34 | .run(id, 's1', 'u1', slug, 'Titel', 'Een teaser.',
|
|---|
| 35 | `<p>${GEHEIM}</p><p>en nog meer</p>`, '2026-08-15T12:00:00Z', paid, fan, nsfw, cw);
|
|---|
| 36 | return db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | const alleTekst = (o) => JSON.stringify(o);
|
|---|
| 40 |
|
|---|
| 41 | test('een betaalde post geeft een teaser, nooit de inhoud', () => {
|
|---|
| 42 | const note = AP.buildNote('https://ons.test', site(), maak({ id: 'p1', slug: 'betaald', paid: 1 }));
|
|---|
| 43 | assert.ok(!alleTekst(note).includes(GEHEIM), 'de inhoud staat nergens in het object');
|
|---|
| 44 | assert.match(note.content, /Een teaser/);
|
|---|
| 45 | });
|
|---|
| 46 |
|
|---|
| 47 | test('ook niet via de OUTBOX, het pad waar het misging', () => {
|
|---|
| 48 | // Bart zag hem juist daar: de outbox serveert Create-activiteiten en die
|
|---|
| 49 | // dragen het Note-object. Als de redactie daar niet meekomt, lekt alles.
|
|---|
| 50 | const p = maak({ id: 'p2', slug: 'betaald-2', paid: 1 });
|
|---|
| 51 | const ob = AP.buildOutbox('https://ons.test', site(), [p], []);
|
|---|
| 52 | assert.ok(!alleTekst(ob).includes(GEHEIM), 'ook de outbox draagt hem niet');
|
|---|
| 53 | });
|
|---|
| 54 |
|
|---|
| 55 | test('en niet voor een VRIEND, want de redactie kent geen publiek', () => {
|
|---|
| 56 | // outboxAudience geeft 'friend' aan iedere volger; fan_only laat die posts dan
|
|---|
| 57 | // mee. Een betaalde post mag dat NIET volgen -- betalen is iets anders dan
|
|---|
| 58 | // volgen.
|
|---|
| 59 | const p = maak({ id: 'p3', slug: 'betaald-vrienden', paid: 1, fan: 1 });
|
|---|
| 60 | const ob = AP.buildOutbox('https://ons.test', site(), [p], []);
|
|---|
| 61 | assert.ok(!alleTekst(ob).includes(GEHEIM));
|
|---|
| 62 | });
|
|---|
| 63 |
|
|---|
| 64 | test('een SENSITIVE betaalde post houdt zijn waarschuwing', () => {
|
|---|
| 65 | // Hier zat de tweede fout: de vroege return liet `sensitive` en `summary`
|
|---|
| 66 | // vallen, dus een betaalde post met een waarschuwing kwam ZONDER die
|
|---|
| 67 | // waarschuwing de deur uit. De teaser is publiek; dan hoort de waarschuwing
|
|---|
| 68 | // dat ook te zijn.
|
|---|
| 69 | const p = maak({ id: 'p4', slug: 'betaald-nsfw', paid: 1, nsfw: 1, cw: 'Smut' });
|
|---|
| 70 | const note = AP.buildNote('https://ons.test', site(), p);
|
|---|
| 71 | assert.ok(!alleTekst(note).includes(GEHEIM), 'inhoud nog steeds weg');
|
|---|
| 72 | assert.equal(note.sensitive, true, 'de vlag reist mee');
|
|---|
| 73 | assert.equal(note.summary, 'Smut', 'en de waarschuwingstekst ook');
|
|---|
| 74 | });
|
|---|
| 75 |
|
|---|
| 76 | test('een NIET-betaalde post lekt niets extra: die hoort gewoon leesbaar', () => {
|
|---|
| 77 | // De tegenproef, zodat de test niet slaagt door alles dicht te gooien.
|
|---|
| 78 | const note = AP.buildNote('https://ons.test', site(), maak({ id: 'p5', slug: 'gewoon' }));
|
|---|
| 79 | assert.ok(alleTekst(note).includes(GEHEIM), 'een gewone post draagt zijn inhoud wel');
|
|---|
| 80 | });
|
|---|
| 81 |
|
|---|
| 82 |
|
|---|
| 83 | // ── De reparatie van 15 augustus 2026 ──────────────────────────────
|
|---|
| 84 | // De test hierboven ('ook niet via de OUTBOX') gaf buildOutbox een rij uit
|
|---|
| 85 | // `SELECT *` en was dus groen TERWIJL DE OUTBOX LEKTE. De echte route haalt een
|
|---|
| 86 | // kolommenlijst op, en `paid` stond daar niet in -- dus post.paid was undefined
|
|---|
| 87 | // en de redactie viel stil weg. Een test die de echte weg niet neemt, bewijst
|
|---|
| 88 | // de echte weg niet.
|
|---|
| 89 | test('via outboxSlice, de echte weg, komt paid mee', () => {
|
|---|
| 90 | maak({ id: 'p9', slug: 'betaald-slice', paid: 1 });
|
|---|
| 91 | const rij = AP.outboxSlice('s1', { offset: 0, limit: 50 }).posts.find((x) => x.id === 'p9');
|
|---|
| 92 | assert.ok(rij, 'de post hoort in de slice te zitten');
|
|---|
| 93 | assert.equal(rij.paid, 1, 'paid MOET uit de query komen; undefined IS de lek');
|
|---|
| 94 | const note = AP.buildNote('https://ons.test', site(), rij);
|
|---|
| 95 | assert.ok(!alleTekst(note).includes(GEHEIM), 'de volledige tekst mag de outbox niet uit');
|
|---|
| 96 | assert.match(note.content, /Een teaser/);
|
|---|
| 97 | });
|
|---|
| 98 |
|
|---|
| 99 | // Klasse-wacht. Het lekte op twee plekken met dezelfde fout (outboxSlice en
|
|---|
| 100 | // backfillNewFollower), en zo'n kolommenlijst faalt STIL: een vergeten veld is
|
|---|
| 101 | // `undefined`, niet een fout. Deze test kijkt daarom naar de bron, zodat een
|
|---|
| 102 | // derde plek niet opnieuw ongemerkt gaat lekken.
|
|---|
| 103 | test('elke kolommenlijst die post-tekst ophaalt, haalt ook paid op', async () => {
|
|---|
| 104 | const { readFile } = await import('node:fs/promises');
|
|---|
| 105 | const bron = await readFile(new URL('../src/services/ActivityPubService.js', import.meta.url), 'utf8');
|
|---|
| 106 | const fout = [];
|
|---|
| 107 | for (const m of bron.matchAll(/SELECT\s+([^;`']*?)\s+FROM\s+posts\b/gis)) {
|
|---|
| 108 | const kol = m[1].replace(/\s+/g, ' ').trim();
|
|---|
| 109 | if (kol === '*' || kol.includes('(')) continue; // SELECT * is veilig
|
|---|
| 110 | if (!/\bcontent\b/.test(kol)) continue; // levert geen post-tekst
|
|---|
| 111 | if (!/\bpaid\b/.test(kol)) fout.push(`regel ${bron.slice(0, m.index).split('\n').length}: ${kol.slice(0, 80)}`);
|
|---|
| 112 | }
|
|---|
| 113 | assert.deepEqual(fout, [], 'deze SELECTs leveren post.content zonder post.paid:\n ' + fout.join('\n '));
|
|---|
| 114 | });
|
|---|