source: Klonkt/test/c2s-compose.test.js

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

Follow: gesigneerd resolven, fouten naar de app, guardians ingelicht, volg-QR

Robins meldingen (31-7). Vier dingen aan de volg-kant.

Een: followActor haalde het actor-document anoniem op; een
authorized-fetch-instance weigert dat, waardoor volgen vanaf een boost
faalde. De fetch is nu gesigneerd als de eigen actor.

Twee: de C2S-ingest slikte followActor-fouten in en gaf altijd 202,
dus een mislukte follow zag er in de app uit als een gelukte. Fouten
komen nu als 502 follow_failed (met detail) bij de app aan, met test.

Drie: de guardians worden ingelicht als hun ward iemand gaat volgen:
een follow brengt nieuwe content het kind binnen, en het dorp hoort te
weten dat de deur openging. Een directe note per guardian,
best-effort. FEP-633c 5.3 gate't inkomende follows; deze uitgaande
melding is Shaer-beleid (spec-vraag als bead).

Vier: GET /ap/users/:slug/follow-qr.png serveert een QR-PNG van
share:social/follow/AP/@slug@host (npm qrcode, puur JS). Publiek met
opzet: er staat alleen de publieke handle in, en de plain image-loaders
van de apps dragen geen bearer.

Changed files:
src/services/ActivityPubService.js

  • followActor: signedGetJson voor het actor-doc; guardian-notice
  • C2S Follow-case: fouten door naar de app

src/routes/activitypub.js

  • follow-qr.png-route (cache 1 dag)

package.json / package-lock.json

  • qrcode-dependency

test/c2s-compose.test.js

  • onbereikbare follow geeft 502 follow_failed/unreachable

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

  • Property mode set to 100644
