Changeset 61d52ed in Klonkt


Ignore:
Timestamp:
10/14/2026 04:25:21 PM (5 hours ago)
Author:
Bart <bart@…>
Branches:
main
Parents:
6e9bc70
git-author:
Bart <bart@…> (10/14/2026 04:23:29 PM)
git-committer:
Bart <bart@…> (10/14/2026 04:25:21 PM)
Message:

Eén note ophalen, voor wie een gesprek naar boven volgt

De thread-route (shaer-tqz) geeft wat ONDER een post hangt. Een lezer die een
antwoord kreeg en de ouder niet, kon die ouder bij een server in secure mode
niet halen: daar telt alleen een ondertekend verzoek, en de sleutel woont hier.
R9999 haalt ouders voortaan op zodra een antwoord binnenkomt, en heeft daar
deze route voor nodig -- zonder anonieme terugval, om dezelfde reden als bij de
thread.

GET /ap/users/:slug/object?uri= -- bearer-only en niets bewaren, dezelfde
weigering-of-storing (404/502) en dezelfde poorten als de thread. Een
geblokkeerde auteur is er niet. Voor een ward met een dichte kring is een note
van buiten de kring een 404 'outside the circle', met shaer:hidden erbij in
plaats van verzwegen.

inReplyTo alleen als de bron hem gaf. De thread vult een ontbrekende inReplyTo
aan met de post waaronder het antwoord in de replies-collectie stond, en daar is
dat bewijs. Voor een losse note is er geen bewijs, en een verzonnen ouder is
erger dan geen.

De vorm van één note (readerNote) en de poorten (readerGates) zijn uit de
thread getild, zodat de twee niet uit elkaar kunnen lopen. De thread-route
gedraagt zich hetzelfde.

