// De Ward PWA (/ward): de ward-kant van hetzelfde vangnet, in de browser. // // Wat hier bewaakt wordt is niet de opmaak maar de POORT en de INHOUD: een // vreemde mag deze pagina niet zien, en wat er wel doorheen komt moet de drie // dingen dragen waarvoor het scherm bestaat (wie waakt over mij, wat wacht er // op hen, en de §4-uitkomst die zegt of dat vangnet heel is). import { test } from 'node:test'; import assert from 'node:assert/strict'; process.env.DATABASE_PATH = ':memory:'; process.env.PUBLIC_BASE_URL = 'https://kind.example'; const dbMod = await import('../src/config/database.js'); const db = dbMod.default; dbMod.initializeDatabase(); const express = (await import('express')).default; const wardRoutes = (await import('../src/routes/ward.js')).default; db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') .run('u1', 'mee', 'mee@t', 'x', 'user'); db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)') .run('s1', 'mee', 'Mee', 'u1'); // Twee guardians: oma is in orde, en een tweede die zelf een ward is. Die // laatste is het geval uit §4: een guardian met eigen guardians is er geen. const OMA = 'https://elders.example/ap/users/oma'; const KAPOT = 'https://elders.example/ap/users/kapot'; for (const [uri, handle] of [[OMA, '@oma@elders.example'], [KAPOT, '@kapot@elders.example']]) { db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, created_at) VALUES (?, 'ward', ?, ?, 'accepted', datetime('now'))`).run('mee', uri, handle); } /// Een sessie die zegt dat je ingelogd bent als deze gebruiker. function appWith(session) { const app = express(); // Zelfde view-opzet als de echte server, anders kan express het sjabloon // niet vinden en faalt de render met "no default engine". app.set('view engine', 'ejs'); app.set('views', new URL('../src/views', import.meta.url).pathname); app.use((req, res, next) => { req.session = session; res.locals = {}; next(); }); app.use('/ward', wardRoutes); return app; } async function listen(app) { const server = app.listen(0); await new Promise((r) => server.once('listening', r)); return { server, port: server.address().port }; } test('een vreemde komt er niet in', async () => { // Geen sessie: requireAuth hoort dit te weren. Zonder deze poort zou het // vangnet van een kind voor iedereen te lezen zijn. const { server, port } = await listen(appWith({})); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`, { redirect: 'manual' }); assert.notEqual(r.status, 200, 'een niet-ingelogde lezer kreeg de ward-toestand'); } finally { server.close(); } }); test('de toestand draagt de guardians en wat er op hen wacht', async () => { // Een follow die de guardians nog moeten goedkeuren (§5.3 uitgaand). const G = await import('../src/services/guardianship/outgoing.js'); // De velden heten zoals recordPending ze leest: id, target, inbox, handle. G.recordPending('mee', { id: 'of-test-1', target: 'https://elders.example/ap/users/tante', handle: '@tante@elders.example', inbox: 'https://elders.example/inbox', quorum: 'any', }); const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`); assert.equal(r.status, 200); const s = await r.json(); assert.equal(s.slug, 'mee'); assert.deepEqual(s.guardians.map((g) => g.uri).sort(), [OMA, KAPOT].sort()); // Beschikbaarheid hoort erbij: een guardian die weg is verandert de echte // maat van je vangnet. assert.ok(s.guardians.every((g) => typeof g.availability === 'string')); assert.equal(s.waitingFollows.length, 1); assert.equal(s.waitingFollows[0].handle, '@tante@elders.example'); // Niet 'sent' en niet mislukt: dit is de derde uitkomst. assert.equal(s.waitingFollows[0].status, 'pending'); } finally { server.close(); } }); test('de pagina serveert zichzelf compleet, zonder inline script', async () => { const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/`); assert.equal(r.status, 200); const html = await r.text(); // De begintoestand reist mee, zodat het scherm niet leeg begint. assert.match(html, /id="ward-state"/); assert.match(html, /application\/json/); // CSP: alle logica komt uit een bestand, nooit uit de pagina zelf. assert.doesNotMatch(html, /]*(src=|type="application\/json"))/); assert.match(html, /\/ward\/app\.js/); // En het is een installeerbare PWA, geen losse pagina. assert.match(html, /rel="manifest" href="\/ward\/manifest\.webmanifest"/); } finally { server.close(); } }); test('het manifest wijst naar zijn eigen scope', async () => { const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const m = await (await fetch(`http://127.0.0.1:${port}/ward/manifest.webmanifest`)).json(); assert.equal(m.scope, '/ward/'); assert.equal(m.display, 'standalone'); // Eigen scope EN eigen start_url: anders opent de geïnstalleerde app in // het guardian-dashboard, en dat is de andere kant van deze relatie. assert.match(m.start_url, /^\/ward/); } finally { server.close(); } }); test('de feed levert afgemaakte kaarten, niet kale content', async () => { // Een post in de tijdlijn van dit kind. db.prepare(`INSERT INTO ap_timeline (slug, id, author_uri, author_handle, author_name, content, url, published, created_at) VALUES ('mee', 'p-1', ?, '@vriend@elders.example', 'Vriend', '

