source: Klonkt/test/paid-open-audio.test.js@ 27cb1e3

main
Last change on this file since 27cb1e3 was b4af6f3, checked in by Robin <roboburr@…>, 2 weeks ago

Open audio travels with a paid post, and plays on the site itself

A paid post federated a bare teaser and dropped every attachment. But its
fedi_open tracks federate on their own anyway, as public Audio objects with
their context pointing back at that post. So the early return kept no secret:
it withheld only the ORDER and the fact that this is one tape. In the hub a
mixtape fell apart into loose tracks under an empty teaser card
(boiert.eu/introducing-this-machine).

The wall belongs around the TEXT. fedi_open is a separate, one-way, per-track
choice by the owner, and buildMixtapeObject already builds from
playlistOpenTracks — the tape object can only ever carry open tracks. So the
paid branch now carries them, and the redaction of the body is untouched.

The same rule on the site itself: the paid gate now shows the player when the
post's audio is open. Without it the music was available everywhere except on
the site releasing it. Only when ALL of it is open — a shortcode renders its
whole list, and /audio/stream lets a same-origin fetch through, so half open
has to count as closed. The gate gets a post that is nothing but the audio
shortcodes, so the body cannot leak through it.

Extracted openAudioAttachments() and mixtapeAttachment() out of buildNote so
both branches use one implementation.

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

  • Property mode set to 100644
