| 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.
|
|---|
| 4 | import { test } from 'node:test';
|
|---|
| 5 | import assert from 'node:assert/strict';
|
|---|
| 6 |
|
|---|
| 7 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 8 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 9 |
|
|---|
| 10 | const dbMod = await import('../src/config/database.js');
|
|---|
| 11 | const db = dbMod.default;
|
|---|
| 12 | dbMod.initializeDatabase();
|
|---|
| 13 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 14 |
|
|---|
| 15 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 16 | .run('u1', 'robin', 'u1@t', 'x', 'god');
|
|---|
| 17 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
|
|---|
| 18 | const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('s1' ? 'kid' : 'kid');
|
|---|
| 19 | const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
|
|---|
| 20 |
|
|---|
| 21 | test('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 |
|
|---|
| 62 | test("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 |
|
|---|
| 104 | test("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 |
|
|---|
| 139 | test('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 |
|
|---|
| 154 | test('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 |
|
|---|
| 181 | test('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 |
|
|---|
| 210 | test('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 |
|
|---|
| 221 | test('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 |
|
|---|
| 229 | test('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 | });
|
|---|