source: Klonkt/test/search-fediverse.test.js@ 9eb6e1b

main
Last change on this file since 9eb6e1b was 9eb6e1b, checked in by Robin <roboburr@…>, 8 hours ago

Een fediverse-post ziet eruit als een post, ook in de zoekbalk

Plak je een link in de zoekbalk, dan laadt de uitklaplijst nu de post zelf
in plaats van te zoeken naar de tekst van de link. Dezelfde kaart staat op de
zoekpagina en op /authorize_interaction, dus één render op drie plekken.

DE KAART. Wat eromheen stond is teruggebracht tot een kop met gezicht, de
inhoud, en één rij knoppen: Reageer, Like, Boost en het origineel. Weg zijn de
zin "je reageert op", de aparte knop naar het origineel, de twee grote pillen,
"of reageer:" en het antwoordvenster dat altijd openstond. Antwoorden klapt
open als je erom vraagt; melden staat onderaan en niet in de weg.

Foto's staan in een raster met vaste rijhoogtes. Met verhoudingen groeide het
mee met de breedte, en werd op een desktop de eerste van drie foto's zo hoog
dat de knoppen onder de vouw van de uitklaplijst verdwenen.

EEN INHOUDSWAARSCHUWING WORDT GERESPECTEERD. De oude render toonde alles open,
ook wat de schrijver achter een waarschuwing had gezet.

MOBIEL. Elke knop minstens 44 punten. Op een smal scherm vallen de woorden weg
en blijven de tekens staan, gelijk verdeeld; de woorden blijven bestaan voor
wie het scherm voorgelezen krijgt. Nagemeten op 375 pixels: geen horizontale
overloop, kleinste tikdoel 44, de knoppen in beeld zonder te scrollen.

DE PREVIEW IS COMPACT. Waarderen en boosten kan ter plekke; antwoorden en
melden gaan naar de volle pagina, met ?reply=1 of ?report=1 zodat dat deel
daar al openstaat. Een editor in een uitklaplijst is op een telefoon geen plek
om te schrijven. Zonder JS doet dezelfde link hetzelfde.

/search/remote levert het fragment, achter dezelfde grens als de volle
pagina: alleen wie de site beheert laat ophalen. Die grens weegt hier zwaarder,
want deze route wordt bij elke toetsaanslag geraakt; zonder hem is hij een
haalservice die iedereen deze server op adressen naar keuze laat afsturen.
Voor een bezoeker 204, en dan valt de zoekbalk terug op de gewone suggesties.
Elke toetsaanslag krijgt een volgnummer, zodat een traag antwoord op een oude
vraag niet overschrijft wat je inmiddels hebt getypt.

Waarderen en boosten ter plekke verhuisde van authorize-interaction.js naar
chrome.js: de kaart staat nu op elke pagina, via de zoekbalk. De zoekpagina
stuurde je bij een like eerder weg naar /authorize_interaction.

Onderweg: /authorize_interaction haalt nu ook ondertekend op, net als de
zoekbalk. Anders zegt die pagina "niet gevonden" over een post voor volgers die
de preview wel liet zien.

Getoetst: de preview met de post, ondertekend; wat er in de compacte kaart wel
en niet staat; dat een bezoeker via de preview niets laat ophalen; dat een
zoekterm niets ophaalt; de waarschuwing; het venster op verzoek. Tegenproef:
rechtencheck eruit, waarschuwing genegeerd, compact genegeerd en anoniem
ophalen laten elk hun eigen toets omvallen. Volledige suite 1283 groen.

Het gedrag in chrome.js is in de browser nagelopen met een nagebootste
server: herkennen, laden, invoegen, de volgorde bij een traag antwoord, de
terugval bij 204, waarderen en boosten heen en terug, en het antwoordvenster
open en dicht met de focus op de goede plek.

shaer-utpi

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

  • Property mode set to 100644
