source: Klonkt/test/embed-resolver.test.js@ b1512e0

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

De kop-lezer stopte op een og:image in een script, net voor de echte tag

Op mijn machine werkte YouTube, op de VPS niet. Verschil: die krijgt een andere
variant van de pagina (lang=de-DE), en daarin staat de string og:image eerst in
inline JavaScript. Mijn vroege stop trapte daarin en kapte de pagina af op 661kB,
net voor het echte meta-blok. Resultaat: geen oEmbed-link, geen og-tags, geen
kaart.

Dat is dezelfde near-miss als de body-cap eerder, met een andere oorzaak: ik
stopte met lezen op het moment dat het ER uitzag alsof ik klaar was. De stop
vraagt nu om een echte <meta ...og:image en niet om de kale string.

Changed files:
src/services/EmbedResolver.js

  • vroege stop vereist een meta-tag, geen losse string

src/services/ActivityPubService.js

  • SELFHEAL_VERSION 18 -> 19

test/embed-resolver.test.js

  • regressietest met een lokkertje in een script voor de echte tag

remarks: 215 tests groen.

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

  • Property mode set to 100644
File size: 10.1 KB
RevLine 
[6bc2e31b]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
[c52dc82]54// Regression: a hardcoded provider list used to short-circuit here and return a
55// card with no title and no thumbnail, so a YouTube link ended up storing
56// nothing at all. There is no provider list any more; every non-fediverse URL
57// takes the generic path, which is exactly what gives it a thumbnail.
58test('a video host is not special-cased and still gets a real card', async () => {
[6bc2e31b]59 const r = await resolveEmbed('https://youtu.be/abcdefghijk', io({
[c52dc82]60 provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }), // ignored on purpose
[6bc2e31b]61 getPage: async () => OEMBED_PAGE,
62 getJSON: async () => OEMBED_JSON,
63 }));
[c52dc82]64 assert.equal(r.kind, 'oembed');
65 assert.equal(r.title, 'A talk');
66 assert.ok(r.media[0].url, 'and it has a thumbnail, which the old path never produced');
[6bc2e31b]67});
68
69test('anything else goes through oEmbed discovery', async () => {
70 const r = await resolveEmbed('https://v.example/watch/1', io({
71 getPage: async () => OEMBED_PAGE,
72 getJSON: async () => OEMBED_JSON,
73 }));
74 assert.equal(r.kind, 'oembed');
75 assert.equal(r.title, 'A talk');
76 assert.equal(r.author.name, 'Ada');
77 assert.equal(r.provider, 'Vid');
78 assert.equal(r.media[0].url, 'https://v.example/t.jpg');
79 assert.ok(r.html.startsWith('<iframe'));
80});
81
82test('no oEmbed link, or a dead endpoint, still yields a usable link card', async () => {
83 const noLink = await resolveEmbed('https://plain.example/p', io({ getPage: async () => '<html></html>' }));
84 assert.equal(noLink.kind, 'link');
85 const deadEndpoint = await resolveEmbed('https://v.example/p', io({
86 getPage: async () => OEMBED_PAGE, getJSON: async () => null,
87 }));
88 assert.equal(deadEndpoint.kind, 'link');
89});
90
91test('a failing AP fetch does not abort the pipeline', async () => {
92 const r = await resolveEmbed('https://v.example/p', io({
93 getAP: async () => { throw new Error('boom'); },
94 getPage: async () => OEMBED_PAGE,
95 getJSON: async () => OEMBED_JSON,
96 }));
97 assert.equal(r.kind, 'oembed');
98});
99
100test('non-http input is refused', async () => {
101 assert.equal(await resolveEmbed('javascript:alert(1)', io()), null);
102 assert.equal(await resolveEmbed('', io()), null);
103});
104
105test('fromOEmbed keeps only an http(s) canonical url', () => {
106 const c = fromOEmbed('https://a/b', { url: 'javascript:alert(1)', title: 'x' });
107 assert.equal(c.url, 'https://a/b');
108});
109
110// The live binding: the ordering above stays, but every fetch is capped and
111// goes through safeFetch, so a URL in a post cannot make us probe internals.
112test('liveIO caps oversized bodies and never throws on a bad fetch', async () => {
113 const { liveIO } = await import('../src/services/EmbedResolver.js');
114 const calls = [];
115 const fakeFetch = async (u, o) => {
116 calls.push([u, o.headers.Accept]);
117 if (u.includes('huge')) return { ok: true, headers: { get: () => String(9_000_000) }, text: async () => 'x' };
118 if (u.includes('boom')) throw new Error('refused');
119 return { ok: true, headers: { get: () => '10' }, text: async () => '{"type":"Note","id":"https://s/1"}' };
120 };
121 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
[c52dc82]122 // A JSON payload must parse whole, so an oversized one is refused outright.
123 assert.equal(await io.getJSON('https://x/huge'), null, 'oversized JSON refused');
[6bc2e31b]124 assert.equal(await io.getAP('https://x/boom'), null, 'a refused fetch is not an error');
125 assert.deepEqual(await io.getAP('https://x/ok'), { type: 'Note', id: 'https://s/1' });
126 assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json');
127});
[0101d0a]128
129// OpenGraph: the one that actually carries link previews on the open web.
130// oEmbed is richer, but most sites simply do not ship it, which is why cards
131// stayed empty until this fallback existed.
132const OG_PAGE = '<html><head><meta property="og:title" content="Linux f&amp;uuml;r Einsteiger">'
133 + '<meta property="og:site_name" content="Linux Guides">'
134 + '<meta property="og:image" content="https://lg.example/tux.png"></head></html>';
135
136test('findOpenGraph reads og:image/title/site and decodes entities', async () => {
137 const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
138 const og = findOpenGraph(OG_PAGE);
139 assert.equal(og.image, 'https://lg.example/tux.png');
140 assert.equal(og.site, 'Linux Guides');
141 assert.ok(og.title.startsWith('Linux f'));
142 assert.equal(findOpenGraph('<html><head><title>x</title></head></html>'), null);
143 assert.equal(findOpenGraph(null), null);
144});
145
146test('findOpenGraph falls back to twitter:image and refuses a non-http image', async () => {
147 const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
148 const tw = findOpenGraph('<meta name="twitter:image" content="https://x/y.png"><meta name="twitter:title" content="T">');
149 assert.equal(tw.image, 'https://x/y.png');
150 const bad = findOpenGraph('<meta property="og:image" content="javascript:alert(1)"><meta property="og:title" content="T">');
151 assert.equal(bad.image, null, 'a non-http image is dropped, the title survives');
152 assert.equal(bad.title, 'T');
153});
154
155test('oEmbed still wins over OpenGraph when a page offers both', async () => {
156 const both = OEMBED_PAGE.replace('</head>', '<meta property="og:title" content="OG"></head>');
157 const r = await resolveEmbed('https://v.example/1', io({
158 getPage: async () => both, getJSON: async () => OEMBED_JSON,
159 }));
160 assert.equal(r.kind, 'oembed');
161 assert.equal(r.title, 'A talk');
162});
163
164test('a page with only OpenGraph yields a thumbnail card', async () => {
165 const r = await resolveEmbed('https://lg.example/artikel', io({ getPage: async () => OG_PAGE }));
166 assert.equal(r.kind, 'opengraph');
167 assert.equal(r.media[0].url, 'https://lg.example/tux.png');
168 assert.equal(r.provider, 'Linux Guides');
169 assert.equal(r.url, 'https://lg.example/artikel');
170});
[c52dc82]171
172// Regression, the one that kept YouTube blank: a page is read from the START and
173// cut off, never refused for being large. Refusing it meant no thumbnail at all
174// for exactly the sites people share most.
175test('a huge page is truncated, not rejected', async () => {
176 const { liveIO } = await import('../src/services/EmbedResolver.js');
177 const big = '<html><head>' + 'x'.repeat(5000) + '<meta property="og:title" content="T">'
178 + '<meta property="og:image" content="https://x/i.png"></head></html>';
179 const fakeFetch = async () => ({ ok: true, headers: { get: () => String(9_000_000) }, text: async () => big });
180 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
181 const page = await io.getPage('https://x/huge');
182 assert.ok(page && page.includes('og:image'), 'the head survives the cap');
183});
[b1512e0]184
185// Regression: big sites carry the bare string "og:image" inside inline JSON long
186// before the real meta tag. Stopping the read there cut the page off just short
187// of the tags and produced no card at all.
188test('the head read does not stop on an og:image mention inside a script', async () => {
189 const { liveIO } = await import('../src/services/EmbedResolver.js');
190 const page = '<html><head><script>var cfg={"og:image":"decoy"};</script>'
191 + 'y'.repeat(3000)
192 + '<meta property="og:title" content="Echt"><meta property="og:image" content="https://x/real.png">'
193 + '</head></html>';
194 const fakeFetch = async () => ({ ok: true, headers: { get: () => '999' }, text: async () => page });
195 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
196 const got = await io.getPage('https://x/p');
197 const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
198 assert.equal(findOpenGraph(got).image, 'https://x/real.png', 'reads past the decoy to the real tag');
199});
Note: See TracBrowser for help on using the repository browser.