source: Klonkt/test/embed-resolver.test.js@ 6bc2e31b

main
Last change on this file since 6bc2e31b was 6bc2e31b, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Een embed-pijplijn: AP eerst volgens de FEP, dan provider, dan oEmbed

Robins besluit: shaer-ibs (oEmbed) en shaer-277 (fediverse-embeds) worden een
ding. Een lezer ziet straks dezelfde kaart, of het nu een geciteerde
fediverse-post is of een ingesloten video. Het verschil zit niet in wat je ziet
maar in wat er FEDEREERT, en dus in de volgorde van resolutie:

  1. ActivityPub-object -> het FEP-pad. Een quote van een fediverse-object draagt echte semantiek (FEP-044f quote + FEP-e232 Link-tag, de geciteerde auteur wordt geadresseerd, permissions gelden). Nooit via oEmbed, want daar zit niets van dat alles in.
  2. Bekende provider -> de bestaande speler (YouTube/Spotify/Bandcamp/...).
  3. oEmbed-discovery -> de generieke weg, en de voorkeur voor alles buiten de fediverse.
  4. Anders -> een kale link blijft een kale link.

De io is geinjecteerd, zodat de volgorde te testen is zonder netwerk. liveIO
bindt 'm aan de echte fetchers: alles via safeFetch (weigert private ranges,
begrenst redirects) en met een body-cap, zodat een vijandige URL in een post de
server niet aan het rondsnuffelen krijgt op interne hosts.

New file:
src/services/EmbedResolver.js

  • resolveEmbed met de vier stappen, alle vier naar dezelfde kaartvorm
  • findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject
  • liveIO: SSRF-veilige binding met body-cap

test/embed-resolver.test.js

  • 10 tests: volgorde (AP wint van provider en oEmbed, provider wint van oEmbed), oEmbed-discovery, degradatie naar link, en dat liveIO grote bodies weigert en niet gooit op een geweigerde fetch

remarks: 200 tests groen. Dit is de kern; nog aan te sluiten (fase 2, zie de
bead): de compose-kant (URL in een post -> kaart), het emitten van de quote
richting de fediverse met notificatie aan de auteur, en de render die de
bestaande quote-kaart hergebruikt voor alle vier de soorten.

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

  • Property mode set to 100644
