source: Klonkt/test/search-fediverse.test.js@ 86cfa1a

main
Last change on this file since 86cfa1a was 86cfa1a, checked in by Robin <roboburr@…>, 6 hours ago

Een profiel op mastodon.social laadt: ook het profiel wordt ondertekend opgehaald

Plak je ​https://mastodon.social/@hotdogsladies in de zoekbalk, dan kwam er
"niet gevonden"; ​https://arvr.social/@bedrijfzondernaam liet gewoon een profiel
zien. Allebei Mastodon. Het verschil is authorized fetch: mastodon.social geeft
een onbetekende GET van een profiel een 401 {"error":"Request not signed"},
arvr.social serveert hem aan iedereen.

fetchActor kan dat al aan -- eerst onbetekend, en tekenen als dat niet lukt --
maar alleen als de aanroeper zegt als welke site. resolveRemoteActor gaf dat
nooit door, dus tekende nooit. De ironie: de zoekbalk probeert eerst "is het een
post?", en dat verzoek gaat WEL ondertekend en krijgt het profiel ook terug, maar
gooit het weg omdat het geen post is. Daarna haalde de profielstap hetzelfde
adres opnieuw op, onbetekend.

Gemeten op dev voor de wijziging: onbetekend niets, ondertekend als dev het
profiel met inbox. arvr.social in beide gevallen gevonden, en daar gaat ook
geen ondertekend verzoek uit: dat is alleen de terugval.

ALLEEN ALS JE INGELOGD BENT (Robin, 30-9). Tekenen is deze site die met haar
eigen sleutel voor het verzoek instaat, en dat mag alleen namens een ingelogde
beheerder van die site. resolveRemoteActor blijft zonder opts onbetekend; de
enige aanroepers die de site meegeven zijn de zoekbalk (achter mayLookUp) en
/authorize_interaction (requireSiteManager).

De volgknop had dit gat niet: followActor haalt al sinds 31-7 ondertekend op.

Getoetst: het profiel ondertekend op de zoekpagina, in de preview en op de
interactiepagina, en dat niet ingelogd geen enkel verzoek uitgaat, dus ook geen
ondertekend. Tegenproef: onbetekend in de zoekbalk, onbetekend op de
interactiepagina en de inlogcontrole eruit laten elk hun eigen toets omvallen.
Volledige suite 1286 groen.

shaer-utpi

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

  • Property mode set to 100644
