| 1 | // De Ward PWA (/ward): de ward-kant van hetzelfde vangnet, in de browser.
|
|---|
| 2 | //
|
|---|
| 3 | // Wat hier bewaakt wordt is niet de opmaak maar de POORT en de INHOUD: een
|
|---|
| 4 | // vreemde mag deze pagina niet zien, en wat er wel doorheen komt moet de drie
|
|---|
| 5 | // dingen dragen waarvoor het scherm bestaat (wie waakt over mij, wat wacht er
|
|---|
| 6 | // op hen, en de §4-uitkomst die zegt of dat vangnet heel is).
|
|---|
| 7 | import { test } from 'node:test';
|
|---|
| 8 | import assert from 'node:assert/strict';
|
|---|
| 9 |
|
|---|
| 10 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 11 | process.env.PUBLIC_BASE_URL = 'https://kind.example';
|
|---|
| 12 |
|
|---|
| 13 | const dbMod = await import('../src/config/database.js');
|
|---|
| 14 | const db = dbMod.default;
|
|---|
| 15 | dbMod.initializeDatabase();
|
|---|
| 16 | const express = (await import('express')).default;
|
|---|
| 17 | const wardRoutes = (await import('../src/routes/ward.js')).default;
|
|---|
| 18 |
|
|---|
| 19 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 20 | .run('u1', 'mee', 'mee@t', 'x', 'user');
|
|---|
| 21 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
|
|---|
| 22 | .run('s1', 'mee', 'Mee', 'u1');
|
|---|
| 23 |
|
|---|
| 24 | // Twee guardians: oma is in orde, en een tweede die zelf een ward is. Die
|
|---|
| 25 | // laatste is het geval uit §4: een guardian met eigen guardians is er geen.
|
|---|
| 26 | const OMA = 'https://elders.example/ap/users/oma';
|
|---|
| 27 | const KAPOT = 'https://elders.example/ap/users/kapot';
|
|---|
| 28 | for (const [uri, handle] of [[OMA, '@oma@elders.example'], [KAPOT, '@kapot@elders.example']]) {
|
|---|
| 29 | db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, created_at)
|
|---|
| 30 | VALUES (?, 'ward', ?, ?, 'accepted', datetime('now'))`).run('mee', uri, handle);
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | /// Een sessie die zegt dat je ingelogd bent als deze gebruiker.
|
|---|
| 34 | function appWith(session) {
|
|---|
| 35 | const app = express();
|
|---|
| 36 | // Zelfde view-opzet als de echte server, anders kan express het sjabloon
|
|---|
| 37 | // niet vinden en faalt de render met "no default engine".
|
|---|
| 38 | app.set('view engine', 'ejs');
|
|---|
| 39 | app.set('views', new URL('../src/views', import.meta.url).pathname);
|
|---|
| 40 | app.use((req, res, next) => { req.session = session; res.locals = {}; next(); });
|
|---|
| 41 | app.use('/ward', wardRoutes);
|
|---|
| 42 | return app;
|
|---|
| 43 | }
|
|---|
| 44 |
|
|---|
| 45 | async function listen(app) {
|
|---|
| 46 | const server = app.listen(0);
|
|---|
| 47 | await new Promise((r) => server.once('listening', r));
|
|---|
| 48 | return { server, port: server.address().port };
|
|---|
| 49 | }
|
|---|
| 50 |
|
|---|
| 51 | test('een vreemde komt er niet in', async () => {
|
|---|
| 52 | // Geen sessie: requireAuth hoort dit te weren. Zonder deze poort zou het
|
|---|
| 53 | // vangnet van een kind voor iedereen te lezen zijn.
|
|---|
| 54 | const { server, port } = await listen(appWith({}));
|
|---|
| 55 | try {
|
|---|
| 56 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`, { redirect: 'manual' });
|
|---|
| 57 | assert.notEqual(r.status, 200, 'een niet-ingelogde lezer kreeg de ward-toestand');
|
|---|
| 58 | } finally { server.close(); }
|
|---|
| 59 | });
|
|---|
| 60 |
|
|---|
| 61 | test('de toestand draagt de guardians en wat er op hen wacht', async () => {
|
|---|
| 62 | // Een follow die de guardians nog moeten goedkeuren (§5.3 uitgaand).
|
|---|
| 63 | const G = await import('../src/services/guardianship/outgoing.js');
|
|---|
| 64 | // De velden heten zoals recordPending ze leest: id, target, inbox, handle.
|
|---|
| 65 | G.recordPending('mee', {
|
|---|
| 66 | id: 'of-test-1',
|
|---|
| 67 | target: 'https://elders.example/ap/users/tante',
|
|---|
| 68 | handle: '@tante@elders.example',
|
|---|
| 69 | inbox: 'https://elders.example/inbox',
|
|---|
| 70 | quorum: 'any',
|
|---|
| 71 | });
|
|---|
| 72 |
|
|---|
| 73 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 74 | try {
|
|---|
| 75 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`);
|
|---|
| 76 | assert.equal(r.status, 200);
|
|---|
| 77 | const s = await r.json();
|
|---|
| 78 |
|
|---|
| 79 | assert.equal(s.slug, 'mee');
|
|---|
| 80 | assert.deepEqual(s.guardians.map((g) => g.uri).sort(), [OMA, KAPOT].sort());
|
|---|
| 81 | // Beschikbaarheid hoort erbij: een guardian die weg is verandert de echte
|
|---|
| 82 | // maat van je vangnet.
|
|---|
| 83 | assert.ok(s.guardians.every((g) => typeof g.availability === 'string'));
|
|---|
| 84 |
|
|---|
| 85 | assert.equal(s.waitingFollows.length, 1);
|
|---|
| 86 | assert.equal(s.waitingFollows[0].handle, '@tante@elders.example');
|
|---|
| 87 | // Niet 'sent' en niet mislukt: dit is de derde uitkomst.
|
|---|
| 88 | assert.equal(s.waitingFollows[0].status, 'pending');
|
|---|
| 89 | } finally { server.close(); }
|
|---|
| 90 | });
|
|---|
| 91 |
|
|---|
| 92 | test('de pagina serveert zichzelf compleet, zonder inline script', async () => {
|
|---|
| 93 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 94 | try {
|
|---|
| 95 | const r = await fetch(`http://127.0.0.1:${port}/ward/`);
|
|---|
| 96 | assert.equal(r.status, 200);
|
|---|
| 97 | const html = await r.text();
|
|---|
| 98 | // De begintoestand reist mee, zodat het scherm niet leeg begint.
|
|---|
| 99 | assert.match(html, /id="ward-state"/);
|
|---|
| 100 | assert.match(html, /application\/json/);
|
|---|
| 101 | // CSP: alle logica komt uit een bestand, nooit uit de pagina zelf.
|
|---|
| 102 | assert.doesNotMatch(html, /<script(?![^>]*(src=|type="application\/json"))/);
|
|---|
| 103 | assert.match(html, /\/ward\/app\.js/);
|
|---|
| 104 | // En het is een installeerbare PWA, geen losse pagina.
|
|---|
| 105 | assert.match(html, /rel="manifest" href="\/ward\/manifest\.webmanifest"/);
|
|---|
| 106 | } finally { server.close(); }
|
|---|
| 107 | });
|
|---|
| 108 |
|
|---|
| 109 | test('het manifest wijst naar zijn eigen scope', async () => {
|
|---|
| 110 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 111 | try {
|
|---|
| 112 | const m = await (await fetch(`http://127.0.0.1:${port}/ward/manifest.webmanifest`)).json();
|
|---|
| 113 | assert.equal(m.scope, '/ward/');
|
|---|
| 114 | assert.equal(m.display, 'standalone');
|
|---|
| 115 | // Eigen scope EN eigen start_url: anders opent de geïnstalleerde app in
|
|---|
| 116 | // het guardian-dashboard, en dat is de andere kant van deze relatie.
|
|---|
| 117 | assert.match(m.start_url, /^\/ward/);
|
|---|
| 118 | } finally { server.close(); }
|
|---|
| 119 | });
|
|---|
| 120 |
|
|---|
| 121 | test('de feed levert afgemaakte kaarten, niet kale content', async () => {
|
|---|
| 122 | // Een post in de tijdlijn van dit kind.
|
|---|
| 123 | db.prepare(`INSERT INTO ap_timeline (slug, id, author_uri, author_handle, author_name, content, url, published, created_at)
|
|---|
| 124 | VALUES ('mee', 'p-1', ?, '@vriend@elders.example', 'Vriend', '<p>hallo</p>', 'https://elders.example/n/1', datetime('now'), datetime('now'))`)
|
|---|
| 125 | .run('https://elders.example/ap/users/vriend');
|
|---|
| 126 |
|
|---|
| 127 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 128 | try {
|
|---|
| 129 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/feed`);
|
|---|
| 130 | assert.equal(r.status, 200);
|
|---|
| 131 | const { items } = await r.json();
|
|---|
| 132 | assert.equal(items.length, 1);
|
|---|
| 133 | // De body komt AF van de server: dezelfde partial als de Krant. Alleen
|
|---|
| 134 | // `content` doorgeven zou media en quote-kaarten laten verdwijnen.
|
|---|
| 135 | assert.ok(items[0].bodyHtml, 'de kaart kwam zonder gerenderde body');
|
|---|
| 136 | assert.ok(items[0].whenText, 'geen leesbare tijd');
|
|---|
| 137 | // De id is waar de boei op mikt.
|
|---|
| 138 | assert.equal(items[0].id, 'p-1');
|
|---|
| 139 | } finally { server.close(); }
|
|---|
| 140 | });
|
|---|
| 141 |
|
|---|
| 142 | test('de boei weigert als er niemand is om te vragen', async () => {
|
|---|
| 143 | // Een kind ZONDER guardians. Dan is er niets om naartoe te sturen, en dat
|
|---|
| 144 | // moet het zeggen in plaats van te doen alsof er hulp onderweg is.
|
|---|
| 145 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 146 | .run('u2', 'alleen', 'a@t', 'x', 'user');
|
|---|
| 147 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
|
|---|
| 148 | .run('s2', 'alleen', 'Alleen', 'u2');
|
|---|
| 149 |
|
|---|
| 150 | const { server, port } = await listen(appWith({ user: { id: 'u2' } }));
|
|---|
| 151 | try {
|
|---|
| 152 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, {
|
|---|
| 153 | method: 'POST',
|
|---|
| 154 | headers: { 'Content-Type': 'application/json' },
|
|---|
| 155 | body: JSON.stringify({ words: 'help' }),
|
|---|
| 156 | });
|
|---|
| 157 | assert.equal(r.status, 409);
|
|---|
| 158 | assert.equal((await r.json()).error, 'no_guardians');
|
|---|
| 159 | } finally { server.close(); }
|
|---|
| 160 | });
|
|---|
| 161 |
|
|---|
| 162 | test('een vreemde kan de boei niet afvuren', async () => {
|
|---|
| 163 | // Zonder deze poort zou iedereen namens een kind zijn guardians kunnen
|
|---|
| 164 | // alarmeren.
|
|---|
| 165 | const { server, port } = await listen(appWith({}));
|
|---|
| 166 | try {
|
|---|
| 167 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/help`, {
|
|---|
| 168 | method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|---|
| 169 | body: JSON.stringify({ words: 'x' }), redirect: 'manual',
|
|---|
| 170 | });
|
|---|
| 171 | assert.notEqual(r.status, 200);
|
|---|
| 172 | } finally { server.close(); }
|
|---|
| 173 | });
|
|---|
| 174 |
|
|---|
| 175 |
|
|---|
| 176 | test('het gesprek draagt beide kanten en een zwaai heet een zwaai', async () => {
|
|---|
| 177 | // Eén binnengekomen bericht (ap_mentions) en één eigen antwoord (ap_outbox,
|
|---|
| 178 | // direct). Het gesprek moet ze allebei dragen, elk met zijn richting.
|
|---|
| 179 | const VRIEND = 'https://elders.example/ap/users/vriend';
|
|---|
| 180 | db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, wave, published, created_at)
|
|---|
| 181 | VALUES ('mee', 'https://elders.example/n/w1', ?, 'Vriend', '@vriend@elders.example', '<p>👋</p>', 1, datetime('now'), datetime('now'))`)
|
|---|
| 182 | .run(VRIEND);
|
|---|
| 183 | // post_id is NOT NULL (een reply hangt normaal onder een post); een direct
|
|---|
| 184 | // bericht draagt daar een leeg anker, precies zoals deliverDirectNote doet.
|
|---|
| 185 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, in_reply_to, content, visibility, to_actors, created_at)
|
|---|
| 186 | VALUES ('out-1', 'mee', '', NULL, '<p>hoi terug</p>', 'direct', ?, datetime('now', '+1 minute'))`)
|
|---|
| 187 | .run(JSON.stringify([VRIEND]));
|
|---|
| 188 |
|
|---|
| 189 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 190 | try {
|
|---|
| 191 | const r = await fetch(`http://127.0.0.1:${port}/ward/api/thread?with=${encodeURIComponent(VRIEND)}`);
|
|---|
| 192 | assert.equal(r.status, 200);
|
|---|
| 193 | const { items } = await r.json();
|
|---|
| 194 | const dirs = items.map((i) => i.dir).sort();
|
|---|
| 195 | assert.deepEqual(dirs, ['in', 'out'], 'een kant van het gesprek ontbreekt');
|
|---|
| 196 | // De zwaai-vlag overleeft de reis: de client toont hem groter, en dat kan
|
|---|
| 197 | // alleen als de server hem niet als kale tekst plat slaat.
|
|---|
| 198 | assert.equal(items.find((i) => i.dir === 'in').wave, true);
|
|---|
| 199 | } finally { server.close(); }
|
|---|
| 200 | });
|
|---|
| 201 |
|
|---|
| 202 | test('een bericht sturen eist een echte ontvanger en echte tekst', async () => {
|
|---|
| 203 | const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
|
|---|
| 204 | try {
|
|---|
| 205 | const post = (body) => fetch(`http://127.0.0.1:${port}/ward/api/message`, {
|
|---|
| 206 | method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|---|
| 207 | });
|
|---|
| 208 | // Geen uri: weigeren, want "aan niemand" bestaat niet.
|
|---|
| 209 | assert.equal((await post({ to: 'vriend', text: 'hoi' })).status, 400);
|
|---|
| 210 | // Lege tekst: weigeren, een leeg bericht versturen kan niet.
|
|---|
| 211 | assert.equal((await post({ to: 'https://elders.example/ap/users/vriend', text: ' ' })).status, 400);
|
|---|
| 212 | } finally { server.close(); }
|
|---|
| 213 | });
|
|---|
| 214 |
|
|---|
| 215 | test('hidden wint van elke display-regel in de stylesheet', async () => {
|
|---|
| 216 | // De gespreks-overlay heeft display:flex; zonder een [hidden]-regel met
|
|---|
| 217 | // !important verslaat die de browser-default en staat een "verborgen"
|
|---|
| 218 | // overlay permanent over de pagina (Robins schermafdruk, 12-8). De CSS is
|
|---|
| 219 | // hier de waarheid, dus die bewaken we, niet de DOM.
|
|---|
| 220 | const fs = await import('node:fs');
|
|---|
| 221 | const css = fs.readFileSync(new URL('../src/assets/css/ward.css', import.meta.url), 'utf8');
|
|---|
| 222 | assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important/, 'de [hidden]-regel ontbreekt');
|
|---|
| 223 | // En hij moet VOOR de #thread-regel staan is niet nodig (important wint),
|
|---|
| 224 | // maar de flex-regel moet er wel zijn, anders bewaakt deze test niets.
|
|---|
| 225 | assert.match(css, /#thread\s*\{[^}]*display:\s*flex/);
|
|---|
| 226 | });
|
|---|