source: Klonkt/test/search-fediverse.test.js@ 3ceaef6

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

Zoeken op een adres: het object erachter, met de knoppen erbij

Plak je een link naar een fediverse-post of -profiel in de zoekbalk, dan is dat
geen zoekterm maar een aanwijzing. Klonkt haalt hem op en zet hem bovenaan de
resultaten, met dezelfde knoppen als /authorize_interaction: antwoorden,
waarderen, boosten, stemmen, volgen en melden.

De weg erheen was vooral opruimen. De render en de formulieren stonden als 200
regels in authorize-interaction.ejs, en er een tweede kopie van maken voor de
zoekpagina zou een tweede plek zijn waar een poort, een sanitizer of een knop
vergeten kan worden. Ze staan nu in partials/remote-object.ejs en
partials/remote-actor.ejs, met de opmaak in een derde. De pagina die ze had
gebruikt ze nu ook, dus er is één render en de formulieren posten in beide
gevallen naar /authorize_interaction/..., waar het werk en de rechtencontrole
al zaten.

DRIE DINGEN DIE VASTLIGGEN, en elk om een eigen reden:

Het schema is verplicht. "soundfabrics.nl" is een geldige zoekterm -- iemand
zoekt naar de naam -- en een kale domeinnaam als adres opvatten maakt van elke
zoekopdracht met een punt erin een netwerkverzoek. Met ​https:// ervoor is er
geen twijfel over de bedoeling.

Ophalen mag alleen een beheerder van de site laten doen. Dat is dezelfde grens
als /authorize_interaction, en met opzet: ophalen is een uitgaand verzoek dat
deze server namens de bezoeker doet. Zonder die grens is de zoekbalk een
haalservice waarmee een vreemde deze server laat aankloppen op adressen naar
keuze. Een bezoeker die toch een link plakt krijgt de gewone zoekresultaten en
een zin die zegt waarom er verder niets staat.

De ophaling gaat ONDERTEKEND als de site. Een post die alleen voor volgers
zichtbaar is weigert een anonieme GET, en dan lijkt een bestaande post te
ontbreken -- dezelfde val als bij het antwoorden op een post van een vriend.

Bijgekomen zonder dat erom gevraagd is, en het is het vermelden waard: het
profiel toont nu zijn avatar. Die hadden we al in handen (actor_icon uit
resolveRemoteActor) en hij stond op geen van beide schermen.

Getoetst: de herkenning zelf, dat een zoekterm GEEN verzoek wordt, dat de post
met zijn vier formulieren binnenkomt, dat een profiel de volgknop geeft, dat
een bezoeker niets laat ophalen, en dat /authorize_interaction na de verhuizing
nog steeds hetzelfde toont -- die pagina had zelf geen enkele toets, dus dat
was de bodem die er onder moest. Tegenproef per zaak.

Nog open: een @naam@server in de zoekbalk (webfinger kan het al, dus klein
werk) en een hint in de live-suggesties terwijl je plakt.

shaer-utpi

  • Property mode set to 100644