hallo

', 'https://elders.example/n/1', datetime('now'), datetime('now'))`) .run('https://elders.example/ap/users/vriend'); const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/feed`); assert.equal(r.status, 200); const { items } = await r.json(); assert.equal(items.length, 1); // De body komt AF van de server: dezelfde partial als de Krant. Alleen // `content` doorgeven zou media en quote-kaarten laten verdwijnen. assert.ok(items[0].bodyHtml, 'de kaart kwam zonder gerenderde body'); assert.ok(items[0].whenText, 'geen leesbare tijd'); // De id is waar de boei op mikt. assert.equal(items[0].id, 'p-1'); } finally { server.close(); } }); test('de boei weigert als er niemand is om te vragen', async () => { // Een kind ZONDER guardians. Dan is er niets om naartoe te sturen, en dat // moet het zeggen in plaats van te doen alsof er hulp onderweg is. db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') .run('u2', 'alleen', 'a@t', 'x', 'user'); db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)') .run('s2', 'alleen', 'Alleen', 'u2'); const { server, port } = await listen(appWith({ user: { id: 'u2' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ words: 'help' }), }); assert.equal(r.status, 409); assert.equal((await r.json()).error, 'no_guardians'); } finally { server.close(); } }); test('een vreemde kan de boei niet afvuren', async () => { // Zonder deze poort zou iedereen namens een kind zijn guardians kunnen // alarmeren. const { server, port } = await listen(appWith({})); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ words: 'x' }), redirect: 'manual', }); assert.notEqual(r.status, 200); } finally { server.close(); } }); test('het gesprek draagt beide kanten en een zwaai heet een zwaai', async () => { // Eén binnengekomen bericht (ap_mentions) en één eigen antwoord (ap_outbox, // direct). Het gesprek moet ze allebei dragen, elk met zijn richting. const VRIEND = 'https://elders.example/ap/users/vriend'; db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, wave, published, created_at) VALUES ('mee', 'https://elders.example/n/w1', ?, 'Vriend', '@vriend@elders.example', '

👋

', 1, datetime('now'), datetime('now'))`) .run(VRIEND); // post_id is NOT NULL (een reply hangt normaal onder een post); een direct // bericht draagt daar een leeg anker, precies zoals deliverDirectNote doet. db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, in_reply_to, content, visibility, to_actors, created_at) VALUES ('out-1', 'mee', '', NULL, '

hoi terug

