'use strict'; const assert = require('assert'); const nconf = require('nconf'); const db = require('../mocks/databasemock'); const meta = require('../../src/meta'); const install = require('../../src/install'); const user = require('../../src/user'); const categories = require('../../src/categories'); const posts = require('../../src/posts'); const topics = require('../../src/topics'); const api = require('../../src/api'); const activitypub = require('../../src/activitypub'); const utils = require('../../src/utils'); const helpers = require('./helpers'); describe('Notes', () => { before(async () => { meta.config.activitypubEnabled = 1; await install.giveWorldPrivileges(); }); describe('Assertion', () => { describe('Public objects', () => { it('should pull a remote root-level object by its id and create a new topic', async () => { const { id } = helpers.mocks.note(); const assertion = await activitypub.notes.assert(0, id, { skipChecks: true }); assert(assertion); const { tid, count } = assertion; assert(tid); assert.strictEqual(count, 1); const exists = await topics.exists(tid); assert(exists); }); it('should assert if the cc property is missing', async () => { const { id } = helpers.mocks.note({ cc: 'remove' }); const assertion = await activitypub.notes.assert(0, id, { skipChecks: true }); assert(assertion); const { tid, count } = assertion; assert(tid); assert.strictEqual(count, 1); const exists = await topics.exists(tid); assert(exists); }); it('should assert if the object is of type Video', async () => { const { id } = helpers.mocks.note({ type: 'Video', }); const assertion = await activitypub.notes.assert(0, id, { skipChecks: true }); assert(assertion); const { tid, count } = assertion; assert(tid); assert.strictEqual(count, 1); const exists = await topics.exists(tid); assert(exists); }); describe('Category-specific behaviours', () => { it('should slot newly created topic in local category if addressed', async () => { const { cid } = await categories.create({ name: utils.generateUUID() }); const { id } = helpers.mocks.note({ cc: [`${nconf.get('url')}/category/${cid}`], }); const assertion = await activitypub.notes.assert(0, id); assert(assertion); const { tid, count } = assertion; assert(tid); assert.strictEqual(count, 1); const topic = await topics.getTopicData(tid); assert.strictEqual(topic.cid, cid); }); it('should add a remote category topic to a user\'s inbox if they are following the category', async () => { const { id: cid, actor } = helpers.mocks.group(); await activitypub.actors.assertGroup([cid]); const uid = await user.create({ username: utils.generateUUID() }); await api.categories.setWatchState({ uid }, { cid, state: categories.watchStates.tracking }); const { id } = helpers.mocks.note({ cc: [cid], }); const { tid } = await activitypub.notes.assert(0, id, { cid }); const inInbox = await db.isSortedSetMember(`uid:${uid}:inbox`, tid); assert(inInbox); }); }); describe('User-specific behaviours', () => { let remoteCid; let uid; before(async () => { // Remote const { id, actor } = helpers.mocks.group(); remoteCid = id; await activitypub.actors.assertGroup([id]); // User uid = await user.create({ username: utils.generateUUID() }); await topics.markAllRead(uid); }); it('should not show up in my unread if it is in cid -1', async () => { const { id } = helpers.mocks.note(); const assertion = await activitypub.notes.assert(0, id, { skipChecks: 1 }); assert(assertion); const unread = await topics.getTotalUnread(uid); assert.strictEqual(unread, 0); }); it('should show up in my recent/unread if I am tracking the remote category', async () => { await api.categories.setWatchState({ uid }, { cid: remoteCid, state: categories.watchStates.tracking, uid, }); const { id } = helpers.mocks.note({ cc: [remoteCid], }); const assertion = await activitypub.notes.assert(0, id, { cid: remoteCid }); assert(assertion); const unread = await topics.getTotalUnread(uid); assert.strictEqual(unread, 1); await topics.markAllRead(uid); }); it('should show up in recent/unread and notify me if I am watching the remote category', async () => { await api.categories.setWatchState({ uid }, { cid: remoteCid, state: categories.watchStates.watching, uid, }); const { id, note } = helpers.mocks.note({ cc: [remoteCid], }); const assertion = await activitypub.notes.assert(0, id, { cid: remoteCid }); assert(assertion); const unread = await topics.getTotalUnread(uid); assert.strictEqual(unread, 1); // Notification inbox delivery is async so can't test directly const exists = await db.exists(`notifications:new_topic:tid:${assertion.tid}:uid:${note.attributedTo}`); assert(exists); await topics.markAllRead(uid); }); it('should not show up in recent/unread if I am ignoring the remote category', async () => { await api.categories.setWatchState({ uid }, { cid: remoteCid, state: categories.watchStates.ignoring, uid, }); const { id, note } = helpers.mocks.note({ cc: [remoteCid], }); const assertion = await activitypub.notes.assert(0, id, { cid: remoteCid }); assert(assertion); const unread = await topics.getTotalUnread(uid); assert.strictEqual(unread, 0); }); }); }); describe('Private objects', () => { let recipientUid; before(async () => { recipientUid = await user.create({ username: utils.generateUUID().slice(0, 8) }); }); it('should NOT create a new topic or post when asserting a private note', async () => { const { id, note } = helpers.mocks.note({ to: [`${nconf.get('url')}/uid/${recipientUid}`], cc: [], }); const { activity } = helpers.mocks.create(note); const { roomId } = await activitypub.inbox.create({ body: activity }); assert(roomId); assert(utils.isNumber(roomId)); const exists = await posts.exists(id); assert(!exists); }); it('should still assert if the cc property is missing', async () => { const { id, note } = helpers.mocks.note({ to: [`${nconf.get('url')}/uid/${recipientUid}`], cc: 'remove', }); const { activity } = helpers.mocks.create(note); const { roomId } = await activitypub.inbox.create({ body: activity }); assert(roomId); assert(utils.isNumber(roomId)); }); }); }); describe('Creation', () => { let uid; before(async () => { uid = await user.create({ username: utils.generateUUID() }); }); describe('Local categories', () => { let cid; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID() })); activitypub._sent.clear(); }); afterEach(() => { activitypub._sent.clear(); }); describe('new topics', () => { let activity; before(async () => { const { tid } = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), }); assert(tid); assert.strictEqual(activitypub._sent.size, 1); const key = Array.from(activitypub._sent.keys())[0]; activity = activitypub._sent.get(key); }); it('should federate out a Create activity', () => { assert(activity && activity.to); assert.strictEqual(activity.type, 'Create'); }); it('should have the local category addressed', () => { const addressees = new Set([ ...(activity.to || []), ...(activity.cc || []), ...(activity.bcc || []), ...(activity.object.to || []), ...(activity.object.cc || []), ...(activity.object.bcc || []), ]); assert(addressees.has(`${nconf.get('url')}/category/${cid}`)); }); it('should federate out an activity with object of type "Article"', () => { assert(activity.object && activity.object.type); assert.strictEqual(activity.object.type, 'Article'); }); }); describe('new reply', () => { let activity; before(async () => { const { tid } = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), }); activitypub._sent.clear(); const { pid } = await api.topics.reply({ uid }, { tid, content: utils.generateUUID(), }); const key = Array.from(activitypub._sent.keys())[0]; activity = activitypub._sent.get(key); }); it('should federate out an activity with object of type "Note"', () => { assert(activity.object && activity.object.type); assert.strictEqual(activity.object.type, 'Note'); }); }); }); describe('Remote Categories', () => { let cid; before(async () => { ({ id: cid } = helpers.mocks.group()); await activitypub.actors.assert([cid]); }); afterEach(() => { activitypub._sent.clear(); }); describe('new topics', () => { it('should federate out a Create activity with the remote community addressed', async () => { const { tid } = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), }); assert(tid); assert.strictEqual(activitypub._sent.size, 1); const key = Array.from(activitypub._sent.keys())[0]; const activity = activitypub._sent.get(key); assert(activity && activity.to); assert.strictEqual(activity.type, 'Create'); const addressees = new Set([ ...(activity.to || []), ...(activity.cc || []), ...(activity.bcc || []), ...(activity.object.to || []), ...(activity.object.cc || []), ...(activity.object.bcc || []), ]); assert(addressees.has(cid)); }); }); describe('replies', () => { it('should federate out a Create activity with the remote community addressed', async () => { const { tid } = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), }); activitypub._sent.clear(); const postData = await api.topics.reply({ uid }, { tid, content: utils.generateUUID(), }); assert(postData); assert.strictEqual(activitypub._sent.size, 1); const key = Array.from(activitypub._sent.keys())[0]; const activity = activitypub._sent.get(key); assert(activity && activity.to); assert.strictEqual(activity.type, 'Create'); const addressees = new Set([ ...(activity.to || []), ...(activity.cc || []), ...(activity.bcc || []), ...(activity.object.to || []), ...(activity.object.cc || []), ...(activity.object.bcc || []), ]); assert(addressees.has(cid)); }); }); }); }); describe('Inbox handling', () => { describe('helper self-check', () => { it('should generate a Like activity', () => { const object = utils.generateUUID(); const { id: actor } = helpers.mocks.person(); const { activity } = helpers.mocks.like({ object, actor, }); assert.deepStrictEqual(activity, { '@context': 'https://www.w3.org/ns/activitystreams', id: `${helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`, type: 'Like', actor, object, }); }); it('should generate an Announce activity wrapping a Like activity', () => { const object = utils.generateUUID(); const { id: actor } = helpers.mocks.person(); const { activity: like } = helpers.mocks.like({ object, actor, }); const { id: gActor } = helpers.mocks.group(); const { activity } = helpers.mocks.announce({ actor: gActor, object: like, }); assert.deepStrictEqual(activity, { '@context': 'https://www.w3.org/ns/activitystreams', id: `${helpers.mocks._baseUrl}/announce/${encodeURIComponent(like.id)}`, type: 'Announce', to: [ 'https://www.w3.org/ns/activitystreams#Public' ], cc: [ `${gActor}/followers`, ], actor: gActor, object: like, }); }); }); describe('Create', () => { let uid; before(async () => { uid = await user.create({ username: utils.generateUUID() }); }); describe('(Note)', () => { it('should create a new topic in cid -1', async () => { const { note, id } = helpers.mocks.note(); const { activity } = helpers.mocks.create(note); await db.sortedSetAdd(`followersRemote:${note.attributedTo}`, Date.now(), uid); await activitypub.inbox.create({ body: activity }); assert(await posts.exists(id)); const cid = await posts.getCidByPid(id); assert.strictEqual(cid, -1); }); it('should create a new topic in cid -1 even if a remote category is addressed', async () => { const { id: remoteCid } = helpers.mocks.group(); const { note, id } = helpers.mocks.note({ audience: [remoteCid], }); const { activity } = helpers.mocks.create(note); await activitypub.inbox.create({ body: activity }); assert(await posts.exists(id)); const cid = await posts.getCidByPid(id); assert.strictEqual(cid, -1); }); }); }); describe('Announce', () => { let cid; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); }); describe('(Create)', () => { it('should create a new topic in a remote category if addressed', async () => { const { id: remoteCid } = helpers.mocks.group(); const { id, note } = helpers.mocks.note({ audience: [remoteCid], }); let { activity } = helpers.mocks.create(note); ({ activity } = helpers.mocks.announce({ actor: remoteCid, object: activity })); await activitypub.inbox.announce({ body: activity }); assert(await posts.exists(id)); const cid = await posts.getCidByPid(id); assert.strictEqual(cid, remoteCid); }); }); describe('(Create) or (Note) referencing local post', () => { let uid; let topicData; let postData; let localNote; let announces = 0; before(async () => { uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); ({ topicData, postData } = await topics.post({ cid, uid, title: utils.generateUUID(), content: utils.generateUUID(), })); localNote = await activitypub.mocks.notes.public(postData); }); it('should increment announces counter when a remote user shares', async () => { const { id } = helpers.mocks.person(); const { activity } = helpers.mocks.announce({ actor: id, object: localNote, cc: [`${nconf.get('url')}/uid/${topicData.uid}`], }); await activitypub.inbox.announce({ body: activity }); announces += 1; const count = await posts.getPostField(topicData.mainPid, 'announces'); assert.strictEqual(count, announces); }); it('should contain the remote user announcer id in the post announces zset', async () => { const { id } = helpers.mocks.person(); const { activity } = helpers.mocks.announce({ actor: id, object: localNote, cc: [`${nconf.get('url')}/uid/${topicData.uid}`], }); await activitypub.inbox.announce({ body: activity }); announces += 1; const exists = await db.isSortedSetMember(`pid:${topicData.mainPid}:announces`, id); assert(exists); }); it('should NOT increment announces counter when a remote category shares', async () => { const { id } = helpers.mocks.group(); const { activity } = helpers.mocks.announce({ actor: id, object: localNote, cc: [`${nconf.get('url')}/uid/${topicData.uid}`], }); await activitypub.inbox.announce({ body: activity }); const count = await posts.getPostField(topicData.mainPid, 'announces'); assert.strictEqual(count, announces); }); it('should NOT contain the remote category announcer id in the post announces zset', async () => { const { id } = helpers.mocks.group(); const { activity } = helpers.mocks.announce({ actor: id, object: localNote, cc: [`${nconf.get('url')}/uid/${topicData.uid}`], }); await activitypub.inbox.announce({ body: activity }); const exists = await db.isSortedSetMember(`pid:${topicData.mainPid}:announces`, id); assert(!exists); }); }); describe('(Note)', () => { it('should create a new topic in cid -1 if category not addressed', async () => { const { note } = helpers.mocks.note(); await activitypub.actors.assert([note.attributedTo]); const { activity } = helpers.mocks.announce({ object: note, }); const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid); const beforeCount = await db.sortedSetCard(`cid:-1:tids`); await activitypub.inbox.announce({ body: activity }); const count = await db.sortedSetCard(`cid:-1:tids`); assert.strictEqual(count, beforeCount + 1); }); it('should create a new topic in local category', async () => { const { note } = helpers.mocks.note({ cc: [`${nconf.get('url')}/category/${cid}`], }); await activitypub.actors.assert([note.attributedTo]); const { activity } = helpers.mocks.announce({ object: note, }); const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid); const beforeCount = await db.sortedSetCard(`cid:${cid}:tids`); await activitypub.inbox.announce({ body: activity }); const count = await db.sortedSetCard(`cid:${cid}:tids`); assert.strictEqual(count, beforeCount + 1); }); }); describe('(Like)', () => { it('should upvote a local post', async () => { const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); const { postData } = await topics.post({ cid, uid, title: utils.generateUUID(), content: utils.generateUUID(), }); const { activity: like } = helpers.mocks.like({ object: `${nconf.get('url')}/post/${postData.pid}`, }); const { activity } = helpers.mocks.announce({ object: like, }); let { upvotes } = await posts.getPostFields(postData.pid, 'upvotes'); assert.strictEqual(upvotes, 0); await activitypub.inbox.announce({ body: activity }); ({ upvotes } = await posts.getPostFields(postData.pid, 'upvotes')); assert.strictEqual(upvotes, 1); }); it('should upvote an asserted remote post', async () => { const { id } = helpers.mocks.note(); await activitypub.notes.assert(0, [id], { skipChecks: true }); const { activity: like } = helpers.mocks.like({ object: id, }); const { activity } = helpers.mocks.announce({ object: like, }); let { upvotes } = await posts.getPostFields(id, 'upvotes'); assert.strictEqual(upvotes, 0); await activitypub.inbox.announce({ body: activity }); ({ upvotes } = await posts.getPostFields(id, 'upvotes')); assert.strictEqual(upvotes, 1); }); }); describe('(Update)', () => { it('should update a note\'s content', async () => { const { id: actor } = helpers.mocks.person(); const { id, note } = helpers.mocks.note({ attributedTo: actor }); await activitypub.notes.assert(0, [id], { skipChecks: true }); note.content = utils.generateUUID(); const { activity: update } = helpers.mocks.update({ object: note }); const { activity } = helpers.mocks.announce({ object: update }); await activitypub.inbox.announce({ body: activity }); const content = await posts.getPostField(id, 'content'); assert.strictEqual(content, note.content); }); }); }); }); describe('Inbox Synchronization', () => { let cid; let uid; let topicData; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); }); beforeEach(async () => { uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); ({ topicData } = await topics.post({ cid, uid, title: utils.generateUUID(), content: utils.generateUUID(), })); }); it('should add a topic to a user\'s inbox if user is a recipient in OP', async () => { await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); await activitypub.notes.syncUserInboxes(topicData.tid); const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); assert.strictEqual(inboxed, true); }); it('should add a topic to a user\'s inbox if a user is a recipient in a reply', async () => { const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); const { pid } = await topics.reply({ tid: topicData.tid, uid, content: utils.generateUUID(), }); await db.setAdd(`post:${pid}:recipients`, [uid]); await activitypub.notes.syncUserInboxes(topicData.tid); const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); assert.strictEqual(inboxed, true); }); it('should maintain a list of recipients at the topic level', async () => { await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); await activitypub.notes.syncUserInboxes(topicData.tid); const [isRecipient, count] = await Promise.all([ db.isSetMember(`tid:${topicData.tid}:recipients`, uid), db.setCount(`tid:${topicData.tid}:recipients`), ]); assert(isRecipient); assert.strictEqual(count, 1); }); it('should add topic to a user\'s inbox if it is explicitly passed in as an argument', async () => { await activitypub.notes.syncUserInboxes(topicData.tid, uid); const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); assert.strictEqual(inboxed, true); }); it('should remove a topic from a user\'s inbox if that user is no longer a recipient in any contained posts', async () => { await activitypub.notes.syncUserInboxes(topicData.tid, uid); await activitypub.notes.syncUserInboxes(topicData.tid); const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); assert.strictEqual(inboxed, false); }); }); describe('Deletion', () => { let cid; let uid; let topicData; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); }); beforeEach(async () => { uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); ({ topicData } = await topics.post({ cid, uid, title: utils.generateUUID(), content: utils.generateUUID(), })); }); it('should clean up recipient sets for the post', async () => { const { pid } = await topics.reply({ pid: `https://example.org/${utils.generateUUID().slice(0, 8)}`, tid: topicData.tid, uid, content: utils.generateUUID(), }); await db.setAdd(`post:${pid}:recipients`, [uid]); await activitypub.notes.delete([pid]); const inboxed = await db.isSetMember(`post:${pid}:recipients`, uid); assert(!inboxed); }); }); });