source: Klonkt/test/ward-pwa.test.js@ 5b6f93b

Last change on this file since 5b6f93b was 5e0fea7, checked in by Robin <roboburr@…>, 3 months ago

Ward-PWA: theme.js kreeg geen route (404 brak de module)

app.js importeert /ward/theme.js, maar de asset-route ontbrak, dus de
hele module viel om op een 404 (Robins dev-check, 12-8). Route erbij,
naast sky.js. En een test die de koppeling bewaakt: elke module die
app.js importeert MOET ook geserveerd worden. Dat is precies het soort
fout dat je aan een screenshot niet ziet maar aan een witte pagina wel.

Changed files:
src/routes/ward.js

  • /theme.js als asset-route

test/ward-pwa.test.js

  • elke import uit app.js wordt als javascript geserveerd

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

  • Property mode set to 100644
File size: 15.9 KB
Line 
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).
7import { test } from 'node:test';
8import assert from 'node:assert/strict';
9
10process.env.DATABASE_PATH = ':memory:';
11process.env.PUBLIC_BASE_URL = 'https://kind.example';
12
13const dbMod = await import('../src/config/database.js');
14const db = dbMod.default;
15dbMod.initializeDatabase();
16const express = (await import('express')).default;
17const wardRoutes = (await import('../src/routes/ward.js')).default;
18
19db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
20 .run('u1', 'mee', 'mee@t', 'x', 'user');
21db.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.
26const OMA = 'https://elders.example/ap/users/oma';
27const KAPOT = 'https://elders.example/ap/users/kapot';
28for (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.
34function 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
45async 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
51test('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
61test('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
92test('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
109test('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
121test('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
142test('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
162test('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
176test('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
202test('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
215test('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});
227
228test('de vrienden achter de (+) komen uit beide richtingen', async () => {
229 // Iemand die JIJ volgt en iemand die JOU volgt: allebei een deur. De hemel
230 // toont alleen met wie je al sprak; de kiezer moet ruimer zijn, anders kun
231 // je nooit een EERSTE bericht sturen.
232 db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, name, icon, follow_id, status)
233 VALUES ('mee', 'https://elders.example/ap/users/tante', '@tante@elders.example', 'Tante', NULL, 'f-1', 'accepted')`).run();
234 db.prepare(`INSERT INTO ap_followers (slug, actor_uri, handle, name)
235 VALUES ('mee', 'https://elders.example/ap/users/neef', '@neef@elders.example', 'Neef')`).run();
236
237 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
238 try {
239 const r = await fetch(`http://127.0.0.1:${port}/ward/api/friends`);
240 assert.equal(r.status, 200);
241 const { items } = await r.json();
242 const uris = items.map((f) => f.uri);
243 assert.ok(uris.includes('https://elders.example/ap/users/tante'), 'wie je volgt ontbreekt');
244 assert.ok(uris.includes('https://elders.example/ap/users/neef'), 'wie jou volgt ontbreekt');
245 // Elke deur heeft een leesbare naam, ook zonder display name.
246 assert.ok(items.every((f) => f.name && f.handle));
247 } finally { server.close(); }
248});
249
250test('de compose-poort geldt ook voor de web-ward', async () => {
251 // mee IS een ward (heeft guardians), dus compose is gated. De web-composer
252 // gaat door DEZELFDE poort als de app (ingestOutboxActivity), dus hij wordt
253 // net zo tegengehouden. Zonder deze gelijkheid zou de browser een sluiproute
254 // om de guardians heen zijn.
255 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
256 try {
257 const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, {
258 method: 'POST', headers: { 'Content-Type': 'application/json' },
259 body: JSON.stringify({ text: 'mag dit', visibility: 'friends' }),
260 });
261 assert.equal(r.status, 403);
262 assert.equal((await r.json()).error, 'gated_compose');
263 } finally { server.close(); }
264});
265
266test('een niet-gated account plaatst met media', async () => {
267 // Een account ZONDER guardians is geen ward, dus compose is open. De post
268 // landt echt, met de foto eraan.
269 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
270 .run('u3', 'vrij', 'v@t', 'x', 'user');
271 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
272 .run('s3', 'vrij', 'Vrij', 'u3');
273
274 const { server, port } = await listen(appWith({ user: { id: 'u3' } }));
275 try {
276 const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, {
277 method: 'POST', headers: { 'Content-Type': 'application/json' },
278 body: JSON.stringify({
279 text: 'kijk mijn tekening',
280 visibility: 'friends',
281 media: [{ url: '/media/reply-media/x.jpg', mediaType: 'image/jpeg', name: 'tekening.jpg' }],
282 }),
283 });
284 assert.equal(r.status, 201, JSON.stringify(await r.clone().json().catch(() => ({}))));
285 const row = db.prepare("SELECT content FROM posts WHERE site_id = 's3' ORDER BY created_at DESC LIMIT 1").get();
286 assert.ok(row, 'de post is nergens opgeslagen');
287 // De foto reist als attachment mee, niet als kale tekst.
288 assert.match(row.content, /x\.jpg|<img|video|Image/i);
289 } finally { server.close(); }
290});
291
292test('een lege post wordt geweigerd', async () => {
293 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
294 try {
295 const r = await fetch(`http://127.0.0.1:${port}/ward/api/post`, {
296 method: 'POST', headers: { 'Content-Type': 'application/json' },
297 body: JSON.stringify({ text: ' ', media: [] }),
298 });
299 assert.equal(r.status, 400);
300 assert.equal((await r.json()).error, 'empty');
301 } finally { server.close(); }
302});
303
304test('een vreemde kan niet uploaden', async () => {
305 // De upload-poort moet net zo dicht zijn als de rest; anders vult iemand
306 // ongevraagd de media-map.
307 const { server, port } = await listen(appWith({}));
308 try {
309 const r = await fetch(`http://127.0.0.1:${port}/ward/api/upload`, { method: 'POST', redirect: 'manual' });
310 assert.notEqual(r.status, 201);
311 } finally { server.close(); }
312});
313
314test('elke ge-importeerde module wordt ook geserveerd', async () => {
315 // app.js importeert sky.js en theme.js. Zonder een route valt de hele module
316 // stil op een 404 en doet de pagina niets (Robins dev-check, 12-8: theme.js
317 // gaf 404). Dit bewaakt de koppeling die je aan een screenshot niet ziet.
318 const fs = await import('node:fs');
319 const app = fs.readFileSync(new URL('../src/assets/js/ward.js', import.meta.url), 'utf8');
320 const imports = [...app.matchAll(/from '\/ward\/([\w.-]+)'/g)].map((m) => m[1]);
321 assert.ok(imports.includes('sky.js') && imports.includes('theme.js'), 'verwachtte sky.js + theme.js imports');
322
323 const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
324 try {
325 for (const asset of imports) {
326 const r = await fetch(`http://127.0.0.1:${port}/ward/${asset}`);
327 assert.equal(r.status, 200, `/ward/${asset} werd niet geserveerd`);
328 assert.match(r.headers.get('content-type') || '', /javascript/);
329 }
330 } finally { server.close(); }
331});
Note: See TracBrowser for help on using the repository browser.