File size: 5.6 KB
Line 
1// One embed pipeline (shaer-277): AP first (FEP semantics), then a known
2// provider, then oEmbed, then a plain link. All four end in the same shape.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5import {
6 resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed,
7} from '../src/services/EmbedResolver.js';
8
9const AP_NOTE = { id: 'https://s/objects/1', type: 'Note', content: '<p>hi</p>', attributedTo: 'https://s/users/alice', url: 'https://s/@alice/1' };
10const OEMBED_PAGE = '<html><head><link rel="alternate" type="application/json+oembed" href="https://v.example/oembed?url=x&amp;f=json"><title>t</title></head></html>';
11const OEMBED_JSON = { type: 'video', title: 'A talk', author_name: 'Ada', provider_name: 'Vid', html: '<iframe src="https://v.example/e/1"></iframe>', thumbnail_url: 'https://v.example/t.jpg' };
12
13const io = (over = {}) => ({
14 getAP: async () => null,
15 getPage: async () => null,
16 getJSON: async () => null,
17 actorOf: async () => ({ name: 'Alice', handle: '@alice@s', icon: null }),
18 provider: () => null,
19 ...over,
20});
21
22test('findOEmbedEndpoint reads the json+oembed link and decodes entities', () => {
23 assert.equal(findOEmbedEndpoint(OEMBED_PAGE), 'https://v.example/oembed?url=x&f=json');
24 assert.equal(findOEmbedEndpoint('<html><head></head></html>'), null);
25 // the XML flavour is not parsed
26 assert.equal(findOEmbedEndpoint('<link rel="alternate" type="text/xml+oembed" href="https://x/o">'), null);
27});
28
29test('looksLikeAPObject accepts quotable content, rejects actors and activities', () => {
30 assert.ok(looksLikeAPObject(AP_NOTE));
31 assert.ok(looksLikeAPObject({ id: 'https://s/v/1', type: 'Video' }));
32 assert.ok(!looksLikeAPObject({ id: 'https://s/users/a', type: 'Person' }));
33 assert.ok(!looksLikeAPObject({ id: 'https://s/a/1', type: 'Create' }));
34 assert.ok(!looksLikeAPObject({ type: 'Note' })); // no id
35 assert.ok(!looksLikeAPObject(null));
36});
37
38test('an ActivityPub object resolves over AP, never over oEmbed', async () => {
39 let pageFetched = false;
40 const r = await resolveEmbed('https://s/@alice/1', io({
41 getAP: async () => AP_NOTE,
42 getPage: async () => { pageFetched = true; return OEMBED_PAGE; },
43 getJSON: async () => OEMBED_JSON,
44 provider: () => ({ provider: 'youtube', id: 'x' }), // must not win either
45 }));
46 assert.equal(r.kind, 'ap');
47 assert.equal(r.id, 'https://s/objects/1');
48 assert.equal(r.attributedTo, 'https://s/users/alice');
49 assert.equal(r.author.handle, '@alice@s');
50 assert.equal(r.url, 'https://s/@alice/1');
51 assert.ok(!pageFetched, 'AP wins before any oEmbed discovery happens');
52});
53
54test('a known provider beats oEmbed but loses to AP', async () => {
55 const r = await resolveEmbed('https://youtu.be/abcdefghijk', io({
56 provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }),
57 getPage: async () => OEMBED_PAGE,
58 getJSON: async () => OEMBED_JSON,
59 }));
60 assert.equal(r.kind, 'provider');
61 assert.equal(r.provider, 'youtube');
62});
63
64test('anything else goes through oEmbed discovery', async () => {
65 const r = await resolveEmbed('https://v.example/watch/1', io({
66 getPage: async () => OEMBED_PAGE,
67 getJSON: async () => OEMBED_JSON,
68 }));
69 assert.equal(r.kind, 'oembed');
70 assert.equal(r.title, 'A talk');
71 assert.equal(r.author.name, 'Ada');
72 assert.equal(r.provider, 'Vid');
73 assert.equal(r.media[0].url, 'https://v.example/t.jpg');
74 assert.ok(r.html.startsWith('<iframe'));
75});
76
77test('no oEmbed link, or a dead endpoint, still yields a usable link card', async () => {
78 const noLink = await resolveEmbed('https://plain.example/p', io({ getPage: async () => '<html></html>' }));
79 assert.equal(noLink.kind, 'link');
80 const deadEndpoint = await resolveEmbed('https://v.example/p', io({
81 getPage: async () => OEMBED_PAGE, getJSON: async () => null,
82 }));
83 assert.equal(deadEndpoint.kind, 'link');
84});
85
86test('a failing AP fetch does not abort the pipeline', async () => {
87 const r = await resolveEmbed('https://v.example/p', io({
88 getAP: async () => { throw new Error('boom'); },
89 getPage: async () => OEMBED_PAGE,
90 getJSON: async () => OEMBED_JSON,
91 }));
92 assert.equal(r.kind, 'oembed');
93});
94
95test('non-http input is refused', async () => {
96 assert.equal(await resolveEmbed('javascript:alert(1)', io()), null);
97 assert.equal(await resolveEmbed('', io()), null);
98});
99
100test('fromOEmbed keeps only an http(s) canonical url', () => {
101 const c = fromOEmbed('https://a/b', { url: 'javascript:alert(1)', title: 'x' });
102 assert.equal(c.url, 'https://a/b');
103});
104
105// The live binding: the ordering above stays, but every fetch is capped and
106// goes through safeFetch, so a URL in a post cannot make us probe internals.
107test('liveIO caps oversized bodies and never throws on a bad fetch', async () => {
108 const { liveIO } = await import('../src/services/EmbedResolver.js');
109 const calls = [];
110 const fakeFetch = async (u, o) => {
111 calls.push([u, o.headers.Accept]);
112 if (u.includes('huge')) return { ok: true, headers: { get: () => String(9_000_000) }, text: async () => 'x' };
113 if (u.includes('boom')) throw new Error('refused');
114 return { ok: true, headers: { get: () => '10' }, text: async () => '{"type":"Note","id":"https://s/1"}' };
115 };
116 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
117 assert.equal(await io.getPage('https://x/huge'), null, 'oversized body refused');
118 assert.equal(await io.getAP('https://x/boom'), null, 'a refused fetch is not an error');
119 assert.deepEqual(await io.getAP('https://x/ok'), { type: 'Note', id: 'https://s/1' });
120 assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json');
121});
Note: See TracBrowser for help on using the repository browser.