source: Klonkt/test/ward-profile.test.js

main
Last change on this file was 746d98a, checked in by Robin <roboburr@…>, 4 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

  • Property mode set to 100644
File size: 9.6 KB
RevLine 
[746d98a]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.
15import { test } from 'node:test';
16import assert from 'node:assert/strict';
17import os from 'os';
18import path from 'path';
19
20process.env.DATABASE_PATH = ':memory:';
21process.env.PUBLIC_BASE_URL = 'https://test.example';
22process.env.MEDIA_PATH = path.join(os.tmpdir(), 'klonkt-test-media');
23
24const dbMod = await import('../src/config/database.js');
25const db = dbMod.default;
26{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
27const { resolveSite } = await import('../src/middleware/site.js');
28const { wardProfileGate } = await import('../src/middleware/ward-profile.js');
29
30db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('kid', 'kid', 'k@t', 'x', 'user');
31db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('fan', 'fan', 'f@t', 'x', 'user');
32db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('baas', 'baas', 'b@t', 'x', 'god');
33db.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
38const GUARDIAN = 'https://elders.test/u/oma';
39const koppel = (status) => db.prepare("INSERT OR REPLACE INTO ap_guardianships (slug, role, other_uri, status) VALUES ('kid','ward',?,?)").run(GUARDIAN, status);
40const 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.
44let sessie = {};
45const bereikt = [];
46const express = (await import('express')).default;
47const app = express();
48app.use((req, res, next) => { req.session = sessie; next(); });
49app.use(resolveSite);
50app.use(wardProfileGate);
51const hier = (naam) => (req, res) => { bereikt.push(naam); res.send('ROUTE:' + naam); };
52app.get('/', hier('feed'));
53app.get('/auth/login', hier('login'));
54app.get('/audio/stream/:f', hier('stream'));
55app.get('/manifest.webmanifest', hier('manifest'));
56app.get('/search', hier('search'));
57app.get('/feed.xml', hier('rss'));
58app.post('/:slug/comment', hier('comment'));
59app.get('/:slug', hier('post'));
60const server = app.listen(0);
61server.unref();
62const base = `http://127.0.0.1:${server.address().port}`;
63test.after(() => server.close());
64
65const vraag = (pad, opts = {}) => fetch(base + pad, { redirect: 'manual', headers: { Accept: 'text/html', ...(opts.headers || {}) }, ...opts });
66const alsBezoeker = () => { sessie = {}; };
67const als = (id, role) => { sessie = { user: { id, username: id, role } }; };
68
69test('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
75test('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
80test('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
92test('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
101test('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
110test('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
117test('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
125test('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
134test('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
140test('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 ────────
148const accountRoutes = (await import('../src/routes/account.js')).default;
149const sitesRoutes = (await import('../src/routes/admin-sites.js')).default;
150const app2 = express();
151app2.use(express.urlencoded({ extended: true }));
152app2.use((req, res, next) => { req.session = sessie; res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); res.locals.siteUrlBase = ''; next(); });
153app2.use('/account', accountRoutes);
154app2.use('/admin/sites', sitesRoutes);
155const server2 = app2.listen(0);
156server2.unref();
157const base2 = `http://127.0.0.1:${server2.address().port}`;
158test.after(() => server2.close());
159const links = () => JSON.parse(db.prepare("SELECT profile_links FROM sites WHERE id = 's1'").get().profile_links || '[]');
160
161test('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
170test('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
180test('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});
Note: See TracBrowser for help on using the repository browser.