File size: 13.4 KB
Line 
1// Een geplakt adres in de zoekbalk is geen zoekterm (shaer-utpi).
2//
3// Wat hier bewaakt wordt, en waarom het elk een eigen zaak is:
4//
5// 1. HERKENNING. "soundfabrics.nl" is een zoekterm en https://... is een
6// adres. Het schema is het verschil; zonder die regel wordt elke
7// zoekopdracht met een punt erin een uitgaand netwerkverzoek.
8// 2. WIE HET MAG LATEN OPHALEN. Ophalen is een verzoek dat deze server
9// namens de bezoeker doet. Voor een willekeurige bezoeker zou de zoekbalk
10// daarmee een haalservice zijn waarmee je andermans server laat aankloppen
11// op adressen naar keuze. Dezelfde grens als /authorize_interaction.
12// 3. DAT DE KNOPPEN ER ECHT STAAN. De render is een gedeelde partial; valt
13// die uit de pagina, dan ziet het er nog steeds uit als een gevonden post
14// en kan er alleen niets meer mee.
15import { test } from 'node:test';
16import assert from 'node:assert/strict';
17
18process.env.DATABASE_PATH = ':memory:';
19process.env.PUBLIC_BASE_URL = 'https://test.example';
20
21const dbMod = await import('../src/config/database.js');
22const db = dbMod.default;
23{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
24
25const AP = (await import('../src/services/ActivityPubService.js')).default;
26const searchMod = await import('../src/routes/search.js');
27const router = searchMod.default;
28const { lookupUri } = searchMod;
29
30db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
31 .run('u1', 'baas', 'b@t.nl', 'x', 'god');
32db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
33 .run('s1', 'robo', 'Soundfabrics', 'u1');
34const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
35
36// De echte resolvers doen netwerk. Hier staat wat ze zouden vinden, plus een
37// teller: "is er überhaupt opgehaald" is de helft van wat deze toets bewaakt.
38let gevraagd = [];
39let opties = [];
40AP.resolveRemoteNote = async (uri, opts) => {
41 gevraagd.push(['note', uri]);
42 opties.push(opts || {});
43 if (!uri.includes('/notes/')) return null;
44 return {
45 object_uri: uri, url: uri,
46 actor_uri: 'https://elders.test/u/oma', actor_url: 'https://elders.test/@oma',
47 actor_handle: '@oma@elders.test', actor_name: 'Oma', actor_icon: '',
48 content: '<p>Een bericht van ver weg</p>', images: [], media: [], poll: null,
49 preview: 'Een bericht van ver weg', threadInboxes: [], localPostId: '',
50 sensitive: uri.includes('/cw'), cw: uri.includes('/cw') ? 'Spinnen' : '',
51 };
52};
53AP.resolveRemoteActor = async (uri) => {
54 gevraagd.push(['actor', uri]);
55 if (!uri.includes('/u/')) return null;
56 return {
57 actor_uri: uri, actor_name: 'Oma', actor_handle: '@oma@elders.test',
58 actor_url: uri, actor_icon: 'https://elders.test/avatar.png', inbox: uri + '/inbox',
59 };
60};
61
62const express = (await import('express')).default;
63let ingelogd = true;
64const app = express();
65app.use((req, res, next) => {
66 res.locals.site = site();
67 res.locals.siteUrlBase = '';
68 req.session = ingelogd ? { user: { id: 'u1', role: 'god', username: 'baas' } } : {};
69 next();
70});
71app.use('/search', router);
72const server = app.listen(0);
73server.unref();
74const poort = server.address().port;
75test.after(() => server.close());
76
77const zoek = async (q) => {
78 gevraagd = [];
79 const r = await fetch(`http://127.0.0.1:${poort}/search?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });
80 return { status: r.status, html: await r.text() };
81};
82
83test('een adres wordt herkend, een zoekterm niet', () => {
84 assert.equal(lookupUri('https://elders.test/notes/1'), 'https://elders.test/notes/1');
85 assert.equal(lookupUri(' https://elders.test/notes/1 '), 'https://elders.test/notes/1');
86 // De fragmentverwijzing hoort bij de browser, niet bij het object.
87 assert.equal(lookupUri('https://elders.test/notes/1#comment-2'), 'https://elders.test/notes/1');
88 // Geen schema is geen adres: anders wordt zoeken naar een naam een verzoek.
89 assert.equal(lookupUri('soundfabrics.nl'), null);
90 assert.equal(lookupUri('www.soundfabrics.nl/muziek'), null);
91 assert.equal(lookupUri('soundfabrics'), null);
92 // Twee woorden waarvan er een een adres is, is een zoekopdracht.
93 assert.equal(lookupUri('https://elders.test/notes/1 oma'), null);
94 // Andere schema's halen we niet op.
95 assert.equal(lookupUri('javascript:alert(1)'), null);
96 assert.equal(lookupUri('file:///etc/passwd'), null);
97 // Een naam zonder punt is geen adres op het open net.
98 assert.equal(lookupUri('http://localhost:3000/notes/1'), null);
99});
100
101test('een gewone zoekopdracht haalt niets op', async () => {
102 const { status, html } = await zoek('muziek');
103 assert.equal(status, 200);
104 assert.deepEqual(gevraagd, [], 'een zoekterm hoort geen netwerkverzoek te worden');
105 assert.ok(!html.includes('search-remote'), 'en er hoort geen fediverse-blok te staan');
106});
107
108test('een post van een andere server komt met zijn knoppen binnen', async () => {
109 const { html } = await zoek('https://elders.test/notes/1');
110 assert.deepEqual(gevraagd, [['note', 'https://elders.test/notes/1']]);
111 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
112 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
113 // De functies van authorize_interaction, op de zoekpagina.
114 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
115 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt`);
116 }
117 assert.ok(html.includes('action="/authorize_interaction"'), 'het antwoordvenster ontbreekt');
118});
119
120test('een profiel levert de volgknop', async () => {
121 const { html } = await zoek('https://elders.test/u/oma');
122 assert.deepEqual(gevraagd.map((g) => g[0]), ['note', 'actor'], 'eerst post, dan profiel');
123 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
124 assert.ok(html.includes('https://elders.test/u/oma'), 'het profiel hoort in het formulier te staan');
125});
126
127test('een adres dat niets oplevert laat de rest van de zoekpagina staan', async () => {
128 const { status, html } = await zoek('https://elders.test/iets-anders');
129 assert.equal(status, 200);
130 assert.equal(gevraagd.length, 2, 'beide vormen zijn geprobeerd');
131 assert.ok(html.includes('search-remote'), 'het blok staat er, met de melding erin');
132 assert.ok(html.includes('search-page-form'), 'en de zoekpagina zelf is niet weggevallen');
133});
134
135test('een bezoeker laat deze server niets ophalen', async () => {
136 ingelogd = false;
137 try {
138 const { status, html } = await zoek('https://elders.test/notes/1');
139 assert.equal(status, 200);
140 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan');
141 assert.ok(!html.includes('/authorize_interaction/like'), 'en er staan geen knoppen');
142 } finally {
143 ingelogd = true;
144 }
145});
146
147// ── Dezelfde render, de andere ingang ───────────────────────────────────────
148//
149// De zoekpagina en /authorize_interaction delen sinds deze ronde één partial.
150// Die pagina had zelf geen enkele toets, dus een verhuizing van 200 regels eruit
151// zou stil kunnen mislukken: de suite bleef groen en het scherm was leeg. Deze
152// toets is de bodem daaronder.
153const postsRouter = (await import('../src/routes/posts.js')).default;
154const app2 = express();
155app2.use((req, res, next) => {
156 res.locals.site = site();
157 res.locals.siteUrlBase = '';
158 req.session = { user: { id: 'u1', role: 'god', username: 'baas' } };
159 next();
160});
161app2.use('/', postsRouter);
162const server2 = app2.listen(0);
163server2.unref();
164const poort2 = server2.address().port;
165test.after(() => server2.close());
166
167test('de interactiepagina toont hetzelfde als de zoekpagina', async () => {
168 const r = await fetch(
169 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/notes/1')}`,
170 { signal: AbortSignal.timeout(10000) },
171 );
172 assert.equal(r.status, 200);
173 const html = await r.text();
174 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
175 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
176 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
177 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt op de interactiepagina`);
178 }
179 // En de opmaak reist mee: die staat sinds deze ronde in een eigen partial,
180 // en een render zonder opmaak ziet er kapot uit zonder dat er iets faalt.
181 assert.ok(html.includes('.rp-card'), 'de opmaak van de kaart ontbreekt');
182});
183
184test('een profiel op de interactiepagina levert dezelfde volgknop', async () => {
185 const r = await fetch(
186 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/u/oma')}`,
187 { signal: AbortSignal.timeout(10000) },
188 );
189 const html = await r.text();
190 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
191 assert.ok(html.includes('@oma@elders.test'));
192});
193
194// ── De live-preview onder de zoekbalk ───────────────────────────────────────
195//
196// Plak je een link, dan laadt de uitklaplijst de post zelf. Deze route wordt bij
197// elke toetsaanslag geraakt, en dat maakt de rechtencheck hier zwaarder dan op
198// de pagina: zonder hem is dit een haalservice waarmee iedereen deze server op
199// adressen naar keuze laat afsturen.
200
201const preview = async (q) => {
202 gevraagd = []; opties = [];
203 const r = await fetch(`http://127.0.0.1:${poort}/search/remote?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });
204 return { status: r.status, html: await r.text(), cache: r.headers.get('cache-control') };
205};
206
207test('de preview laadt de post zelf, ondertekend als de site', async () => {
208 const { status, html, cache } = await preview('https://elders.test/notes/1');
209 assert.equal(status, 200);
210 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort in de preview te staan');
211 assert.ok(html.includes('@oma@elders.test'));
212 assert.equal(opties[0].asSlug, 'robo', 'een post voor volgers weigert een anonieme GET');
213 // Wat iemand ziet hangt van zijn rechten en reacties af.
214 assert.match(cache || '', /no-store/);
215});
216
217test('in de preview kun je waarderen en boosten, antwoorden gaat naar de volle pagina', async () => {
218 const { html } = await preview('https://elders.test/notes/1');
219 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost']) {
220 assert.ok(html.includes(`action="${actie}"`), `${actie} hoort in de preview te staan`);
221 }
222 // Een editor in een uitklaplijst is op een telefoon geen plek om te
223 // schrijven: de antwoordknop wijst naar de volle pagina, met het venster open.
224 assert.ok(!html.includes('action="/authorize_interaction"'), 'geen antwoordvenster in de preview');
225 assert.ok(!html.includes('action="/authorize_interaction/report"'), 'melden gebeurt op de volle pagina');
226 assert.ok(html.includes('/authorize_interaction?uri=https%3A%2F%2Felders.test%2Fnotes%2F1&amp;reply=1'),
227 'de antwoordknop moet naar de volle pagina met het venster al open');
228});
229
230test('een profiel in de preview geeft de volgknop', async () => {
231 const { status, html } = await preview('https://elders.test/u/oma');
232 assert.equal(status, 200);
233 assert.ok(html.includes('action="/authorize_interaction/follow"'));
234});
235
236test('een bezoeker laat via de preview niets ophalen', async () => {
237 ingelogd = false;
238 try {
239 const { status, html } = await preview('https://elders.test/notes/1');
240 // 204 en geen 403: voor een bezoeker is een geplakte link gewoon een
241 // zoekterm, en de zoekbalk valt dan terug op de gewone suggesties.
242 assert.equal(status, 204);
243 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan');
244 assert.equal(html, '');
245 } finally {
246 ingelogd = true;
247 }
248});
249
250test('een zoekterm in de preview haalt niets op', async () => {
251 const { status } = await preview('soundfabrics.nl');
252 assert.equal(status, 204);
253 assert.deepEqual(gevraagd, []);
254});
255
256test('een inhoudswaarschuwing blijft dicht tot je ervoor kiest', async () => {
257 // De oude render toonde alles open, ook wat de schrijver achter een
258 // waarschuwing had gezet.
259 const { html } = await preview('https://elders.test/notes/cw');
260 const cw = html.indexOf('<details class="rp-cw">');
261 assert.ok(cw >= 0, 'de post hoort achter een waarschuwing te staan');
262 assert.ok(html.indexOf('Spinnen', cw) > cw, 'met de tekst van de waarschuwing erop');
263 assert.ok(html.indexOf('Een bericht van ver weg') > cw, 'en de inhoud erachter, niet ervoor');
264});
265
266test('de interactiepagina opent het antwoordvenster op verzoek, en haalt ondertekend op', async () => {
267 const pagina = async (extra) => {
268 opties = [];
269 const r = await fetch(
270 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/notes/1')}${extra}`,
271 { signal: AbortSignal.timeout(10000) },
272 );
273 return r.text();
274 };
275 const dicht = await pagina('');
276 assert.match(dicht, /<section class="rp-reply" id="rp-reply" hidden>/, 'zonder verzoek is het venster dicht');
277 assert.equal(opties[0].asSlug, 'robo', 'ook hier ondertekend, anders "niet gevonden" voor een volgerspost');
278 const open = await pagina('&reply=1');
279 assert.match(open, /<section class="rp-reply" id="rp-reply">/, 'met ?reply=1 staat het open, ook zonder JS');
280 assert.ok(open.includes('action="/authorize_interaction"'), 'en het venster heeft zijn formulier');
281});
282
Note: See TracBrowser for help on using the repository browser.