| 1 | // De wardmodus: een besloten Klonkt waarvan alleen het profiel openbaar is
|
|---|
| 2 | // (Robins ontwerp, 30-9; middleware/ward-profile.js).
|
|---|
| 3 | //
|
|---|
| 4 | // Wat hier bewaakt wordt, en waarom elk een eigen zaak is:
|
|---|
| 5 | // 1. AFGELEID VAN DE GUARDIANS. Aan zodra een guardianship geaccepteerd is,
|
|---|
| 6 | // niet bij een voorstel; uit zodra de set leeg is (emancipatie). Geen
|
|---|
| 7 | // schakelaar die ernaast kan gaan liggen.
|
|---|
| 8 | // 2. STANDAARD DICHT. Een bezoeker ziet het profiel en verder niets; een
|
|---|
| 9 | // pagina gaat terug naar het profiel, al het andere is 404, en een POST
|
|---|
| 10 | // bereikt zijn handler niet.
|
|---|
| 11 | // 3. WAT OPEN MOET BLIJVEN, blijft open: inloggen, de audiostream (die bewaakt
|
|---|
| 12 | // elk bestand zelf), de bestanden van de pagina zelf.
|
|---|
| 13 | // 4. DE WARD EN HET BEHEER zien de hele site.
|
|---|
| 14 | // 5. DE PROFIELLINKS staan bij het account, en Appearance wist ze niet meer.
|
|---|
| 15 | import { test } from 'node:test';
|
|---|
| 16 | import assert from 'node:assert/strict';
|
|---|
| 17 | import os from 'os';
|
|---|
| 18 | import path from 'path';
|
|---|
| 19 |
|
|---|
| 20 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 21 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 22 | process.env.MEDIA_PATH = path.join(os.tmpdir(), 'klonkt-test-media');
|
|---|
| 23 |
|
|---|
| 24 | const dbMod = await import('../src/config/database.js');
|
|---|
| 25 | const db = dbMod.default;
|
|---|
| 26 | { const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
|
|---|
| 27 | const { resolveSite } = await import('../src/middleware/site.js');
|
|---|
| 28 | const { wardProfileGate } = await import('../src/middleware/ward-profile.js');
|
|---|
| 29 |
|
|---|
| 30 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('kid', 'kid', 'k@t', 'x', 'user');
|
|---|
| 31 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('fan', 'fan', 'f@t', 'x', 'user');
|
|---|
| 32 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('baas', 'baas', 'b@t', 'x', 'god');
|
|---|
| 33 | db.prepare(`INSERT INTO sites (id, slug, title, description, owner_id, is_primary, is_public, profile_links)
|
|---|
| 34 | VALUES (?,?,?,?,?,1,1,?)`)
|
|---|
| 35 | .run('s1', 'kid', 'Robin', 'Ik maak muziek met mijn gitaar', 'kid',
|
|---|
| 36 | JSON.stringify([{ platform: 'instagram', url: 'https://instagram.com/robin' }]));
|
|---|
| 37 |
|
|---|
| 38 | const GUARDIAN = 'https://elders.test/u/oma';
|
|---|
| 39 | const koppel = (status) => db.prepare("INSERT OR REPLACE INTO ap_guardianships (slug, role, other_uri, status) VALUES ('kid','ward',?,?)").run(GUARDIAN, status);
|
|---|
| 40 | const ontkoppel = () => db.prepare("DELETE FROM ap_guardianships WHERE slug = 'kid'").run();
|
|---|
| 41 |
|
|---|
| 42 | // Een Klonkt in het klein: de echte siteresolver en de echte poort, en daarachter
|
|---|
| 43 | // routes die alleen zeggen dat ze bereikt zijn.
|
|---|
| 44 | let sessie = {};
|
|---|
| 45 | const bereikt = [];
|
|---|
| 46 | const express = (await import('express')).default;
|
|---|
| 47 | const app = express();
|
|---|
| 48 | app.use((req, res, next) => { req.session = sessie; next(); });
|
|---|
| 49 | app.use(resolveSite);
|
|---|
| 50 | app.use(wardProfileGate);
|
|---|
| 51 | const hier = (naam) => (req, res) => { bereikt.push(naam); res.send('ROUTE:' + naam); };
|
|---|
| 52 | app.get('/', hier('feed'));
|
|---|
| 53 | app.get('/auth/login', hier('login'));
|
|---|
| 54 | app.get('/audio/stream/:f', hier('stream'));
|
|---|
| 55 | app.get('/manifest.webmanifest', hier('manifest'));
|
|---|
| 56 | app.get('/search', hier('search'));
|
|---|
| 57 | app.get('/feed.xml', hier('rss'));
|
|---|
| 58 | app.post('/:slug/comment', hier('comment'));
|
|---|
| 59 | app.get('/:slug', hier('post'));
|
|---|
| 60 | const server = app.listen(0);
|
|---|
| 61 | server.unref();
|
|---|
| 62 | const base = `http://127.0.0.1:${server.address().port}`;
|
|---|
| 63 | test.after(() => server.close());
|
|---|
| 64 |
|
|---|
| 65 | const vraag = (pad, opts = {}) => fetch(base + pad, { redirect: 'manual', headers: { Accept: 'text/html', ...(opts.headers || {}) }, ...opts });
|
|---|
| 66 | const alsBezoeker = () => { sessie = {}; };
|
|---|
| 67 | const als = (id, role) => { sessie = { user: { id, username: id, role } }; };
|
|---|
| 68 |
|
|---|
| 69 | test('zonder guardians is het een gewone Klonkt', async () => {
|
|---|
| 70 | ontkoppel(); alsBezoeker();
|
|---|
| 71 | const r = await vraag('/');
|
|---|
| 72 | assert.equal(await r.text(), 'ROUTE:feed');
|
|---|
| 73 | });
|
|---|
| 74 |
|
|---|
| 75 | test('een voorstel is nog geen koppeling', async () => {
|
|---|
| 76 | ontkoppel(); koppel('offered'); alsBezoeker();
|
|---|
| 77 | assert.equal(await (await vraag('/')).text(), 'ROUTE:feed', 'pas een GEACCEPTEERDE guardianship zet de site dicht');
|
|---|
| 78 | });
|
|---|
| 79 |
|
|---|
| 80 | test('gekoppeld ziet een bezoeker alleen het profiel', async () => {
|
|---|
| 81 | ontkoppel(); koppel('accepted'); alsBezoeker();
|
|---|
| 82 | const r = await vraag('/');
|
|---|
| 83 | const html = await r.text();
|
|---|
| 84 | assert.equal(r.status, 200);
|
|---|
| 85 | assert.ok(!html.includes('ROUTE:feed'), 'de stroom komt er niet aan te pas');
|
|---|
| 86 | assert.ok(html.includes('Robin'), 'de naam');
|
|---|
| 87 | assert.ok(html.includes('Ik maak muziek met mijn gitaar'), 'de bio');
|
|---|
| 88 | assert.ok(html.includes('https://instagram.com/robin'), 'de links');
|
|---|
| 89 | assert.ok(html.includes('@kid@test.example'), 'de handle, om te volgen');
|
|---|
| 90 | });
|
|---|
| 91 |
|
|---|
| 92 | test('elke andere pagina gaat terug naar het profiel', async () => {
|
|---|
| 93 | koppel('accepted'); alsBezoeker();
|
|---|
| 94 | for (const pad of ['/geheim', '/search?q=gitaar']) {
|
|---|
| 95 | const r = await vraag(pad);
|
|---|
| 96 | assert.equal(r.status, 302, pad);
|
|---|
| 97 | assert.equal(r.headers.get('location'), '/', pad);
|
|---|
| 98 | }
|
|---|
| 99 | });
|
|---|
| 100 |
|
|---|
| 101 | test('wat geen pagina is, bestaat niet voor een bezoeker', async () => {
|
|---|
| 102 | koppel('accepted'); alsBezoeker(); bereikt.length = 0;
|
|---|
| 103 | const rss = await vraag('/feed.xml', { headers: { Accept: 'application/rss+xml' } });
|
|---|
| 104 | assert.equal(rss.status, 404);
|
|---|
| 105 | const post = await vraag('/geheim/comment', { method: 'POST' });
|
|---|
| 106 | assert.equal(post.status, 404);
|
|---|
| 107 | assert.deepEqual(bereikt, [], 'geen enkele handler bereikt, ook de reactie niet');
|
|---|
| 108 | });
|
|---|
| 109 |
|
|---|
| 110 | test('een htmx-navigatie gaat echt naar het profiel, niet als brokstuk', async () => {
|
|---|
| 111 | koppel('accepted'); alsBezoeker();
|
|---|
| 112 | const r = await vraag('/geheim', { headers: { 'HX-Request': 'true', Accept: '*/*' } });
|
|---|
| 113 | assert.equal(r.status, 204);
|
|---|
| 114 | assert.equal(r.headers.get('hx-redirect'), '/');
|
|---|
| 115 | });
|
|---|
| 116 |
|
|---|
| 117 | test('inloggen, de audiostream en de bestanden van de pagina blijven bereikbaar', async () => {
|
|---|
| 118 | koppel('accepted'); alsBezoeker();
|
|---|
| 119 | assert.equal(await (await vraag('/auth/login')).text(), 'ROUTE:login', 'de ward moet kunnen inloggen');
|
|---|
| 120 | assert.equal(await (await vraag('/audio/stream/zwaluwen.mp3')).text(), 'ROUTE:stream',
|
|---|
| 121 | 'die bewaakt elk bestand zelf, en een geopende track moet speelbaar blijven op de fediverse');
|
|---|
| 122 | assert.equal(await (await vraag('/manifest.webmanifest')).text(), 'ROUTE:manifest');
|
|---|
| 123 | });
|
|---|
| 124 |
|
|---|
| 125 | test('de ward zelf en het beheer zien de hele site', async () => {
|
|---|
| 126 | koppel('accepted');
|
|---|
| 127 | als('kid', 'user');
|
|---|
| 128 | assert.equal(await (await vraag('/')).text(), 'ROUTE:feed', 'de eigenaar ziet zijn eigen Klonkt');
|
|---|
| 129 | assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post');
|
|---|
| 130 | als('baas', 'god');
|
|---|
| 131 | assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post');
|
|---|
| 132 | });
|
|---|
| 133 |
|
|---|
| 134 | test('een andere ingelogde gebruiker is gewoon een bezoeker', async () => {
|
|---|
| 135 | koppel('accepted'); als('fan', 'user');
|
|---|
| 136 | const r = await vraag('/geheim');
|
|---|
| 137 | assert.equal(r.status, 302, 'ingelogd zijn is niet hetzelfde als deze site mogen beheren');
|
|---|
| 138 | });
|
|---|
| 139 |
|
|---|
| 140 | test('emancipatie maakt er weer een gewone Klonkt van', async () => {
|
|---|
| 141 | koppel('accepted'); alsBezoeker();
|
|---|
| 142 | assert.equal((await vraag('/geheim')).status, 302);
|
|---|
| 143 | ontkoppel();
|
|---|
| 144 | assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post', 'geen guardians meer: alles weer open');
|
|---|
| 145 | });
|
|---|
| 146 |
|
|---|
| 147 | // ── De profiellinks: bij het account, en Appearance wist ze niet meer ────────
|
|---|
| 148 | const accountRoutes = (await import('../src/routes/account.js')).default;
|
|---|
| 149 | const sitesRoutes = (await import('../src/routes/admin-sites.js')).default;
|
|---|
| 150 | const app2 = express();
|
|---|
| 151 | app2.use(express.urlencoded({ extended: true }));
|
|---|
| 152 | app2.use((req, res, next) => { req.session = sessie; res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); res.locals.siteUrlBase = ''; next(); });
|
|---|
| 153 | app2.use('/account', accountRoutes);
|
|---|
| 154 | app2.use('/admin/sites', sitesRoutes);
|
|---|
| 155 | const server2 = app2.listen(0);
|
|---|
| 156 | server2.unref();
|
|---|
| 157 | const base2 = `http://127.0.0.1:${server2.address().port}`;
|
|---|
| 158 | test.after(() => server2.close());
|
|---|
| 159 | const links = () => JSON.parse(db.prepare("SELECT profile_links FROM sites WHERE id = 's1'").get().profile_links || '[]');
|
|---|
| 160 |
|
|---|
| 161 | test('de accountpagina toont de links die er al zijn', async () => {
|
|---|
| 162 | // Gevonden bij het bekijken, niet door een toets: ownedSite haalde
|
|---|
| 163 | // profile_links niet op, dus de pagina toonde nul links -- en wie daarna
|
|---|
| 164 | // opsloeg, wiste ze. Het opslaan was getoetst, het tonen niet.
|
|---|
| 165 | als('kid', 'user');
|
|---|
| 166 | const html = await (await fetch(`${base2}/account`)).text();
|
|---|
| 167 | assert.ok(html.includes('value="https://instagram.com/robin"'), 'de bestaande link staat in het formulier');
|
|---|
| 168 | });
|
|---|
| 169 |
|
|---|
| 170 | test('de links bewerk je bij je account', async () => {
|
|---|
| 171 | als('kid', 'user');
|
|---|
| 172 | const body = new URLSearchParams();
|
|---|
| 173 | body.append('profile_link_platform[]', 'instagram'); body.append('profile_link_url[]', 'https://instagram.com/robin');
|
|---|
| 174 | body.append('profile_link_platform[]', 'youtube'); body.append('profile_link_url[]', 'https://youtube.com/@robin');
|
|---|
| 175 | const r = await fetch(`${base2}/account/links`, { method: 'POST', body, redirect: 'manual' });
|
|---|
| 176 | assert.equal(r.status, 302);
|
|---|
| 177 | assert.deepEqual(links().map((l) => l.platform), ['instagram', 'youtube']);
|
|---|
| 178 | });
|
|---|
| 179 |
|
|---|
| 180 | test('opslaan in Appearance wist de links niet meer', async () => {
|
|---|
| 181 | // Hier zat de val: de linkvelden staan niet meer op dat formulier, en zolang
|
|---|
| 182 | // die pagina profile_links bleef schrijven, wiste elke keer opslaan ze.
|
|---|
| 183 | als('baas', 'god');
|
|---|
| 184 | const voor = links();
|
|---|
| 185 | assert.ok(voor.length > 0);
|
|---|
| 186 | const body = new URLSearchParams({ title: 'Robin', description: 'Nu ook met drums', palette: 'klonkt' });
|
|---|
| 187 | await fetch(`${base2}/admin/sites/kid/save`, { method: 'POST', body, redirect: 'manual' });
|
|---|
| 188 | // Eerst bewijzen dat de opslag echt gebeurde. Een verkeerd pad geeft een 404,
|
|---|
| 189 | // en dan "wist" hij de links ook niet -- dan zegt deze toets niets.
|
|---|
| 190 | assert.equal(db.prepare("SELECT description FROM sites WHERE id = 's1'").get().description, 'Nu ook met drums',
|
|---|
| 191 | 'Appearance heeft opgeslagen');
|
|---|
| 192 | assert.deepEqual(links(), voor, 'en de links staan er nog precies zo');
|
|---|
| 193 | });
|
|---|