source: Klonkt/test/paid-post-lekt-niet.test.js@ d97c58c

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

Betaalde posts lekten hun inhoud via de outbox en de volger-backfill

Bart zag de inhoud van een betaalde post op boiert.eu binnenkomen via
@actor@…. De redactie in buildNote werkte, maar kreeg de
gegevens niet: beide paden halen een KOLOMMENLIJST op, en paid stond
daar niet in. Dan is post.paid undefined, valt de vroege return weg, en
gaat de volledige tekst de deur uit -- zonder foutmelding.

  • outboxSlice miste paid en excerpt. Dit is het pad dat Bart zag.
  • backfillNewFollower miste paid, excerpt EN ap_visibility, en filterde alleen op fan_only. Die duwt bij ELKE nieuwe volger twintig posts naar hun inbox: een push, dus niet terug te nemen. Nu filtert hij ook op ap_visibility, want een friends- of direct-post hoort daar niet in.

De bestaande test was groen omdat hij buildOutbox een rij uit SELECT *
gaf -- de echte weg deed hij niet. Vervangen door een test via
outboxSlice, plus een klasse-wacht die de bron nakijkt: elke SELECT die
post.content oplevert moet ook paid ophalen.

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

  • Property mode set to 100644
File size: 6.0 KB
Line 
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.
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13
14process.env.DATABASE_PATH = ':memory:';
15process.env.PUBLIC_BASE_URL = 'https://ons.test';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = await import('../src/services/ActivityPubService.js');
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@t', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)')
25 .run('s1', 'dev', 'Dev', 'u1');
26const site = () => db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
27
28const GEHEIM = 'DIT IS DE BETAALDE INHOUD DIE NERGENS MAG STAAN';
29
30function 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
39const alleTekst = (o) => JSON.stringify(o);
40
41test('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
47test('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
55test('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
64test('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
76test('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.
89test('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.
103test('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});
Note: See TracBrowser for help on using the repository browser.