', 'direct', ?, datetime('now', '+1 minute'))`) .run(JSON.stringify([VRIEND])); const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/thread?with=${encodeURIComponent(VRIEND)}`); assert.equal(r.status, 200); const { items } = await r.json(); const dirs = items.map((i) => i.dir).sort(); assert.deepEqual(dirs, ['in', 'out'], 'een kant van het gesprek ontbreekt'); // De zwaai-vlag overleeft de reis: de client toont hem groter, en dat kan // alleen als de server hem niet als kale tekst plat slaat. assert.equal(items.find((i) => i.dir === 'in').wave, true); } finally { server.close(); } }); test('een bericht sturen eist een echte ontvanger en echte tekst', async () => { const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const post = (body) => fetch(`http://127.0.0.1:${port}/ward/api/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // Geen uri: weigeren, want "aan niemand" bestaat niet. assert.equal((await post({ to: 'vriend', text: 'hoi' })).status, 400); // Lege tekst: weigeren, een leeg bericht versturen kan niet. assert.equal((await post({ to: 'https://elders.example/ap/users/vriend', text: ' ' })).status, 400); } finally { server.close(); } }); test('hidden wint van elke display-regel in de stylesheet', async () => { // De gespreks-overlay heeft display:flex; zonder een [hidden]-regel met // !important verslaat die de browser-default en staat een "verborgen" // overlay permanent over de pagina (Robins schermafdruk, 12-8). De CSS is // hier de waarheid, dus die bewaken we, niet de DOM. const fs = await import('node:fs'); const css = fs.readFileSync(new URL('../src/assets/css/ward.css', import.meta.url), 'utf8'); assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important/, 'de [hidden]-regel ontbreekt'); // En hij moet VOOR de #thread-regel staan is niet nodig (important wint), // maar de flex-regel moet er wel zijn, anders bewaakt deze test niets. assert.match(css, /#thread\s*\{[^}]*display:\s*flex/); }); test('de vrienden achter de (+) komen uit beide richtingen', async () => { // Iemand die JIJ volgt en iemand die JOU volgt: allebei een deur. De hemel // toont alleen met wie je al sprak; de kiezer moet ruimer zijn, anders kun // je nooit een EERSTE bericht sturen. db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, name, icon, follow_id, status) VALUES ('mee', 'https://elders.example/ap/users/tante', '@tante@elders.example', 'Tante', NULL, 'f-1', 'accepted')`).run(); db.prepare(`INSERT INTO ap_followers (slug, actor_uri, handle, name) VALUES ('mee', 'https://elders.example/ap/users/neef', '@neef@elders.example', 'Neef')`).run(); const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/friends`); assert.equal(r.status, 200); const { items } = await r.json(); const uris = items.map((f) => f.uri); assert.ok(uris.includes('https://elders.example/ap/users/tante'), 'wie je volgt ontbreekt'); assert.ok(uris.includes('https://elders.example/ap/users/neef'), 'wie jou volgt ontbreekt'); // Elke deur heeft een leesbare naam, ook zonder display name. assert.ok(items.every((f) => f.name && f.handle)); } finally { server.close(); } }); test('de compose-poort geldt ook voor de web-ward', async () => { // mee IS een ward (heeft guardians), dus compose is gated. De web-composer // gaat door DEZELFDE poort als de app (ingestOutboxActivity), dus hij wordt // net zo tegengehouden. Zonder deze gelijkheid zou de browser een sluiproute // om de guardians heen zijn. const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'mag dit', visibility: 'friends' }), }); assert.equal(r.status, 403); assert.equal((await r.json()).error, 'gated_compose'); } finally { server.close(); } }); test('een niet-gated account plaatst met media', async () => { // Een account ZONDER guardians is geen ward, dus compose is open. De post // landt echt, met de foto eraan. db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') .run('u3', 'vrij', 'v@t', 'x', 'user'); db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)') .run('s3', 'vrij', 'Vrij', 'u3'); const { server, port } = await listen(appWith({ user: { id: 'u3' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'kijk mijn tekening', visibility: 'friends', media: [{ url: '/media/reply-media/x.jpg', mediaType: 'image/jpeg', name: 'tekening.jpg' }], }), }); assert.equal(r.status, 201, JSON.stringify(await r.clone().json().catch(() => ({})))); const row = db.prepare("SELECT content FROM posts WHERE site_id = 's3' ORDER BY created_at DESC LIMIT 1").get(); assert.ok(row, 'de post is nergens opgeslagen'); // De foto reist als attachment mee, niet als kale tekst. assert.match(row.content, /x\.jpg| { const { server, port } = await listen(appWith({ user: { id: 'u1' } })); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: ' ', media: [] }), }); assert.equal(r.status, 400); assert.equal((await r.json()).error, 'empty'); } finally { server.close(); } }); test('een vreemde kan niet uploaden', async () => { // De upload-poort moet net zo dicht zijn als de rest; anders vult iemand // ongevraagd de media-map. const { server, port } = await listen(appWith({})); try { const r = await fetch(`http://127.0.0.1:${port}/ward/api/upload`, { method: 'POST', redirect: 'manual' }); assert.notEqual(r.status, 201); } finally { server.close(); } });