source: Klonkt/test/ward-pwa.test.js@ 39dab6b

Last change on this file since 39dab6b was 2086ea2, checked in by Robin <roboburr@…>, 3 months ago

Ward-PWA: de composer met media

Schrijven met foto, geluid of video, in de browser.

DEZELFDE POORT ALS DE APP, en dat is de kern. De composer bouwt precies
de AS2 Create die Shaer over C2S stuurt en geeft die aan
ingestOutboxActivity: die doet de compose-poort (par. 5), de
attachment-validatie en de zichtbaarheid. GEEN eigen posting-pad, want
dan zou de web-ward een sluiproute om de guardians heen zijn. Een test
bewaakt precies dat: een gated ward wordt ook via de browser
tegengehouden.

Media gaan eerst apart naar /ward/api/upload, in DEZELFDE bak als de
app (reply-media), zodat een foto van een app-ward en een browser-ward
niet van twee plekken federeert. De server maakt de rest af zoals altijd:
posterframe voor video, waveform voor audio. De post draagt daarna alleen
de url, dus de payload blijft klein.

Sessie-geauthenticeerd, niet met een bearer: /ward draait op je eigen
ingelogde Klonkt, dus de browser heeft geen token nodig zoals de app.

New file: (geen)
Changed files:
src/routes/ward.js

  • /api/upload: sessie-geauthenticeerd, zelfde filters/map als C2S
  • /api/post: bouwt de AS2 Create, door ingestOutboxActivity

src/views/pages/ward.ejs

  • schrijfknop, composer-dialoog met tekst, media en zichtbaarheid

src/assets/js/ward.js

  • upload-per-bestand, media-chips, plaatsen; opruiming van een dubbel geraakte doc-comment

src/assets/css/ward.css

  • composer, media-chips, tools

src/services/i18n.js

  • twaalf sleutels in nl, en, de

test/ward-pwa.test.js

  • de gate geldt ook in de browser; een vrij account plaatst met media; leeg wordt geweigerd; een vreemde kan niet uploaden

remarks: 903 tests groen. Opnemen van audio/video in de browser zelf
(MediaRecorder) is nog niet gebouwd; nu kies je een bestand. Bead volgt.

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

  • Property mode set to 100644
File size: 14.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});
Note: See TracBrowser for help on using the repository browser.