source: Klonkt/test/external-embeds.test.js@ 7d7b36d

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

Fase 3: quotes federeren volgens de FEP, en de geciteerde auteur hoort het

De spiegel van wat we al aan de ingest-kant deden. Quoot je vanuit Klonkt een
fediverse-object, dan gaat dat nu ook echt de lijn over als quote, in de drie
vormen die het netwerk werkelijk leest: de FEP-044f quote-property, de de-facto
aliassen quoteUrl en _misskey_quote, en een FEP-e232 Link-tag. Alle drie wijzen
naar hetzelfde object, want elke lezer kijkt ergens anders.

En het belangrijkste: de geciteerde auteur komt in cc en zijn inbox in de
bezorglijst. Geciteerd worden zonder het te horen is precies de onbeleefdheid
die deze FEP probeert weg te ontwerpen.

De resolutie gebeurt een keer, bij publiceren (deliverCreate is toch al async),
en wordt opgeslagen op de post. buildNote blijft daardoor synchroon en hoeft
nooit te fetchen, ook niet als de outbox 'm opnieuw opbouwt.

Web-UI: een externe embed gebruikt nu dezelfde .tl-quote-kaart als een quote, dus
de belofte van een representatie klopt nu ook op het web.

Veiligheid: titel, provider en auteursnaam komen van een derde partij en worden
als PLATTE TEKST opgeslagen (tags eruit, lengte begrensd), zodat geen enkele
renderer verderop degene hoeft te zijn die aan escapen denkt. In de web-template
gaat de titel er bovendien nog een keer ge-escaped in, want de quote-kaart
rendert content als HTML (terecht voor AP-content, die we bij binnenkomst
saneren, maar niet voor dit).

Changed files:
src/config/database.js

  • posts.quote_uri en posts.quote_actor

src/services/ActivityPubService.js

  • applyQuoteProps: de drie quote-vormen + de auteur in cc
  • buildNote past ze toe; resolveOwnQuote resolvet een keer bij publiceren
  • deliverCreate bewaart het resultaat en bezorgt bij de geciteerde auteur
  • externe embed slaat titel/provider/auteur als platte tekst op

src/views/partials/tl-item.ejs

  • embed valt terug op dezelfde quote-kaart, met ge-escapete titel

test/external-embeds.test.js

  • 4 tests erbij: de drie vormen, de auteur in cc zonder de followers te verliezen, junk-invoer verandert niets, en er wordt nooit markup opgeslagen

remarks: 209 tests groen.

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

  • Property mode set to 100644