File size: 7.4 KB
Line 
1// Opengezette audio onder een BETAALDE post (Robin, 24-8).
2//
3// Aanleiding: boiert.eu/introducing-this-machine — een mixtape, betaald, met
4// `fedi_open` audio. De vroege return in buildNote gooide ALLE bijlagen weg,
5// dus ook het Mixtape-object. De nummers federeerden ondertussen gewoon los als
6// eigen Audio-objecten met hun `context` naar die post, en in de hub viel het
7// bandje daardoor uiteen in vier losse nummers onder een kale teaserkaart.
8//
9// Wat de poort tegenhield was dus niet inhoud maar VORM: de volgorde en het
10// feit dat het een cassette is. Deze tests leggen de nieuwe grens vast:
11// - de tekst blijft achter de muur, altijd;
12// - `fedi_open` audio gaat mee, met het bandje eromheen;
13// - audio die NIET open staat gaat niet mee, ook niet als losse bijlage;
14// - en wat federeert is op de site zelf ook te horen (de poortpagina).
15import { test } from 'node:test';
16import assert from 'node:assert/strict';
17
18process.env.DATABASE_PATH = ':memory:';
19process.env.PUBLIC_BASE_URL = 'https://ons.test';
20
21const dbMod = await import('../src/config/database.js');
22const db = dbMod.default;
23dbMod.initializeDatabase();
24const AP = await import('../src/services/ActivityPubService.js');
25const { paidOpenAudioHtml } = await import('../src/routes/posts.js');
26
27const BASE = 'https://ons.test';
28const GEHEIM = 'DIT IS DE BETAALDE TEKST DIE NERGENS MAG STAAN';
29
30db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
31 .run('u1', 'u1', 'u1@t', 'x', 'god');
32db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
33 .run('s1', 'band', 'De Band', 'u1');
34const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
35
36// Twee open nummers in een bandje...
37const media = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
38const track = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?)');
39media.run('m1', 's1', 'een.mp3', 'a/een.mp3', 'audio/mpeg', 100);
40media.run('m2', 's1', 'twee.mp3', 'a/twee.mp3', 'audio/mpeg', 100);
41track.run('t1', 's1', 'Kant A', 'De Band', 60, 'm1', 1);
42track.run('t2', 's1', 'Kant B', 'De Band', 60, 'm2', 1);
43db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('bandje','s1','Het Bandje','mixtape')").run();
44db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t1',1)").run();
45db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t2',2)").run();
46
47// ...en eentje dat dicht blijft.
48media.run('m9', 's1', 'dicht.mp3', 'a/dicht.mp3', 'audio/mpeg', 100);
49track.run('t9', 's1', 'Gesloten', 'De Band', 60, 'm9', 0);
50
51function maak({ id, slug, muziek, paid = 1 }) {
52 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content,
53 status, published_at, paid) VALUES (?,?,?,?,?,?,?,'published',?,?)`)
54 .run(id, 's1', 'u1', slug, 'Titel', 'Een teaser.',
55 `<p>${GEHEIM}</p>${muziek}`, '2026-08-24T12:00:00Z', paid);
56 return db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
57}
58
59const alleTekst = (o) => JSON.stringify(o);
60const bijlagen = (n) => (Array.isArray(n.attachment) ? n.attachment : (n.attachment ? [n.attachment] : []));
61
62test('een betaald bandje federeert zijn open nummers EN de cassette', () => {
63 const note = AP.buildNote(BASE, site, maak({ id: 'p1', slug: 'bandje-betaald', muziek: '<p>[[playlist:bandje]]</p>' }));
64 const bij = bijlagen(note);
65
66 const audio = bij.filter((a) => a.type === 'Audio');
67 assert.equal(audio.length, 2, 'beide open nummers horen mee te reizen');
68 assert.deepEqual(audio.map((a) => a.name), ['Kant A', 'Kant B']);
69 assert.ok(audio.every((a) => a.url.startsWith(`${BASE}/audio/stream/`)), 'het bestand zelf, niet een link naar de poort');
70
71 const tape = bij.find((a) => a.type === 'Mixtape');
72 assert.ok(tape, 'zonder dit object valt het bandje in de hub uiteen in losse nummers');
73 assert.equal(tape.totalItems, 2);
74 assert.equal(tape.orderedItems.length, 2, 'de VOLGORDE is het werk; die hoort erin te staan');
75});
76
77test('en de tekst blijft ondertussen gewoon achter de muur', () => {
78 const note = AP.buildNote(BASE, site, maak({ id: 'p2', slug: 'bandje-betaald-2', muziek: '<p>[[playlist:bandje]]</p>' }));
79 assert.ok(!alleTekst(note).includes(GEHEIM), 'de betaalde inhoud staat nergens in het object');
80 assert.match(note.content, /Een teaser/);
81 assert.match(note.content, /Lees de volledige post/);
82});
83
84test('audio die NIET is opengezet reist niet mee', () => {
85 const note = AP.buildNote(BASE, site, maak({ id: 'p3', slug: 'dicht', muziek: '<p>[[track:t9]]</p>' }));
86 assert.equal(bijlagen(note).length, 0, 'een gesloten nummer hoort de deur niet uit te komen');
87});
88
89test('een betaalde post zonder muziek verandert niet: geen bijlagen', () => {
90 const note = AP.buildNote(BASE, site, maak({ id: 'p4', slug: 'kaal', muziek: '' }));
91 assert.equal(bijlagen(note).length, 0);
92});
93
94test('een gewone (niet-betaalde) post houdt dezelfde bijlagen', () => {
95 // De helper wordt door BEIDE takken gebruikt; deze test bewaakt dat het
96 // uitlichten ervan de gewone tak niet stilletjes heeft leeggehaald.
97 const note = AP.buildNote(BASE, site, maak({ id: 'p5', slug: 'open-bandje', muziek: '<p>[[playlist:bandje]]</p>', paid: 0 }));
98 const bij = bijlagen(note);
99 assert.equal(bij.filter((a) => a.type === 'Audio').length, 2);
100 assert.ok(bij.some((a) => a.type === 'Mixtape'), 'het bandje hangt er nog steeds aan');
101 assert.ok(alleTekst(note).includes(GEHEIM), 'en de inhoud van een OPEN post federeert gewoon');
102});
103
104// ── de poortpagina op de site zelf ───────────────────────────────────────────
105// Wat de hub kan afspelen hoort de site zelf ook te kunnen afspelen. Andersom
106// stond de muziek overal behalve op de plek die hem uitbrengt.
107
108test('de poort toont de speler van een open bandje', () => {
109 const post = maak({ id: 'p6', slug: 'poort', muziek: '<p>[[playlist:bandje]]</p>' });
110 const html = paidOpenAudioHtml(site, post, { session: null });
111 assert.ok(html, 'de poort hoort hier een speler te krijgen');
112 assert.ok(html.includes('Kant A') && html.includes('Kant B'), 'met de nummers erin');
113 assert.ok(!html.includes(GEHEIM), 'maar NOOIT met de betaalde tekst');
114 assert.ok(!html.includes('Een teaser'), 'en ook niet met de rest van de post');
115});
116
117test('de poort toont niets als de audio dicht staat', () => {
118 const post = maak({ id: 'p7', slug: 'poort-dicht', muziek: '<p>[[track:t9]]</p>' });
119 assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
120});
121
122test('half open is dicht: een gesloten nummer sluit de hele speler', () => {
123 // Een shortcode rendert zijn hele lijst. Bij een half-open bandje zou de
124 // speler ook het gesloten bestand krijgen, en /audio/stream laat een
125 // gelijke-oorsprong-fetch door -- dat is een echt lek, geen theoretisch.
126 const post = maak({ id: 'p8', slug: 'half', muziek: '<p>[[playlist:bandje]]</p><p>[[track:t9]]</p>' });
127 assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
128});
129
130test('de poortpagina print de speler ook echt', async () => {
131 const fs = await import('node:fs');
132 const bron = fs.readFileSync('src/views/pages/paid-gate.ejs', 'utf8');
133 assert.match(bron, /pgAudio/, 'zonder dit blijft de speler in de route steken');
134 assert.match(bron, /<%- pgAudio %>/, 'ontsnapt hij, dan staat er HTML als tekst op het scherm');
135});
Note: See TracBrowser for help on using the repository browser.