'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 categories = require('../../src/categories'); const user = require('../../src/user'); const topics = require('../../src/topics'); const activitypub = require('../../src/activitypub'); const utils = require('../../src/utils'); const request = require('../../src/request'); const slugify = require('../../src/slugify'); const helpers = require('./helpers'); describe('Actor asserton', () => { before(async () => { meta.config.activitypubEnabled = 1; await install.giveWorldPrivileges(); }); describe('happy path', () => { let uid; let actorUri; before(async () => { uid = utils.generateUUID().slice(0, 8); actorUri = `https://example.org/user/${uid}`; activitypub._cache.set(`0;${actorUri}`, { '@context': 'https://www.w3.org/ns/activitystreams', id: actorUri, url: actorUri, type: 'Person', name: 'example', preferredUsername: 'example', inbox: `https://example.org/user/${uid}/inbox`, outbox: `https://example.org/user/${uid}/outbox`, publicKey: { id: `${actorUri}#key`, owner: actorUri, publicKeyPem: 'somekey', }, }); activitypub.helpers._webfingerCache.set('example@example.org', { actorUri }); }); it('should return true if successfully asserted', async () => { const result = await activitypub.actors.assert([actorUri]); assert(result && result.length); }); it('should contain a representation of that remote user in the database', async () => { const exists = await db.exists(`userRemote:${actorUri}`); assert(exists); const userData = await user.getUserData(actorUri); assert(userData); assert.strictEqual(userData.uid, actorUri); }); it('should save the actor\'s publicly accessible URL in the hash as well', async () => { const url = await user.getUserField(actorUri, 'url'); assert.strictEqual(url, actorUri); }); it('should assert group actors by calling actors.assertGroup', async () => { const { id, actor } = helpers.mocks.group(); const assertion = await activitypub.actors.assert([id]); assert(assertion); assert.strictEqual(assertion.length, 1); assert.strictEqual(assertion[0].cid, actor.id); }); describe('remote user to remote category migration', () => { it('should not migrate a user to a category if .assert is called', async () => { // ... because the user isn't due for an update and so is filtered out during qualification const { id } = helpers.mocks.person(); await activitypub.actors.assert([id]); const { actor } = helpers.mocks.group({ id }); const assertion = await activitypub.actors.assertGroup([id]); assert(assertion.length, 0); const exists = await user.exists(id); assert.strictEqual(exists, false); }); it('should migrate a user to a category if on re-assertion it identifies as an as:Group', async () => { // This is to handle previous behaviour that saved all as:Group actors as NodeBB users. const { id } = helpers.mocks.person(); await activitypub.actors.assert([id]); helpers.mocks.group({ id }); const assertion = await activitypub.actors.assertGroup([id]); assert(assertion && Array.isArray(assertion) && assertion.length === 1); const exists = await user.exists(id); assert.strictEqual(exists, false); }); it('should migrate any shares by that user, into topics in the category', async () => { const { id } = helpers.mocks.person(); await activitypub.actors.assert([id]); // Two shares for (let x = 0; x < 2; x++) { const { id: pid } = helpers.mocks.note(); // eslint-disable-next-line no-await-in-loop const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 }); // eslint-disable-next-line no-await-in-loop await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid); } helpers.mocks.group({ id }); await activitypub.actors.assertGroup([id]); const { topic_count, post_count } = await categories.getCategoryData(id); assert.strictEqual(topic_count, 2); assert.strictEqual(post_count, 2); }); it('should not migrate shares by that user that already belong to a local category', async () => { const { id } = helpers.mocks.person(); await activitypub.actors.assert([id]); const { cid } = await categories.create({ name: utils.generateUUID() }); // Two shares, one moved to local cid for (let x = 0; x < 2; x++) { const { id: pid } = helpers.mocks.note(); // eslint-disable-next-line no-await-in-loop const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 }); // eslint-disable-next-line no-await-in-loop await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid); if (!x) { // eslint-disable-next-line no-await-in-loop await topics.tools.move(tid, { cid, uid: 'system', }); } } helpers.mocks.group({ id }); await activitypub.actors.assertGroup([id]); const { topic_count, post_count } = await categories.getCategoryData(id); assert.strictEqual(topic_count, 1); assert.strictEqual(post_count, 1); }); it('should migrate any local followers into category watches', async () => { const { id } = helpers.mocks.person(); await activitypub.actors.assert([id]); const followerUid = await user.create({ username: utils.generateUUID() }); await Promise.all([ db.sortedSetAdd(`followingRemote:${followerUid}`, Date.now(), id), db.sortedSetAdd(`followersRemote:${id}`, Date.now(), followerUid), ]); helpers.mocks.group({ id }); await activitypub.actors.assertGroup([id]); const states = await categories.getWatchState([id], followerUid); assert.strictEqual(states[0], categories.watchStates.tracking); }); }); }); describe('less happy paths', () => { describe('actor with `preferredUsername` that is not all lowercase', () => { it('should save a handle-to-uid association', async () => { const preferredUsername = 'nameWITHCAPS'; const { id } = helpers.mocks.person({ preferredUsername }); await activitypub.actors.assert([id]); const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`); assert.strictEqual(uid, id); }); it('should preserve that association when re-asserted', async () => { const preferredUsername = 'nameWITHCAPS'; const { id } = helpers.mocks.person({ preferredUsername }); await activitypub.actors.assert([id]); await activitypub.actors.assert([id], { update: true }); const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`); assert.strictEqual(uid, id); }); it('should fail to assert if a passed-in ID\'s webfinger query does not respond with the same ID (gh#13352)', async () => { const { id } = helpers.mocks.person({ preferredUsername: 'foobar', }); const actorUri = `https://example.org/${utils.generateUUID()}`; activitypub.helpers._webfingerCache.set('foobar@example.org', { username: 'foobar', hostname: 'example.org', actorUri, }); const { actorUri: confirm } = await activitypub.helpers.query('foobar@example.org'); assert.strictEqual(confirm, actorUri); const response = await activitypub.actors.assert([id]); assert.deepStrictEqual(response, []); }); }); }); describe('edge cases: loopback handles and uris', () => { let uid; const userslug = utils.generateUUID().slice(0, 8); before(async () => { uid = await user.create({ username: userslug }); }); it('should return true but not actually assert the handle into the database', async () => { const handle = `${userslug}@${nconf.get('url_parsed').host}`; const result = await activitypub.actors.assert([handle]); assert(result); const handleExists = await db.isObjectField('handle:uid', handle); assert.strictEqual(handleExists, false); const userRemoteHashExists = await db.exists(`userRemote:${nconf.get('url')}/uid/${uid}`); assert.strictEqual(userRemoteHashExists, false); }); it('should return true but not actually assert the uri into the database', async () => { const uri = `${nconf.get('url')}/uid/${uid}`; const result = await activitypub.actors.assert([uri]); assert(result); const userRemoteHashExists = await db.exists(`userRemote:${uri}`); assert.strictEqual(userRemoteHashExists, false); }); }); }); describe('as:Group', () => { describe('assertion', () => { let actorUri; let actorData; before(async () => { const { id, actor } = helpers.mocks.group(); actorUri = id; actorData = actor; }); it('should assert a uri identifying as "Group" into a remote category', async () => { const assertion = await activitypub.actors.assertGroup([actorUri]); assert(assertion, Array.isArray(assertion)); assert.strictEqual(assertion.length, 1); const category = assertion.pop(); assert.strictEqual(category.cid, actorUri); }); it('should be considered existing when checked', async () => { const exists = await categories.exists(actorUri); assert(exists); }); it('should contain an entry in categories search zset', async () => { const exists = await db.isSortedSetMember('categories:name', `${actorData.name.toLowerCase()}:${actorUri}`); assert(exists); }); it('should return category data when getter methods are called', async () => { const category = await categories.getCategoryData(actorUri); assert(category); assert.strictEqual(category.cid, actorUri); }); it('should not assert non-group users when called', async () => { const { id } = helpers.mocks.person(); const assertion = await activitypub.actors.assertGroup([id]); assert(Array.isArray(assertion) && !assertion.length); }); describe('deletion', () => { it('should delete a remote category when Categories.purge is called', async () => { const { id } = helpers.mocks.group(); await activitypub.actors.assertGroup([id]); let exists = await categories.exists(id); assert(exists); await categories.purge(id, 0); exists = await categories.exists(id); assert(!exists); exists = await db.exists(`categoryRemote:${id}`); assert(!exists); }); it('should also delete AP-specific keys that were added by assertGroup', async () => { const { id } = helpers.mocks.group(); const assertion = await activitypub.actors.assertGroup([id]); const [{ handle, slug }] = assertion; await categories.purge(id, 0); const isMember = await db.isObjectField('handle:cid', handle); const inSearch = await db.isSortedSetMember('categories:name', `${slug}:${id}`); assert(!isMember); assert(!inSearch); }); }); }); describe('following', () => { let uid; let cid; beforeEach(async () => { uid = await user.create({ username: utils.generateUUID() }); ({ id: cid } = helpers.mocks.group()); await activitypub.actors.assertGroup([cid]); }); afterEach(async () => { activitypub._sent.clear(); }); describe('user not already following', () => { it('should report a watch state consistent with not following', async () => { const states = await categories.getWatchState([cid], uid); assert(states[0] <= categories.watchStates.notwatching); }); it('should do nothing when category is a local category', async () => { const { cid } = await categories.create({ name: utils.generateUUID() }); await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); assert.strictEqual(activitypub._sent.size, 0); }); it('should do nothing when watch state changes to "ignoring"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); assert.strictEqual(activitypub._sent.size, 0); }); it('should send out a Follow activity when watch state changes to "tracking"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); assert.strictEqual(activitypub._sent.size, 1); const activity = Array.from(activitypub._sent.values()).pop(); assert.strictEqual(activity.type, 'Follow'); assert.strictEqual(activity.object, cid); }); it('should send out a Follow activity when the watch state changes to "watching"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.watching); assert.strictEqual(activitypub._sent.size, 1); const activity = Array.from(activitypub._sent.values()).pop(); assert(activity && activity.object && typeof activity.object === 'string'); assert.strictEqual(activity.type, 'Follow'); assert.strictEqual(activity.object, cid); }); it('should not show up in the user\'s following list', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.watching); // Trigger inbox accept const { activity: body } = helpers.mocks.accept(cid, { type: 'Follow', actor: `${nconf.get('url')}/uid/${uid}`, }); await activitypub.inbox.accept({ body }); const following = await user.getFollowing(uid, 0, 1); assert(Array.isArray(following)); assert.strictEqual(following.length, 0); }); }); describe('user already following', () => { beforeEach(async () => { await Promise.all([ user.setCategoryWatchState(uid, cid, categories.watchStates.tracking), db.sortedSetAdd(`followingRemote:${uid}`, Date.now(), cid), ]); activitypub._sent.clear(); }); it('should report a watch state consistent with following', async () => { const states = await categories.getWatchState([cid], uid); assert(states[0] >= categories.watchStates.tracking); }); it('should do nothing when category is a local category', async () => { const { cid } = await categories.create({ name: utils.generateUUID() }); await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); assert.strictEqual(activitypub._sent.size, 0); }); it('should do nothing when watch state changes to "tracking"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking); assert.strictEqual(activitypub._sent.size, 0); }); it('should do nothing when watch state changes to "watching"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.watching); assert.strictEqual(activitypub._sent.size, 0); }); it('should send out an Undo(Follow) activity when watch state changes to "ignoring"', async () => { await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); assert.strictEqual(activitypub._sent.size, 1); const activity = Array.from(activitypub._sent.values()).pop(); assert(activity && activity.object && typeof activity.object === 'object'); assert.strictEqual(activity.type, 'Undo'); assert.strictEqual(activity.object.type, 'Follow'); assert.strictEqual(activity.object.actor, `${nconf.get('url')}/uid/${uid}`); assert.strictEqual(activity.object.object, cid); }); }); }); }); describe('Inbox resolution', () => { describe('remote users', () => { it('should return an inbox if present', async () => { const { id, actor } = helpers.mocks.person(); await activitypub.actors.assert(id); const inboxes = await activitypub.resolveInboxes([id]); assert(inboxes && Array.isArray(inboxes)); assert.strictEqual(inboxes.length, 1); assert.strictEqual(inboxes[0], actor.inbox); }); it('should return a shared inbox if present', async () => { const { id, actor } = helpers.mocks.person({ endpoints: { sharedInbox: 'https://example.org/inbox', }, }); await activitypub.actors.assert(id); const inboxes = await activitypub.resolveInboxes([id]); assert(inboxes && Array.isArray(inboxes)); assert.strictEqual(inboxes.length, 1); assert.strictEqual(inboxes[0], 'https://example.org/inbox'); }); }); describe('remote categories', () => { it('should return an inbox if present', async () => { const { id, actor } = helpers.mocks.group(); await activitypub.actors.assertGroup(id); const inboxes = await activitypub.resolveInboxes([id]); assert(inboxes && Array.isArray(inboxes)); assert.strictEqual(inboxes.length, 1); assert.strictEqual(inboxes[0], actor.inbox); }); it('should return a shared inbox if present', async () => { const { id, actor } = helpers.mocks.group({ endpoints: { sharedInbox: 'https://example.org/inbox', }, }); await activitypub.actors.assertGroup(id); const inboxes = await activitypub.resolveInboxes([id]); assert(inboxes && Array.isArray(inboxes)); assert.strictEqual(inboxes.length, 1); assert.strictEqual(inboxes[0], 'https://example.org/inbox'); }); }); }); describe('Controllers', () => { describe('User Actor endpoint', () => { let uid; let slug; beforeEach(async () => { slug = slugify(utils.generateUUID().slice(0, 8)); uid = await user.create({ username: slug }); }); it('should return a valid ActivityPub Actor JSON-LD payload', async () => { const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, }); assert(response); assert.strictEqual(response.statusCode, 200); assert(body.hasOwnProperty('@context')); assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); ['id', 'url', 'followers', 'following', 'inbox', 'outbox'].forEach((prop) => { assert(body.hasOwnProperty(prop)); assert(body[prop]); }); assert.strictEqual(body.id, `${nconf.get('url')}/uid/${uid}`); assert.strictEqual(body.type, 'Person'); }); it('should contain a `publicKey` property with a public key', async () => { const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, }); assert(body.hasOwnProperty('publicKey')); assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); }); }); describe('Category Actor endpoint', () => { let cid; let slug; let description; beforeEach(async () => { slug = slugify(utils.generateUUID().slice(0, 8)); description = utils.generateUUID(); ({ cid } = await categories.create({ name: slug, description, })); }); it('should return a valid ActivityPub Actor JSON-LD payload', async () => { const { response, body } = await request.get(`${nconf.get('url')}/category/${cid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, }); assert(response); assert.strictEqual(response.statusCode, 200); assert(body.hasOwnProperty('@context')); assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); ['id', 'url', /* 'followers', 'following', */ 'inbox', 'outbox'].forEach((prop) => { assert(body.hasOwnProperty(prop)); assert(body[prop]); }); assert.strictEqual(body.id, `${nconf.get('url')}/category/${cid}`); assert.strictEqual(body.type, 'Group'); assert(body.summary.startsWith(description)); assert.deepStrictEqual(body.icon, { type: 'Image', mediaType: 'image/png', url: `${nconf.get('url')}/assets/uploads/category/category-${cid}-icon.png`, }); }); it('should contain a `publicKey` property with a public key', async () => { const { body } = await request.get(`${nconf.get('url')}/category/${cid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, }); assert(body.hasOwnProperty('publicKey')); assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); }); it('should serve the the backgroundImage in `icon` if set', async () => { const payload = {}; payload[cid] = { backgroundImage: `/assets/uploads/files/test.png`, }; await categories.update(payload); const { body } = await request.get(`${nconf.get('url')}/category/${cid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, }); assert.deepStrictEqual(body.icon, { type: 'Image', mediaType: 'image/png', url: `${nconf.get('url')}/assets/uploads/files/test.png`, }); }); }); describe('Instance Actor endpoint', () => { let response; let body; before(async () => { ({ response, body } = await request.get(`${nconf.get('url')}/actor`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, })); }); it('should respond properly', async () => { assert(response); assert.strictEqual(response.statusCode, 200); }); it('should return a valid ActivityPub Actor JSON-LD payload', async () => { assert(body.hasOwnProperty('@context')); assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); ['id', 'url', 'inbox', 'outbox', 'name', 'preferredUsername'].forEach((prop) => { assert(body.hasOwnProperty(prop)); assert(body[prop]); }); assert.strictEqual(body.id, body.url); assert.strictEqual(body.type, 'Application'); assert.strictEqual(body.name, meta.config.site_title || 'NodeBB'); assert.strictEqual(body.preferredUsername, nconf.get('url_parsed').hostname); }); it('should contain a `publicKey` property with a public key', async () => { assert(body.hasOwnProperty('publicKey')); assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); }); it('should also have a valid WebFinger response tied to `preferredUsername`', async () => { const { response, body: body2 } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${body.preferredUsername}@${nconf.get('url_parsed').host}`); assert.strictEqual(response.statusCode, 200); assert(body2 && body2.aliases && body2.links); assert(body2.aliases.includes(nconf.get('url'))); assert(body2.links.some(item => item.rel === 'self' && item.type === 'application/activity+json' && item.href === `${nconf.get('url')}/actor`)); }); }); describe('Topic Collection endpoint', () => { let cid; let uid; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); const slug = slugify(utils.generateUUID().slice(0, 8)); uid = await user.create({ username: slug }); }); describe('Live', () => { let topicData; let response; let body; before(async () => { ({ topicData } = await topics.post({ uid, cid, title: 'Lorem "Lipsum" Ipsum', content: 'Lorem ipsum dolor sit amet', })); ({ response, body } = await request.get(`${nconf.get('url')}/topic/${topicData.slug}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, })); }); it('should respond properly', async () => { assert(response); assert.strictEqual(response.statusCode, 200); }); it('should return an OrderedCollection with one item', () => { assert.strictEqual(body.type, 'OrderedCollection'); assert.strictEqual(body.totalItems, 1); assert(Array.isArray(body.orderedItems)); assert.strictEqual(body.orderedItems[0], `${nconf.get('url')}/post/${topicData.mainPid}`); }); }); describe('Scheduled', () => { let topicData; let response; let body; before(async () => { ({ topicData } = await topics.post({ uid, cid, title: 'Lorem "Lipsum" Ipsum', content: 'Lorem ipsum dolor sit amet', timestamp: Date.now() + (1000 * 60 * 60), // 1 hour in the future })); ({ response, body } = await request.get(`${nconf.get('url')}/topic/${topicData.slug}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, })); }); it('should respond with a 404 Not Found', async () => { assert(response); assert.strictEqual(response.statusCode, 404); }); }); }); describe('Post Object endpoint', () => { let cid; let uid; before(async () => { ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); const slug = slugify(utils.generateUUID().slice(0, 8)); uid = await user.create({ username: slug }); }); describe('Live', () => { let postData; let response; let body; before(async () => { ({ postData } = await topics.post({ uid, cid, title: 'Lorem "Lipsum" Ipsum', content: 'Lorem ipsum dolor sit amet', })); ({ response, body } = await request.get(`${nconf.get('url')}/post/${postData.pid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, })); }); it('should respond properly', async () => { assert(response); assert.strictEqual(response.statusCode, 200); }); it('should return a Note type object', () => { assert.strictEqual(body.type, 'Note'); }); }); describe('Scheduled', () => { let topicData; let postData; let response; let body; before(async () => { ({ topicData, postData } = await topics.post({ uid, cid, title: 'Lorem "Lipsum" Ipsum', content: 'Lorem ipsum dolor sit amet', timestamp: Date.now() + (1000 * 60 * 60), // 1 hour in the future })); ({ response, body } = await request.get(`${nconf.get('url')}/post/${postData.pid}`, { headers: { Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, })); }); it('should respond with a 404 Not Found', async () => { assert(response); assert.strictEqual(response.statusCode, 404); }); }); }); }); describe('Pruning', () => { before(async () => { meta.config.activitypubEnabled = 1; await install.giveWorldPrivileges(); meta.config.activitypubUserPruneDays = 0; // trigger immediate pruning }); after(() => { meta.config.activitypubUserPruneDays = 7; }); describe('Users', () => { it('should do nothing if the user is newer than the prune cutoff', async () => { const { id: uid } = helpers.mocks.person(); await activitypub.actors.assert([uid]); meta.config.activitypubUserPruneDays = 1; const result = await activitypub.actors.prune(); assert.strictEqual(result.counts.deleted, 0); assert.strictEqual(result.counts.preserved, 0); assert.strictEqual(result.counts.missing, 0); meta.config.activitypubUserPruneDays = 0; await user.deleteAccount(uid); }); it('should purge the user if they have no content (posts, likes, etc.)', async () => { const { id: uid } = helpers.mocks.person(); await activitypub.actors.assert([uid]); const total = await db.sortedSetCard('usersRemote:lastCrawled'); const result = await activitypub.actors.prune(); assert(result.counts.deleted >= 1); }); it('should do nothing if the user has some content (e.g. a topic)', async () => { const { cid } = await categories.create({ name: utils.generateUUID() }); const { id: uid } = helpers.mocks.person(); const { id, note } = helpers.mocks.note({ attributedTo: uid, cc: [`${nconf.get('url')}/category/${cid}`], }); const assertion = await activitypub.notes.assert(0, id); assert(assertion); const result = await activitypub.actors.prune(); assert.strictEqual(result.counts.deleted, 0); assert.strictEqual(result.counts.preserved, 1); assert.strictEqual(result.counts.missing, 0); }); }); describe('Categories', () => { it('should do nothing if the category is newer than the prune cutoff', async () => { const { id: cid } = helpers.mocks.group(); await activitypub.actors.assertGroup([cid]); meta.config.activitypubUserPruneDays = 1; const result = await activitypub.actors.prune(); assert.strictEqual(result.counts.deleted, 0); assert.strictEqual(result.counts.preserved, 0); assert.strictEqual(result.counts.missing, 0); meta.config.activitypubUserPruneDays = 0; await categories.purge(cid, 0); }); it('should purge the category if it has no topics in it', async () => { const { id: cid } = helpers.mocks.group(); await activitypub.actors.assertGroup([cid]); const total = await db.sortedSetCard('usersRemote:lastCrawled'); const result = await activitypub.actors.prune(); assert.strictEqual(result.counts.deleted, 1); assert.strictEqual(result.counts.preserved, total - 1); }); it('should do nothing if the category has topics in it', async () => { const { id: cid } = helpers.mocks.group(); await activitypub.actors.assertGroup([cid]); const { id } = helpers.mocks.note({ cc: [cid], }); await activitypub.notes.assert(0, id, { cid }); const total = await db.sortedSetCard('usersRemote:lastCrawled'); const result = await activitypub.actors.prune(); assert.strictEqual(result.counts.deleted, 0); assert.strictEqual(result.counts.preserved, total); assert(result.preserved.has(cid)); }); }); });