File size: 4.8 KB
RevLine 
[fc40410]1// External embeds are a gated feature (FEP-633c): a ward's world outside the
2// fediverse is the guardians' call, and the gate is applied server-side.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5process.env.DATABASE_PATH = ':memory:';
6process.env.PUBLIC_BASE_URL = 'https://test.example';
7const dbMod = await import('../src/config/database.js');
8dbMod.initializeDatabase();
9const { externalEmbedsAllowed } = await import('../src/services/guardianship/notes.js');
10const { firstExternalUrl, timelineEmbed } = await import('../src/services/ActivityPubService.js');
11
12test('auto (no setting): off for a ward, on for anyone else', () => {
13 assert.equal(externalEmbedsAllowed(null, true), false, 'a ward gets no external embeds by default');
14 assert.equal(externalEmbedsAllowed(null, false), true, 'a free actor does');
15 assert.equal(externalEmbedsAllowed(undefined, true), false);
16});
17
18test('an explicit guardian decision wins over the default, both ways', () => {
19 assert.equal(externalEmbedsAllowed(1, true), true, 'guardians may open it for a ward');
20 assert.equal(externalEmbedsAllowed(0, false), false, 'and may close it for anyone');
21});
22
23test('firstExternalUrl picks the first real link, skipping mentions and hashtags', () => {
24 const html = '<p><a href="https://s/@bob" class="u-url mention">@bob</a> '
25 + '<a href="https://s/tags/x" class="mention hashtag">#x</a> '
26 + 'kijk: <a href="https://v.example/watch/1">dit</a> en <a href="https://later.example/">dat</a></p>';
27 assert.equal(firstExternalUrl(html), 'https://v.example/watch/1');
28});
29
30test('firstExternalUrl ignores non-http hrefs and empty content', () => {
31 assert.equal(firstExternalUrl('<a href="javascript:alert(1)">x</a>'), null);
32 assert.equal(firstExternalUrl('<p>geen links</p>'), null);
33 assert.equal(firstExternalUrl(null), null);
34});
35
36test('timelineEmbed round-trips a stored card and refuses junk', () => {
37 const stored = JSON.stringify({ url: 'https://v.example/1', kind: 'oembed', title: 'A talk', media: [{ url: 'https://v.example/t.jpg' }] });
38 const back = timelineEmbed(stored);
39 assert.equal(back.title, 'A talk');
40 assert.equal(back.media[0].url, 'https://v.example/t.jpg');
41 assert.equal(timelineEmbed(null), undefined);
42 assert.equal(timelineEmbed('not json'), undefined);
43 assert.equal(timelineEmbed('{"title":"no url"}'), undefined, 'a card without a url is not a card');
44});
[b258a79]45
46// FEP-044f emit side: quoting a fediverse object must federate as a quote AND
47// tell the quoted author. This is the mirror of the ingest we already had.
48const { applyQuoteProps } = await import('../src/services/ActivityPubService.js');
49
50test('a quote is emitted in all three shapes the network reads', () => {
51 const note = { to: ['https://www.w3.org/ns/activitystreams#Public'], cc: [], tag: [{ type: 'Hashtag', name: '#x' }] };
52 applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
53 assert.equal(note.quote, 'https://s/objects/9', 'the FEP property');
54 assert.equal(note.quoteUrl, 'https://s/objects/9', 'the as: alias Mastodon reads');
55 assert.equal(note._misskey_quote, 'https://s/objects/9', 'the misskey alias');
56 const link = note.tag.find((t) => t.type === 'Link');
57 assert.ok(link, 'and an FEP-e232 Link tag');
58 assert.equal(link.href, 'https://s/objects/9');
59 assert.ok(link.mediaType.includes('activitystreams'));
60 assert.ok(note.tag.some((t) => t.type === 'Hashtag'), 'existing tags survive');
61});
62
63test('the quoted author is addressed, so being quoted is not a surprise', () => {
64 const note = { cc: ['https://s/users/me/followers'] };
65 applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
66 assert.ok(note.cc.includes('https://s/users/alice'));
67 assert.ok(note.cc.includes('https://s/users/me/followers'), 'without dropping the followers');
68});
69
70test('no quote, or a junk one, changes nothing', () => {
71 const a = { cc: [], tag: [] };
72 applyQuoteProps(a, null, null);
73 assert.equal(a.quote, undefined);
74 assert.equal(a.tag.length, 0);
75 const b = { cc: [], tag: [] };
76 applyQuoteProps(b, 'javascript:alert(1)', 'https://s/users/alice');
77 assert.equal(b.quote, undefined, 'a non-http quote uri is refused');
78 const c = { cc: [], tag: [] };
79 applyQuoteProps(c, 'https://s/objects/9', 'not-a-url');
80 assert.equal(c.quote, 'https://s/objects/9');
81 assert.equal(c.cc.length, 0, 'a junk actor is simply not addressed');
82});
83
84test('a hostile oEmbed title is stored as plain text, not markup', async () => {
85 const { resolveExternalEmbed } = await import('../src/services/ActivityPubService.js');
86 // No network in the test env, so the resolver bails and returns null; the
87 // point here is the contract: whatever comes back is never raw provider HTML.
88 const out = await resolveExternalEmbed('<p><a href="https://v.example/1">x</a></p>');
89 assert.ok(out === null || !/<script/i.test(out), 'never stores executable markup');
90});
Note: See TracBrowser for help on using the repository browser.