import { makeDB } from '../utils' import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import { strict as assert } from 'node:assert/strict' import { cacheObject, getObjectById } from 'wildebeest/backend/src/activitypub/objects/' import { addFollowing } from 'wildebeest/backend/src/mastodon/follow' import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' import { ObjectsRow } from 'wildebeest/backend/src/types/objects' const adminEmail = 'admin@example.com' const domain = 'cloudflare.com' const userKEK = 'test_kek15' const vapidKeys = {} as JWK describe('ActivityPub', () => { describe('handle Activity', () => { describe('Announce', () => { test('records reblog in db', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Announce', actor: actorB.id, object: note.id, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_reblogs').first<{ actor_id: URL object_id: URL }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) test('creates notification', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Announce', actor: actorB.id, object: note.id, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ type: string actor_id: URL from_actor_id: URL }>() assert(entry) assert.equal(entry.type, 'reblog') assert.equal(entry.actor_id.toString(), actorA.id.toString()) assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) }) }) describe('Like', () => { test('records like in db', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Like', actor: actorB.id, object: note.id, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL; object_id: URL }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) test('creates notification', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Like', actor: actorB.id, object: note.id, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ type: string actor_id: URL from_actor_id: URL }>() assert.equal(entry.type, 'favourite') assert.equal(entry.actor_id.toString(), actorA.id.toString()) assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) }) test('records like in db', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Like', actor: actorB.id, object: note.id, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL object_id: URL }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) }) describe('Accept', () => { beforeEach(() => { globalThis.fetch = async (input: RequestInfo) => { throw new Error('unexpected request to ' + input) } }) test('Accept follow request stores in db', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') await addFollowing(db, actor, actor2, 'not needed') const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Accept', actor: { id: 'https://' + domain + '/ap/users/sven2' }, object: { type: 'Follow', actor: actor.id, object: 'https://' + domain + '/ap/users/sven2', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const row = await db .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) .bind(actor.id.toString()) .first<{ target_actor_id: string state: string }>() assert(row) assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') assert.equal(row.state, 'accepted') }) test('Object must be an object', async () => { const db = await makeDB() await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Accept', actor: 'https://example.com/actor', object: 'a', } await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) }) describe('Create', () => { test('Object must be an object', async () => { const db = await makeDB() await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Create', actor: 'https://example.com/actor', object: 'a', } await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) test('Note to inbox stores in DB', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { type: 'Create', actor: actor.id.toString(), to: [actor.id.toString()], cc: [], object: { id: 'https://example.com/note1', type: 'Note', content: 'test note', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db .prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id') .first() const properties = JSON.parse(entry.properties) assert.equal(properties.content, 'test note') }) test("Note adds in remote actor's outbox", async () => { const remoteActorId = 'https://example.com/actor' globalThis.fetch = async (input: RequestInfo) => { if (input.toString() === remoteActorId) { return new Response( JSON.stringify({ id: remoteActorId, type: 'Person', }) ) } throw new Error('unexpected request to ' + input) } const db = await makeDB() await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { type: 'Create', actor: remoteActorId, to: [], cc: [], object: { id: 'https://example.com/note1', type: 'Note', content: 'test note', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') .bind(remoteActorId) .first<{ actor_id: string }>() assert.equal(entry.actor_id, remoteActorId) }) test('local actor sends Note with mention create notification', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') const activity: any = { type: 'Create', actor: actorB.id.toString(), to: [actorA.id.toString()], cc: [], object: { id: 'https://example.com/note2', type: 'Note', content: 'test note', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ type: string actor_id: URL from_actor_id: URL }>() assert(entry) assert.equal(entry.type, 'mention') assert.equal(entry.actor_id.toString(), actorA.id.toString()) assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) }) test('Note records reply', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') { const activity: any = { type: 'Create', actor: actor.id.toString(), to: [actor.id.toString()], object: { id: 'https://example.com/note1', type: 'Note', content: 'post', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) } { const activity: any = { type: 'Create', actor: actor.id.toString(), to: [actor.id.toString()], object: { inReplyTo: 'https://example.com/note1', id: 'https://example.com/note2', type: 'Note', content: 'reply', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) } const entry = await db.prepare('SELECT * FROM actor_replies').first<{ actor_id: string object_id: string in_reply_to_object_id: string }>() assert.equal(entry.actor_id, actor.id.toString().toString()) const obj: any = await getObjectById(db, entry.object_id) assert(obj) assert.equal(obj.originalObjectId, 'https://example.com/note2') const inReplyTo: any = await getObjectById(db, entry.in_reply_to_object_id) assert(inReplyTo) assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1') }) test('preserve Note sent with `to`', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity = { type: 'Create', actor: actor.id.toString(), to: ['some actor'], cc: [], object: { id: 'https://example.com/note1', type: 'Note', content: 'test note', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const row = await db.prepare('SELECT * FROM outbox_objects').first<{ target: string }>() assert.equal(row.target, 'some actor') }) test('Object props get sanitized', async () => { const db = await makeDB() const person = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Create', actor: person, object: { id: 'https://example.com/note2', type: 'Note', name: '', content: '

bold

', }, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const row = await db.prepare(`SELECT * from objects`).first() const { content, name } = JSON.parse(row.properties) assert.equal( content, '


bold

alert("evil")

' ) assert.equal(name, 'Dr Evil') }) }) describe('Update', () => { test('Object must be an object', async () => { const db = await makeDB() const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Update', actor: 'https://example.com/actor', object: 'a', } await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) test('Object must exist', async () => { const db = await makeDB() const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Update', actor: 'https://example.com/actor', object: { id: 'https://example.com/note2', type: 'Note', content: 'test note', }, } await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: 'object https://example.com/note2 does not exist', }) }) test('Object must have the same origin', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const object = { id: 'https://example.com/note2', type: 'Note', content: 'test note', } const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) assert.notEqual(obj, null, 'could not create object') const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Update', actor: 'https://example.com/actor', object: object, } await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: 'actorid mismatch when updating object', }) }) test('Object is updated', async () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const object = { id: 'https://example.com/note2', type: 'Note', content: 'test note', } const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) assert.notEqual(obj, null, 'could not create object') const newObject = { id: 'https://example.com/note2', type: 'Note', content: 'new test note', } const activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Update', actor: actor.id, object: newObject, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const updatedObject = await db .prepare('SELECT * FROM objects WHERE original_object_id=?') .bind(object.id) .first() assert(updatedObject) assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) }) }) describe('Announce', () => { test('Announce objects are stored and added to the remote actors outbox', async () => { const remoteActorId = 'https://example.com/actor' const objectId = 'https://example.com/some-object' globalThis.fetch = async (input: RequestInfo) => { if (input.toString() === remoteActorId) { return new Response( JSON.stringify({ id: remoteActorId, icon: { url: 'img.com' }, type: 'Person', }) ) } if (input.toString() === objectId) { return new Response( JSON.stringify({ id: objectId, type: 'Note', content: 'foo', }) ) } throw new Error('unexpected request to ' + input) } const db = await makeDB() await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { type: 'Announce', actor: remoteActorId, to: [], cc: [], object: objectId, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const object = await db.prepare('SELECT * FROM objects').first<{ type: string original_actor_id: string }>() assert(object) assert.equal(object.type, 'Note') assert.equal(object.original_actor_id, remoteActorId) const outbox_object = await db .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') .bind(remoteActorId) .first<{ actor_id: string }>() assert(outbox_object) assert.equal(outbox_object.actor_id, remoteActorId) }) }) }) })