File size: 15.3 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};
53let actorOpties = [];
54AP.resolveRemoteActor = async (uri, opts) => {
55 gevraagd.push(['actor', uri]);
56 actorOpties.push(opts || {});
57 if (!uri.includes('/u/')) return null;
58 return {
59 actor_uri: uri, actor_name: 'Oma', actor_handle: '@oma@elders.test',
60 actor_url: uri, actor_icon: 'https://elders.test/avatar.png', inbox: uri + '/inbox',
61 };
62};
63
64const express = (await import('express')).default;
65let ingelogd = true;
66const app = express();
67app.use((req, res, next) => {
68 res.locals.site = site();
69 res.locals.siteUrlBase = '';
70 req.session = ingelogd ? { user: { id: 'u1', role: 'god', username: 'baas' } } : {};
71 next();
72});
73app.use('/search', router);
74const server = app.listen(0);
75server.unref();
76const poort = server.address().port;
77test.after(() => server.close());
78
79const zoek = async (q) => {
80 gevraagd = [];
81 const r = await fetch(`http://127.0.0.1:${poort}/search?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });
82 return { status: r.status, html: await r.text() };
83};
84
85test('een adres wordt herkend, een zoekterm niet', () => {
86 assert.equal(lookupUri('https://elders.test/notes/1'), 'https://elders.test/notes/1');
87 assert.equal(lookupUri(' https://elders.test/notes/1 '), 'https://elders.test/notes/1');
88 // De fragmentverwijzing hoort bij de browser, niet bij het object.
89 assert.equal(lookupUri('https://elders.test/notes/1#comment-2'), 'https://elders.test/notes/1');
90 // Geen schema is geen adres: anders wordt zoeken naar een naam een verzoek.
91 assert.equal(lookupUri('soundfabrics.nl'), null);
92 assert.equal(lookupUri('www.soundfabrics.nl/muziek'), null);
93 assert.equal(lookupUri('soundfabrics'), null);
94 // Twee woorden waarvan er een een adres is, is een zoekopdracht.
95 assert.equal(lookupUri('https://elders.test/notes/1 oma'), null);
96 // Andere schema's halen we niet op.
97 assert.equal(lookupUri('javascript:alert(1)'), null);
98 assert.equal(lookupUri('file:///etc/passwd'), null);
99 // Een naam zonder punt is geen adres op het open net.
100 assert.equal(lookupUri('http://localhost:3000/notes/1'), null);
101});
102
103test('een gewone zoekopdracht haalt niets op', async () => {
104 const { status, html } = await zoek('muziek');
105 assert.equal(status, 200);
106 assert.deepEqual(gevraagd, [], 'een zoekterm hoort geen netwerkverzoek te worden');
107 assert.ok(!html.includes('search-remote'), 'en er hoort geen fediverse-blok te staan');
108});
109
110test('een post van een andere server komt met zijn knoppen binnen', async () => {
111 const { html } = await zoek('https://elders.test/notes/1');
112 assert.deepEqual(gevraagd, [['note', 'https://elders.test/notes/1']]);
113 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
114 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
115 // De functies van authorize_interaction, op de zoekpagina.
116 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
117 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt`);
118 }
119 assert.ok(html.includes('action="/authorize_interaction"'), 'het antwoordvenster ontbreekt');
120});
121
122test('een profiel levert de volgknop', async () => {
123 const { html } = await zoek('https://elders.test/u/oma');
124 assert.deepEqual(gevraagd.map((g) => g[0]), ['note', 'actor'], 'eerst post, dan profiel');
125 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
126 assert.ok(html.includes('https://elders.test/u/oma'), 'het profiel hoort in het formulier te staan');
127});
128
129test('een adres dat niets oplevert laat de rest van de zoekpagina staan', async () => {
130 const { status, html } = await zoek('https://elders.test/iets-anders');
131 assert.equal(status, 200);
132 assert.equal(gevraagd.length, 2, 'beide vormen zijn geprobeerd');
133 assert.ok(html.includes('search-remote'), 'het blok staat er, met de melding erin');
134 assert.ok(html.includes('search-page-form'), 'en de zoekpagina zelf is niet weggevallen');
135});
136
137test('een bezoeker laat deze server niets ophalen', async () => {
138 ingelogd = false;
139 try {
140 const { status, html } = await zoek('https://elders.test/notes/1');
141 assert.equal(status, 200);
142 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan');
143 assert.ok(!html.includes('/authorize_interaction/like'), 'en er staan geen knoppen');
144 } finally {
145 ingelogd = true;
146 }
147});
148
149// ── Dezelfde render, de andere ingang ───────────────────────────────────────
150//
151// De zoekpagina en /authorize_interaction delen sinds deze ronde één partial.
152// Die pagina had zelf geen enkele toets, dus een verhuizing van 200 regels eruit
153// zou stil kunnen mislukken: de suite bleef groen en het scherm was leeg. Deze
154// toets is de bodem daaronder.
155const postsRouter = (await import('../src/routes/posts.js')).default;
156const app2 = express();
157app2.use((req, res, next) => {
158 res.locals.site = site();
159 res.locals.siteUrlBase = '';
160 req.session = { user: { id: 'u1', role: 'god', username: 'baas' } };
161 next();
162});
163app2.use('/', postsRouter);
164const server2 = app2.listen(0);
165server2.unref();
166const poort2 = server2.address().port;
167test.after(() => server2.close());
168
169test('de interactiepagina toont hetzelfde als de zoekpagina', async () => {
170 const r = await fetch(
171 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/notes/1')}`,
172 { signal: AbortSignal.timeout(10000) },
173 );
174 assert.equal(r.status, 200);
175 const html = await r.text();
176 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
177 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
178 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
179 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt op de interactiepagina`);
180 }
181 // En de opmaak reist mee: die staat sinds deze ronde in een eigen partial,
182 // en een render zonder opmaak ziet er kapot uit zonder dat er iets faalt.
183 assert.ok(html.includes('.rp-card'), 'de opmaak van de kaart ontbreekt');
184});
185
186test('een profiel op de interactiepagina levert dezelfde volgknop', async () => {
187 const r = await fetch(
188 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/u/oma')}`,
189 { signal: AbortSignal.timeout(10000) },
190 );
191 const html = await r.text();
192 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
193 assert.ok(html.includes('@oma@elders.test'));
194});
195
196// ── De live-preview onder de zoekbalk ───────────────────────────────────────
197//
198// Plak je een link, dan laadt de uitklaplijst de post zelf. Deze route wordt bij
199// elke toetsaanslag geraakt, en dat maakt de rechtencheck hier zwaarder dan op
200// de pagina: zonder hem is dit een haalservice waarmee iedereen deze server op
201// adressen naar keuze laat afsturen.
202
203const preview = async (q) => {
204 gevraagd = []; opties = [];
205 const r = await fetch(`http://127.0.0.1:${poort}/search/remote?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });
206 return { status: r.status, html: await r.text(), cache: r.headers.get('cache-control') };
207};
208
209test('de preview laadt de post zelf, ondertekend als de site', async () => {
210 const { status, html, cache } = await preview('https://elders.test/notes/1');
211 assert.equal(status, 200);
212 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort in de preview te staan');
213 assert.ok(html.includes('@oma@elders.test'));
214 assert.equal(opties[0].asSlug, 'robo', 'een post voor volgers weigert een anonieme GET');
215 // Wat iemand ziet hangt van zijn rechten en reacties af.
216 assert.match(cache || '', /no-store/);
217});
218
219test('in de preview kun je waarderen en boosten, antwoorden gaat naar de volle pagina', async () => {
220 const { html } = await preview('https://elders.test/notes/1');
221 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost']) {
222 assert.ok(html.includes(`action="${actie}"`), `${actie} hoort in de preview te staan`);
223 }
224 // Een editor in een uitklaplijst is op een telefoon geen plek om te
225 // schrijven: de antwoordknop wijst naar de volle pagina, met het venster open.
226 assert.ok(!html.includes('action="/authorize_interaction"'), 'geen antwoordvenster in de preview');
227 assert.ok(!html.includes('action="/authorize_interaction/report"'), 'melden gebeurt op de volle pagina');
228 assert.ok(html.includes('/authorize_interaction?uri=https%3A%2F%2Felders.test%2Fnotes%2F1&amp;reply=1'),
229 'de antwoordknop moet naar de volle pagina met het venster al open');
230});
231
232test('een profiel in de preview geeft de volgknop', async () => {
233 const { status, html } = await preview('https://elders.test/u/oma');
234 assert.equal(status, 200);
235 assert.ok(html.includes('action="/authorize_interaction/follow"'));
236});
237
238test('een bezoeker laat via de preview niets ophalen', async () => {
239 ingelogd = false;
240 try {
241 const { status, html } = await preview('https://elders.test/notes/1');
242 // 204 en geen 403: voor een bezoeker is een geplakte link gewoon een
243 // zoekterm, en de zoekbalk valt dan terug op de gewone suggesties.
244 assert.equal(status, 204);
245 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan');
246 assert.equal(html, '');
247 } finally {
248 ingelogd = true;
249 }
250});
251
252test('een zoekterm in de preview haalt niets op', async () => {
253 const { status } = await preview('soundfabrics.nl');
254 assert.equal(status, 204);
255 assert.deepEqual(gevraagd, []);
256});
257
258test('een inhoudswaarschuwing blijft dicht tot je ervoor kiest', async () => {
259 // De oude render toonde alles open, ook wat de schrijver achter een
260 // waarschuwing had gezet.
261 const { html } = await preview('https://elders.test/notes/cw');
262 const cw = html.indexOf('<details class="rp-cw">');
263 assert.ok(cw >= 0, 'de post hoort achter een waarschuwing te staan');
264 assert.ok(html.indexOf('Spinnen', cw) > cw, 'met de tekst van de waarschuwing erop');
265 assert.ok(html.indexOf('Een bericht van ver weg') > cw, 'en de inhoud erachter, niet ervoor');
266});
267
268test('de interactiepagina opent het antwoordvenster op verzoek, en haalt ondertekend op', async () => {
269 const pagina = async (extra) => {
270 opties = [];
271 const r = await fetch(
272 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/notes/1')}${extra}`,
273 { signal: AbortSignal.timeout(10000) },
274 );
275 return r.text();
276 };
277 const dicht = await pagina('');
278 assert.match(dicht, /<section class="rp-reply" id="rp-reply" hidden>/, 'zonder verzoek is het venster dicht');
279 assert.equal(opties[0].asSlug, 'robo', 'ook hier ondertekend, anders "niet gevonden" voor een volgerspost');
280 const open = await pagina('&reply=1');
281 assert.match(open, /<section class="rp-reply" id="rp-reply">/, 'met ?reply=1 staat het open, ook zonder JS');
282 assert.ok(open.includes('action="/authorize_interaction"'), 'en het venster heeft zijn formulier');
283});
284
285// ── Een profiel op een instance met authorized fetch ────────────────────────
286//
287// mastodon.social geeft een onbetekende GET van een profiel een 401; arvr.social
288// niet. Het profiel werd onbetekend opgehaald, dus het ene Mastodon toonde een
289// profiel en het andere "niet gevonden". De post-kant was al ondertekend, de
290// profiel-kant niet (gemeten op dev, 30-9: onbetekend niets, als dev gevonden).
291//
292// En de regel eronder: ondertekenen is deze site die voor het verzoek instaat,
293// en dat mag alleen namens een INGELOGDE beheerder. Een bezoeker laat niets
294// ophalen, ondertekend of niet.
295
296test('een profiel wordt ondertekend opgehaald, op de zoekpagina en in de preview', async () => {
297 actorOpties = [];
298 await zoek('https://elders.test/u/oma');
299 assert.equal(actorOpties[0] && actorOpties[0].asSlug, 'robo', 'de zoekpagina haalt het profiel als de site op');
300 actorOpties = [];
301 await preview('https://elders.test/u/oma');
302 assert.equal(actorOpties[0] && actorOpties[0].asSlug, 'robo', 'de preview ook');
303});
304
305test('ook de interactiepagina haalt een profiel ondertekend op', async () => {
306 actorOpties = [];
307 await fetch(`http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/u/oma')}`,
308 { signal: AbortSignal.timeout(10000) });
309 assert.equal(actorOpties[0] && actorOpties[0].asSlug, 'robo');
310});
311
312test('niet ingelogd gaat er geen enkel verzoek uit, dus ook geen ondertekend', async () => {
313 ingelogd = false;
314 try {
315 actorOpties = [];
316 await zoek('https://elders.test/u/oma');
317 await preview('https://elders.test/u/oma');
318 assert.deepEqual(gevraagd, [], 'geen post- en geen profielopvraging');
319 assert.deepEqual(actorOpties, [], 'en dus niets dat de sleutel van de site gebruikt');
320 } finally {
321 ingelogd = true;
322 }
323});
324
Note: See TracBrowser for help on using the repository browser.