File size: 12.8 KB
Line 
1// The app's composer posts over C2S. A top-level Note with media used to lose
2// it silently: c2sCreatePost read only the content, so a photo post arrived
3// naked while the very same attachments worked fine on replies and DMs.
4import { test } from 'node:test';
5import assert from 'node:assert/strict';
6
7process.env.DATABASE_PATH = ':memory:';
8process.env.PUBLIC_BASE_URL = 'https://test.example';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const AP = (await import('../src/services/ActivityPubService.js')).default;
14
15db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
16 .run('u1', 'robin', 'u1@t', 'x', 'god');
17db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
18const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('s1' ? 'kid' : 'kid');
19const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
20
21test('a C2S post carries its media: into the web content and out as AS2 attachments', async () => {
22 const r = await AP.ingestOutboxActivity(site, user, {
23 type: 'Create',
24 object: {
25 type: 'Note',
26 content: '<p>kijk dan</p>',
27 source: { content: 'kijk dan', mediaType: 'text/plain' },
28 to: ['https://test.example/ap/users/kid/followers'],
29 cc: ['https://www.w3.org/ns/activitystreams#Public'],
30 attachment: [
31 { type: 'Image', url: '/media/reply-media/foto.jpg', mediaType: 'image/jpeg', name: 'ons plein' },
32 { type: 'Audio', url: '/media/reply-media/opname.m4a', mediaType: 'audio/mp4' },
33 // Not ours: a remote URL must never be laundered into our media.
34 { type: 'Image', url: 'https://evil.test/x.jpg', mediaType: 'image/jpeg' },
35 ],
36 },
37 });
38 assert.equal(r.status, 201, 'the post is created');
39
40 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
41 assert.match(post.content, /<img src="\/media\/reply-media\/foto\.jpg" alt="ons plein">/, 'the web shows the photo');
42 assert.match(post.content, /<audio controls[^>]+src="\/media\/reply-media\/opname\.m4a">/, 'and plays the recording');
43 assert.ok(!post.content.includes('evil.test'), 'the stranger stays out');
44
45 const note = AP.buildNote('https://test.example', site, post);
46 // The FEDERATED content carries no media tags: they ride as attachments,
47 // and the tags' relative /media srcs are dead everywhere but our own web.
48 // Leaving them in showed every remote reader a broken player above the
49 // working one (Robins schermafdruk, 30-7).
50 assert.ok(!/<(video|audio|img)\b/i.test(note.content), 'the note content is clean of media tags');
51 const att = note.attachment || [];
52 const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
53 const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
54 assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
55 assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
56 assert.equal(img.name, 'ons plein', 'alt text rides along');
57 assert.ok(aud, 'the recording federates too');
58 assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
59 assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
60});
61
62test("a video's poster frame rides the tag, the store and the federated attachment", async () => {
63 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is
64 // around (shaer-zowq). From there it must reach three places: the poster=
65 // on the folded tag (web), the stored entry, and the AS2 icon on the
66 // federated attachment (apps and other servers).
67 const os = await import('os');
68 const fsm = await import('fs');
69 const pathm = await import('path');
70 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
71 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
72 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x');
73 const prev = process.env.MEDIA_PATH;
74 process.env.MEDIA_PATH = root;
75 try {
76 const r = await AP.ingestOutboxActivity(site, user, {
77 type: 'Create',
78 object: {
79 type: 'Note', content: '<p>filmpje</p>',
80 to: ['https://test.example/ap/users/kid/followers'],
81 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }],
82 },
83 });
84 assert.equal(r.status, 201);
85 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
86 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still');
87 const note = AP.buildNote('https://test.example', site, post);
88 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4'));
89 assert.ok(vid && vid.type === 'Video');
90 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
91 'the poster federates as the attachment icon');
92 // NO cover (Robins besluit): a cover next to the content showed the same
93 // video twice on the post page. The tiles derive their picture from the
94 // content (post-tile/post-card), so the post model stays single-source.
95 assert.equal(post.cover_video_url, null);
96 assert.equal(post.cover_image_url, null);
97 assert.equal((note.attachment || []).filter((a) => a.url.endsWith('film.mp4')).length, 1,
98 'and the video federates exactly once');
99 } finally {
100 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
101 }
102});
103
104test("an audio note's waveform rides the tag, the store and the federated attachment", async () => {
105 // Same courtesy as the video poster (Robins vraag, 30-7: de kale
106 // audio-tegel): the upload leg draws <name>.poster.png (showwavespic).
107 // From there it must reach the data-poster on the folded tag (the tiles
108 // read it), the stored entry, and the AS2 icon on the federated Audio.
109 const os = await import('os');
110 const fsm = await import('fs');
111 const pathm = await import('path');
112 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
113 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
114 fsm.writeFileSync(pathm.join(root, 'reply-media', 'lied.m4a.poster.png'), 'x');
115 const prev = process.env.MEDIA_PATH;
116 process.env.MEDIA_PATH = root;
117 try {
118 const r = await AP.ingestOutboxActivity(site, user, {
119 type: 'Create',
120 object: {
121 type: 'Note', content: '',
122 to: ['https://test.example/ap/users/kid/followers'],
123 attachment: [{ type: 'Audio', url: '/media/reply-media/lied.m4a', mediaType: 'audio/mp4' }],
124 },
125 });
126 assert.equal(r.status, 201);
127 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
128 assert.match(post.content, /data-poster="\/media\/reply-media\/lied\.m4a\.poster\.png"/, 'the tag carries the waveform for the tiles');
129 const note = AP.buildNote('https://test.example', site, post);
130 const aud = (note.attachment || []).find((a) => a.url.endsWith('lied.m4a'));
131 assert.ok(aud && aud.type === 'Audio');
132 assert.equal(aud.icon && aud.icon.url, 'https://test.example/media/reply-media/lied.m4a.poster.png',
133 'the waveform federates as the attachment icon');
134 } finally {
135 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
136 }
137});
138
139test('a video without a poster simply has none: no guessed icon', async () => {
140 const r = await AP.ingestOutboxActivity(site, user, {
141 type: 'Create',
142 object: {
143 type: 'Note', content: '<p>kaal</p>',
144 to: ['https://test.example/ap/users/kid/followers'],
145 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }],
146 },
147 });
148 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
149 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file');
150 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4'));
151 assert.equal(vid.icon, undefined);
152});
153
154test('the OUTBOX serves the attachment too, not only the delivered Create', async () => {
155 // Root of the broken iPhone player (Robins schermafdrukken, 30-7): the
156 // outbox SELECT did not include c2s_attachments, so a note pulled via the
157 // outbox (backfill, boiert.eu) had NO Video attachment and readers fell
158 // back to the content tag with its relative, dead src. The delivered copy
159 // was fine, which is why one device worked and the other did not. This
160 // locks the outbox contract: the same narrow SELECT, through buildNote,
161 // must carry the attachment.
162 const r = await AP.ingestOutboxActivity(site, user, {
163 type: 'Create',
164 object: {
165 type: 'Note', content: '<p>buiten</p>',
166 to: ['https://test.example/ap/users/kid/followers'],
167 cc: ['https://www.w3.org/ns/activitystreams#Public'],
168 attachment: [{ type: 'Video', url: '/media/reply-media/buiten.mp4', mediaType: 'video/mp4' }],
169 },
170 });
171 const row = db.prepare(
172 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
173 FROM posts WHERE id = ?`).get(r.id);
174 const note = AP.buildNote('https://test.example', site, row);
175 const vid = (note.attachment || []).find((a) => a.url.endsWith('buiten.mp4'));
176 assert.ok(vid, 'the outbox-shaped row still yields the Video attachment');
177 assert.equal(vid.type, 'Video');
178 assert.ok(!/<video\b/i.test(note.content), 'and the content stays clean');
179});
180
181test('C2S Delete takes an own post back, and only an own post', async () => {
182 // Long-press delete in the app (Robins verzoek, 30-7): the child changes
183 // their mind, the post goes, followers get the Tombstone.
184 const r = await AP.ingestOutboxActivity(site, user, {
185 type: 'Create',
186 object: {
187 type: 'Note', content: '<p>weg hiermee</p>',
188 to: ['https://test.example/ap/users/kid/followers'],
189 cc: ['https://www.w3.org/ns/activitystreams#Public'],
190 },
191 });
192 assert.equal(r.status, 201);
193 const del = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: r.url });
194 assert.equal(del.status, 202, 'an own note deletes');
195 assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get(r.id).c, 0, 'and the row is gone');
196
197 const unknown = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/bestaat-niet' });
198 assert.equal(unknown.status, 404, 'an unknown note is a clear no');
199
200 // Another account's post on this server: refused, row untouched.
201 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u2', 'ander', 'u2@t', 'x', 'user');
202 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'ander', 'ander', 'u2');
203 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at, created_at, updated_at)
204 VALUES ('p-ander', 's2', 'u2', 'n-ander', '', '<p>van een ander</p>', 'published', datetime('now'), datetime('now'), datetime('now'))`).run();
205 const foreign = await AP.ingestOutboxActivity(site, user, { type: 'Delete', object: 'https://test.example/ap/notes/p-ander' });
206 assert.equal(foreign.status, 403, "someone else's post is not yours to take back");
207 assert.equal(db.prepare('SELECT COUNT(*) c FROM posts WHERE id = ?').get('p-ander').c, 1, 'and it stays');
208});
209
210test('a failed follow REACHES the app as an error, not a fake 202', async () => {
211 // Following from a boost silently failed (Robins melding, 31-7): the C2S
212 // ingest swallowed followActor's error. An unreachable actor must say so.
213 const r = await AP.ingestOutboxActivity(site, user, {
214 type: 'Follow', object: 'https://unresolvable.invalid/u/niemand',
215 });
216 assert.equal(r.status, 502);
217 assert.equal(r.error, 'follow_failed');
218 assert.equal(r.detail, 'unreachable');
219});
220
221test('selfAuthor: the byline for your own outbox notes', () => {
222 // The owner's app reads its own posts from the outbox, which carried no
223 // author info: every card but your own had a header (Robins melding, 30-7).
224 const me = AP.selfAuthor('https://test.example', site);
225 assert.equal(me.name, 'kid');
226 assert.equal(me.handle, '@kid@test.example');
227});
228
229test('a media-only post is a post, not an empty-note error', async () => {
230 const r = await AP.ingestOutboxActivity(site, user, {
231 type: 'Create',
232 object: {
233 type: 'Note', content: '',
234 to: ['https://test.example/ap/users/kid/followers'],
235 attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
236 },
237 });
238 assert.equal(r.status, 201, 'a picture can be the whole message');
239 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
240 assert.equal(post.cover_image_url, null, 'no cover: the tile reads the photo from the content');
241});
Note: See TracBrowser for help on using the repository browser.