Tests: getObject (de ouder van de bron, geen verzonnen ouder voor een wortel,
blokkade, weigering) en de route (zonder bearer, andermans bearer, alleen
https, weigering is 404).

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

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r6e9bc70 r61d52ed  
    14111411});
    14121412
     1413// De poorten op één vreemde note, voor de thread en voor de losse note:
     1414// beeld, muziek, video en emoji, per verzoek en buiten elke cache om. De
     1415// emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
     1416// ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
     1417function readerGates(site, isWard) {
     1418  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
     1419  const images = gate('gate_images'), audio = gate('gate_music'), video = gate('gate_video');
     1420  const emoji = gate('gate_custom_emoji');
     1421  return (n) => ({
     1422    ...n,
     1423    attachment: AP.gateAttachments(n.attachment, { images, audio, video }),
     1424    tag: emoji ? n.tag : AP.stripEmojiTags(n.tag),
     1425    attributedTo: (!emoji && n.attributedTo && typeof n.attributedTo === 'object')
     1426      ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
     1427  });
     1428}
     1429
    14131430// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
    14141431//
    … …  
    14461463  // poorten als de tijdlijn -- per verzoek, buiten de threadcache om.
    14471464  const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
    1448   const gate2 = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);
    14491465  const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes);
    1450   const imagesOk = gate2('gate_images'), musicOk = gate2('gate_music'), emojiOk = gate2('gate_custom_emoji');
    1451   const videoOk = gate2('gate_video');
    1452   uit.notes = kring.notes.map((n) => ({
    1453     ...n,
    1454     attachment: AP.gateAttachments(n.attachment, { images: imagesOk, audio: musicOk, video: videoOk }),
    1455     tag: emojiOk ? n.tag : AP.stripEmojiTags(n.tag),
    1456     // De emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
    1457     // ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
    1458     attributedTo: (!emojiOk && n.attributedTo && typeof n.attributedTo === 'object')
    1459       ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
    1460   }));
     1466  uit.notes = kring.notes.map(readerGates(auth.site, isWard));
    14611467  uit.hidden = kring.hidden;
    14621468  // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes
    … …  
    14811487});
    14821488
     1489// ── Eén note, voor wie een gesprek naar boven volgt ────────────────
     1490//
     1491// De thread hierboven geeft wat onder een post hangt; dit geeft de post zelf.
     1492// Een lezer die een antwoord kreeg en de ouder niet, kan die ouder bij een
     1493// server in secure mode alleen ondertekend halen, en de sleutel staat hier.
     1494// Bearer-only en niets bewaren, net als de thread. Dezelfde weigering-of-
     1495// storing en dezelfde poorten; voor een ward met een dichte kring is een note
     1496// van buiten de kring er niet, en dat wordt gezegd in plaats van verzwegen.
     1497router.get('/ap/users/:slug/object', async (req, res) => {
     1498  const auth = OAuth.verifyBearer(req.headers.authorization);
     1499  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     1500  const objectUri = String(req.query.uri || '');
     1501  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'uri must be an https URI' });
     1502  const uit = await AP.getObject(auth.site.slug, objectUri);
     1503  if (!uit.found) {
     1504    const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
     1505    return res.status(geweigerd ? 404 : 502)
     1506      .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
     1507  }
     1508  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
     1509  const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
     1510  if (!threadsOpen && !AP.filterThreadToCircle(auth.site.slug, [uit.note]).notes.length) {
     1511    return res.status(404).json({ error: 'outside the circle', 'shaer:hidden': 1 });
     1512  }
     1513  AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...readerGates(auth.site, isWard)(uit.note) }, 'private, no-store');
     1514});
     1515
    14831516router.get('/ap/notes/:id/replies', (req, res) => {
    14841517  const base = baseUrl(req);
  • src/services/ActivityPubService.js

    r6e9bc70 r61d52ed  
    24962496
    24972497/**
    2498  * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
    2499  *
    2500  * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
    2501  * kringfilter (de dichte stand van shaer:externalThreads) woont in
    2502  * filterThreadToCircle en de beeld/muziek/emoji-poorten in
    2503  * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
    2504  * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
    2505  * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
    2506  * een blokkade is onzichtbaar, ook als getal.
     2498 * Eén vreemde note in de vorm die de C2S-lezer krijgt: geschoonde HTML, de
     2499 * byline ingesloten in attributedTo, alleen emoji-tags. Gedeeld door de thread
     2500 * (antwoorden) en getObject (één note), zodat die twee niet uit elkaar lopen.
     2501 *
     2502 * `parentFallback` is voor de thread: een antwoord in de replies-collectie van
     2503 * X dat zelf geen inReplyTo noemt, hangt aantoonbaar onder X. Voor één losse
     2504 * note is er zo'n bewijs niet, en dan blijft inReplyTo leeg.
    25072505 */
    2508 export async function getThread(slug, objectUri) {
    2509   const key = `${slug}|${objectUri}`;
    2510   const hit = threadViewCache.get(key);
    2511   if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
    2512 
    2513   // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
    2514   // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
    2515   // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
    2516   // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
    2517   let sourceStatus = 0;
    2518   const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
    2519   const note = await get(objectUri);
    2520   const repliesRef = note && note.replies;
    2521   let coll = null;
    2522   if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
    2523   else if (repliesRef && typeof repliesRef === 'object') {
    2524     coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
    2525       : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
    2526   }
    2527   // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
    2528   // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
    2529   // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
    2530   // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
    2531   // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
    2532   // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
    2533   // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
    2534   let items = [];
    2535   let node = coll;
    2536   if (node && node.first && !collectionItems(node).length) {
    2537     node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
    2538   }
    2539   let pages = 0;
    2540   while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
    2541     items.push(...collectionItems(node));
    2542     if (!node.next) break;
    2543     node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
    2544   }
    2545   items = items.slice(0, THREAD_VIEW_LIMIT);
    2546 
    2547   // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
    2548   // afwachten is een halve minuut kijken naar een spinner.
    2549   const objs = await Promise.all(items.map(async (it) => {
    2550     const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
    2551     return (o && o.id && o.attributedTo) ? o : null;
    2552   }));
    2553 
    2554   const kept = [];
    2555   for (const o of objs) {
    2556     if (!o) continue;
    2557     const actorUri = actorUriOf(o.attributedTo);
    2558     if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
    2559     kept.push({ o, actorUri });
    2560   }
    2561 
    2562   // Bylines: één fetch per unieke auteur, niet één per antwoord.
    2563   const authors = new Map();
    2564   await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
    2565     authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
    2566   }));
    2567 
    2568   const notes = kept.map(({ o, actorUri }) => ({
     2506function readerNote(o, actorUri, authorDoc, parentFallback = null) {
     2507  return {
    25692508    id: o.id,
    25702509    type: 'Note',
    25712510    // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
    25722511    // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
    2573     attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
    2574     inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
     2512    attributedTo: actorObject(actorUri, actorInfo(authorDoc, actorUri)),
     2513    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || parentFallback || undefined,
    25752514    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
    25762515    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
    … …  
    25972536      return out.length ? out : undefined;
    25982537    })(),
    2599   })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
     2538  };
     2539}
     2540
     2541/**
     2542 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
     2543 *
     2544 * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
     2545 * kringfilter (de dichte stand van shaer:externalThreads) woont in
     2546 * filterThreadToCircle en de beeld/muziek/emoji-poorten in
     2547 * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
     2548 * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
     2549 * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
     2550 * een blokkade is onzichtbaar, ook als getal.
     2551 */
     2552export async function getThread(slug, objectUri) {
     2553  const key = `${slug}|${objectUri}`;
     2554  const hit = threadViewCache.get(key);
     2555  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
     2556
     2557  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
     2558  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
     2559  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
     2560  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
     2561  let sourceStatus = 0;
     2562  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
     2563  const note = await get(objectUri);
     2564  const repliesRef = note && note.replies;
     2565  let coll = null;
     2566  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
     2567  else if (repliesRef && typeof repliesRef === 'object') {
     2568    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
     2569      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
     2570  }
     2571  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
     2572  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
     2573  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
     2574  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
     2575  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
     2576  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
     2577  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
     2578  let items = [];
     2579  let node = coll;
     2580  if (node && node.first && !collectionItems(node).length) {
     2581    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
     2582  }
     2583  let pages = 0;
     2584  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
     2585    items.push(...collectionItems(node));
     2586    if (!node.next) break;
     2587    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
     2588  }
     2589  items = items.slice(0, THREAD_VIEW_LIMIT);
     2590
     2591  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
     2592  // afwachten is een halve minuut kijken naar een spinner.
     2593  const objs = await Promise.all(items.map(async (it) => {
     2594    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
     2595    return (o && o.id && o.attributedTo) ? o : null;
     2596  }));
     2597
     2598  const kept = [];
     2599  for (const o of objs) {
     2600    if (!o) continue;
     2601    const actorUri = actorUriOf(o.attributedTo);
     2602    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
     2603    kept.push({ o, actorUri });
     2604  }
     2605
     2606  // Bylines: één fetch per unieke auteur, niet één per antwoord.
     2607  const authors = new Map();
     2608  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
     2609    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
     2610  }));
     2611
     2612  const notes = kept.map(({ o, actorUri }) => readerNote(o, actorUri, authors.get(actorUri), objectUri))
     2613    .sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
    26002614
    26012615  const out = { notes, found: !!note, sourceStatus };
    … …  
    26062620  }
    26072621  return out;
     2622}
     2623
     2624/**
     2625 * Eén note, ondertekend opgehaald, in dezelfde vorm als een thread-antwoord.
     2626 *
     2627 * De thread hierboven geeft alleen wat ONDER een post hangt. Wie een gesprek
     2628 * naar boven wil volgen -- een antwoord kwam binnen, de ouder nooit -- kan die
     2629 * ouder zonder de sleutel die hier woont niet halen bij een server in secure
     2630 * mode. Niets wordt bewaard, net als bij de thread.
     2631 *
     2632 * inReplyTo staat er alleen als de bron hem gaf: een verzonnen ouder is erger
     2633 * dan geen. Een geblokkeerde auteur maakt de note onvindbaar, met dezelfde
     2634 * status als een bron die nee zegt -- een blokkade hoort onzichtbaar te zijn.
     2635 */
     2636export async function getObject(slug, objectUri) {
     2637  let sourceStatus = 0;
     2638  const o = localNoteObject(objectUri, slug)
     2639    || await signedGetJson(slug, objectUri, (st) => { sourceStatus = st; });
     2640  if (!o || !o.id || !o.attributedTo) return { note: null, found: false, sourceStatus };
     2641  const actorUri = actorUriOf(o.attributedTo);
     2642  if (!actorUri || isBlockedAny(actorUri)) return { note: null, found: false, sourceStatus: 404 };
     2643  const author = localActorObject(actorUri) || await signedGetJson(slug, actorUri).catch(() => null);
     2644  return { note: readerNote(o, actorUri, author), found: true, sourceStatus };
    26082645}
    26092646
    … …  
    43464383  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    43474384  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    4348   sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
     4385  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, getObject, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    43494386  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    43504387  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
  • test/thread.test.js

    r6e9bc70 r61d52ed  
    240240  assert.equal(broken.body.error, 'source unreachable');
    241241});
     242
     243// ── Eén note, naar boven (de object-route) ───────────────────────────
     244//
     245// De thread geeft alleen wat onder een post hangt. Een lezer die een antwoord
     246// kreeg en de ouder niet, vraagt de ouder hier. Wat hier stil kan breken: een
     247// ouder die verzonnen wordt (de thread-fallback die meelekt), een geblokkeerde
     248// die toch zichtbaar wordt, en een route zonder bearer-check.
     249test('getObject geeft de note zelf, met alleen de ouder die de bron noemde', async (t) => {
     250  const vorige = globalThis.fetch;
     251  t.after(() => { globalThis.fetch = vorige; });
     252  const KIND_REPLY = 'https://203.0.113.60/notes/antwoord';
     253  const WORTEL = 'https://203.0.113.60/notes/wortel';
     254  globalThis.fetch = async (url) => {
     255    const u = String(url);
     256    const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
     257    if (u === KIND_REPLY) return json({ id: KIND_REPLY, type: 'Note', attributedTo: TANTE, inReplyTo: WORTEL, content: '<p>ja <script>x</script></p>', published: '2026-09-07T13:42:26Z' });
     258    if (u === WORTEL) return json({ id: WORTEL, type: 'Note', attributedTo: TANTE, content: '<p>begin</p>' });
     259    if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
     260    return new Response('not found', { status: 404 });
     261  };
     262
     263  const antwoord = await AP.getObject('kind', KIND_REPLY);
     264  assert.equal(antwoord.found, true);
     265  assert.equal(antwoord.note.inReplyTo, WORTEL, 'de ouder die de bron gaf');
     266  assert.equal(antwoord.note.attributedTo.name, 'tante');
     267  assert.ok(!antwoord.note.content.includes('<script'), 'geschoond');
     268
     269  const wortel = await AP.getObject('kind', WORTEL);
     270  assert.equal(wortel.found, true);
     271  assert.equal(wortel.note.inReplyTo, undefined, 'geen ouder verzonnen voor een wortel');
     272});
     273
     274test('getObject: een geblokkeerde auteur is er niet, een weigering is een weigering', async (t) => {
     275  const vorige = globalThis.fetch;
     276  t.after(() => { globalThis.fetch = vorige; });
     277  const VAN_NAAR = 'https://203.0.113.40/notes/van-naar';
     278  globalThis.fetch = async (url) => (String(url) === VAN_NAAR
     279    ? new Response(JSON.stringify({ id: VAN_NAAR, type: 'Note', attributedTo: GEBLOKT, content: 'x' }), { status: 200 })
     280    : new Response('nope', { status: 410 }));
     281
     282  const geblokt = await AP.getObject('kind', VAN_NAAR);
     283  assert.equal(geblokt.found, false);
     284  assert.equal(geblokt.note, null);
     285
     286  const weg = await AP.getObject('kind', 'https://203.0.113.61/notes/weg');
     287  assert.equal(weg.found, false);
     288  assert.equal(weg.sourceStatus, 410);
     289});
     290
     291test('de object-route: alleen met de eigen bearer, en een weigering is 404', async (t) => {
     292  const crypto = await import('crypto');
     293  const express = (await import('express')).default;
     294  const routes = (await import('../src/routes/activitypub.js')).default;
     295
     296  // Een eigen site zonder guardians, zodat de poorten van de tests hierboven
     297  // (de ward met de dichte emoji-poort) hier niet meetellen.
     298  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s-lezer', 'lezer', 'Lezer', 'u1');
     299  const bearer = 'test-token-' + 'e'.repeat(24);
     300  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
     301    .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'lezer', 'read write');
     302
     303  const app = express();
     304  app.use(routes);
     305  const server = app.listen(0);
     306  t.after(() => server.close());
     307  await new Promise((r) => server.once('listening', r));
     308
     309  const vorige = globalThis.fetch;
     310  t.after(() => { globalThis.fetch = vorige; });
     311  const OUDER = 'https://203.0.113.62/notes/ouder';
     312  globalThis.fetch = async (url) => {
     313    const u = String(url);
     314    if (u === OUDER) return new Response(JSON.stringify({ id: OUDER, type: 'Note', attributedTo: VREEMDE, content: '<p>de ouder</p>', inReplyTo: BRON }), { status: 200 });
     315    if (u === VREEMDE) return new Response(JSON.stringify(actorDoc(VREEMDE, 'vreemde')), { status: 200 });
     316    return new Response('nope', { status: 404 });
     317  };
     318  const ask = async (slug, uri, auth = `Bearer ${bearer}`) => {
     319    const r = await realFetch(
     320      `http://127.0.0.1:${server.address().port}/ap/users/${slug}/object?uri=${encodeURIComponent(uri)}`,
     321      { headers: auth ? { Authorization: auth } : {} });
     322    return { status: r.status, body: r.status === 403 ? null : await r.json() };
     323  };
     324
     325  assert.equal((await ask('lezer', OUDER, null)).status, 403, 'zonder bearer niets');
     326  assert.equal((await ask('kind', OUDER)).status, 403, 'niet met de bearer van een ander');
     327  assert.equal((await ask('lezer', 'http://203.0.113.62/notes/ouder')).status, 400, 'alleen https');
     328
     329  const ok = await ask('lezer', OUDER);
     330  assert.equal(ok.status, 200);
     331  assert.equal(ok.body.id, OUDER);
     332  assert.equal(ok.body.inReplyTo, BRON);
     333  assert.equal(ok.body.attributedTo.name, 'vreemde');
     334
     335  const dicht = await ask('lezer', 'https://203.0.113.63/notes/dicht');
     336  assert.equal(dicht.status, 404);
     337  assert.equal(dicht.body.error, 'not shared by source');
     338});
Note: See TracChangeset for help on using the changeset viewer.