File size: 8.9 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 = [];
39AP.resolveRemoteNote = async (uri) => {
40 gevraagd.push(['note', uri]);
41 if (!uri.includes('/notes/')) return null;
42 return {
43 object_uri: uri, url: uri,
44 actor_uri: 'https://elders.test/u/oma', actor_url: 'https://elders.test/@oma',
45 actor_handle: '@oma@elders.test', actor_name: 'Oma', actor_icon: '',
46 content: '<p>Een bericht van ver weg</p>', images: [], media: [], poll: null,
47 preview: 'Een bericht van ver weg', threadInboxes: [], localPostId: '', sensitive: false, cw: '',
48 };
49};
50AP.resolveRemoteActor = async (uri) => {
51 gevraagd.push(['actor', uri]);
52 if (!uri.includes('/u/')) return null;
53 return {
54 actor_uri: uri, actor_name: 'Oma', actor_handle: '@oma@elders.test',
55 actor_url: uri, actor_icon: 'https://elders.test/avatar.png', inbox: uri + '/inbox',
56 };
57};
58
59const express = (await import('express')).default;
60let ingelogd = true;
61const app = express();
62app.use((req, res, next) => {
63 res.locals.site = site();
64 res.locals.siteUrlBase = '';
65 req.session = ingelogd ? { user: { id: 'u1', role: 'god', username: 'baas' } } : {};
66 next();
67});
68app.use('/search', router);
69const server = app.listen(0);
70server.unref();
71const poort = server.address().port;
72test.after(() => server.close());
73
74const zoek = async (q) => {
75 gevraagd = [];
76 const r = await fetch(`http://127.0.0.1:${poort}/search?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });
77 return { status: r.status, html: await r.text() };
78};
79
80test('een adres wordt herkend, een zoekterm niet', () => {
81 assert.equal(lookupUri('https://elders.test/notes/1'), 'https://elders.test/notes/1');
82 assert.equal(lookupUri(' https://elders.test/notes/1 '), 'https://elders.test/notes/1');
83 // De fragmentverwijzing hoort bij de browser, niet bij het object.
84 assert.equal(lookupUri('https://elders.test/notes/1#comment-2'), 'https://elders.test/notes/1');
85 // Geen schema is geen adres: anders wordt zoeken naar een naam een verzoek.
86 assert.equal(lookupUri('soundfabrics.nl'), null);
87 assert.equal(lookupUri('www.soundfabrics.nl/muziek'), null);
88 assert.equal(lookupUri('soundfabrics'), null);
89 // Twee woorden waarvan er een een adres is, is een zoekopdracht.
90 assert.equal(lookupUri('https://elders.test/notes/1 oma'), null);
91 // Andere schema's halen we niet op.
92 assert.equal(lookupUri('javascript:alert(1)'), null);
93 assert.equal(lookupUri('file:///etc/passwd'), null);
94 // Een naam zonder punt is geen adres op het open net.
95 assert.equal(lookupUri('http://localhost:3000/notes/1'), null);
96});
97
98test('een gewone zoekopdracht haalt niets op', async () => {
99 const { status, html } = await zoek('muziek');
100 assert.equal(status, 200);
101 assert.deepEqual(gevraagd, [], 'een zoekterm hoort geen netwerkverzoek te worden');
102 assert.ok(!html.includes('search-remote'), 'en er hoort geen fediverse-blok te staan');
103});
104
105test('een post van een andere server komt met zijn knoppen binnen', async () => {
106 const { html } = await zoek('https://elders.test/notes/1');
107 assert.deepEqual(gevraagd, [['note', 'https://elders.test/notes/1']]);
108 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
109 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
110 // De functies van authorize_interaction, op de zoekpagina.
111 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
112 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt`);
113 }
114 assert.ok(html.includes('action="/authorize_interaction"'), 'het antwoordvenster ontbreekt');
115});
116
117test('een profiel levert de volgknop', async () => {
118 const { html } = await zoek('https://elders.test/u/oma');
119 assert.deepEqual(gevraagd.map((g) => g[0]), ['note', 'actor'], 'eerst post, dan profiel');
120 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
121 assert.ok(html.includes('https://elders.test/u/oma'), 'het profiel hoort in het formulier te staan');
122});
123
124test('een adres dat niets oplevert laat de rest van de zoekpagina staan', async () => {
125 const { status, html } = await zoek('https://elders.test/iets-anders');
126 assert.equal(status, 200);
127 assert.equal(gevraagd.length, 2, 'beide vormen zijn geprobeerd');
128 assert.ok(html.includes('search-remote'), 'het blok staat er, met de melding erin');
129 assert.ok(html.includes('search-page-form'), 'en de zoekpagina zelf is niet weggevallen');
130});
131
132test('een bezoeker laat deze server niets ophalen', async () => {
133 ingelogd = false;
134 try {
135 const { status, html } = await zoek('https://elders.test/notes/1');
136 assert.equal(status, 200);
137 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan');
138 assert.ok(!html.includes('/authorize_interaction/like'), 'en er staan geen knoppen');
139 } finally {
140 ingelogd = true;
141 }
142});
143
144// ── Dezelfde render, de andere ingang ───────────────────────────────────────
145//
146// De zoekpagina en /authorize_interaction delen sinds deze ronde één partial.
147// Die pagina had zelf geen enkele toets, dus een verhuizing van 200 regels eruit
148// zou stil kunnen mislukken: de suite bleef groen en het scherm was leeg. Deze
149// toets is de bodem daaronder.
150const postsRouter = (await import('../src/routes/posts.js')).default;
151const app2 = express();
152app2.use((req, res, next) => {
153 res.locals.site = site();
154 res.locals.siteUrlBase = '';
155 req.session = { user: { id: 'u1', role: 'god', username: 'baas' } };
156 next();
157});
158app2.use('/', postsRouter);
159const server2 = app2.listen(0);
160server2.unref();
161const poort2 = server2.address().port;
162test.after(() => server2.close());
163
164test('de interactiepagina toont hetzelfde als de zoekpagina', async () => {
165 const r = await fetch(
166 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/notes/1')}`,
167 { signal: AbortSignal.timeout(10000) },
168 );
169 assert.equal(r.status, 200);
170 const html = await r.text();
171 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan');
172 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is');
173 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) {
174 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt op de interactiepagina`);
175 }
176 // En de opmaak reist mee: die staat sinds deze ronde in een eigen partial,
177 // en een render zonder opmaak ziet er kapot uit zonder dat er iets faalt.
178 assert.ok(html.includes('.auth-interact-preview'), 'de opmaak van het blok ontbreekt');
179});
180
181test('een profiel op de interactiepagina levert dezelfde volgknop', async () => {
182 const r = await fetch(
183 `http://127.0.0.1:${poort2}/authorize_interaction?uri=${encodeURIComponent('https://elders.test/u/oma')}`,
184 { signal: AbortSignal.timeout(10000) },
185 );
186 const html = await r.text();
187 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'de volgknop ontbreekt');
188 assert.ok(html.includes('@oma@elders.test'));
189});
Note: See TracBrowser for help on using the repository browser.