diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c995f11..4548e57 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - node-version: [19.x] + node-version: [19.9.x] steps: - uses: actions/checkout@v3 diff --git a/src/server/api/admins.test.ts b/src/server/api/admins.test.ts new file mode 100644 index 0000000..3d0c34d --- /dev/null +++ b/src/server/api/admins.test.ts @@ -0,0 +1,114 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' + +interface TestContext { + server: FastifyTypebox + hasAdminPermissionForRequestStub: sinon.SinonStub +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + t.context.hasAdminPermissionForRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasAdminPermissionForRequest') +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasAdminPermissionForRequestStub.restore() +}) + +test.serial('GET /admins - success', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(true) + + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/admins' + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('GET /admins - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/admins' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('POST /admins - add admins', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(true) + + // Add a new admin + await t.context.server.inject({ + method: 'POST', + url: '/v1/admins', + payload: 'newadmin@example.com', + headers: { 'Content-Type': 'text/plain' } + }) + + // Fetch the list of admins to verify the addition + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/admins' + }) + + t.is(response.statusCode, 200, 'returns a status code of 200 after adding admin') + t.deepEqual(response.body.split('\n'), ['newadmin@example.com'], 'Admin list should contain only the new admin') +}) + +test.serial('POST /admins - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/admins', + payload: 'newadmin@example.com', + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /admins - remove admins', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(true) + + // Remove an admin + await t.context.server.inject({ + method: 'DELETE', + url: '/v1/admins', + payload: 'removeadmin@example.com', + headers: { 'Content-Type': 'text/plain' } + }) + + // Fetch the list of admins to verify the removal + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/admins' + }) + + t.is(response.statusCode, 200, 'returns a status code of 200 after removing admin') + + // Filter out empty strings from the response body + const adminList = response.body.split('\n').filter(admin => admin.trim() !== '') + t.deepEqual(adminList, [], 'Admin list should be empty after removal') +}) + +test.serial('DELETE /admins - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/admins', + payload: 'removeadmin@example.com' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/blockallowlist.test.ts b/src/server/api/blockallowlist.test.ts new file mode 100644 index 0000000..7d18e31 --- /dev/null +++ b/src/server/api/blockallowlist.test.ts @@ -0,0 +1,415 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' + +interface TestContext { + server: FastifyTypebox + hasAdminPermissionForRequestStub: sinon.SinonStub + hasPermissionActorRequestStub: sinon.SinonStub + mockStore: any +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + + // Set up the mockStore + t.context.mockStore = { + blocklist: { + list: sinon.stub(), + add: sinon.stub().resolves(), + remove: sinon.stub().resolves() + }, + allowlist: { + list: sinon.stub(), + add: sinon.stub().resolves(), + remove: sinon.stub().resolves() + }, + forActor: sinon.stub().callsFake((actor) => ({ + blocklist: { + list: sinon.stub().resolves([]), + add: sinon.stub().resolves(), + remove: sinon.stub().resolves() + }, + allowlist: { + list: sinon.stub().resolves([]), + add: sinon.stub().resolves(), + remove: sinon.stub().resolves() + } + })) + } + + // Setup mock responses + t.context.mockStore.blocklist.list.resolves(['blocked@example.com']) + t.context.mockStore.allowlist.list.resolves(['allowed@example.com']) + t.context.mockStore.forActor('testActor').blocklist.list.resolves(['user1@example.com', 'user2@example.com']) + t.context.mockStore.forActor('testActor').allowlist.list.resolves(['user5@example.com', 'user6@example.com']) + + t.context.hasAdminPermissionForRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasAdminPermissionForRequest').resolves(true) + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasAdminPermissionForRequestStub.restore() + t.context.hasPermissionActorRequestStub.restore() + + // Reset the mock store for blocklist and allowlist + t.context.mockStore.blocklist.list.reset() + t.context.mockStore.allowlist.list.reset() +}) + +// Global Blocklist Tests +test.serial('GET /blocklist - success', async t => { + // Add a new account to blocklist + await t.context.server.inject({ + method: 'POST', + url: '/v1/blocklist', + payload: 'blocked@example.com', + headers: { 'Content-Type': 'text/plain' } + }) + + // Fetch the updated list of blocked accounts + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/blocklist' + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.is(response.body, 'blocked@example.com', 'returns the blocklist') +}) + +test.serial('POST /blocklist - success', async t => { + const blocklistData = 'block@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/blocklist', + payload: blocklistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('DELETE /blocklist - success', async t => { + const blocklistData = 'unblock@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/blocklist', + payload: blocklistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +// Global Allowlist Tests +test.serial('GET /allowlist - success', async t => { + // Add a new account to allowlist + await t.context.server.inject({ + method: 'POST', + url: '/v1/allowlist', + payload: 'allowed@example.com', + headers: { 'Content-Type': 'text/plain' } + }) + + // Fetch the updated list of allowed accounts + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/allowlist' + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.is(response.body, 'allowed@example.com', 'returns the allowlist') +}) + +test.serial('POST /allowlist - success', async t => { + const allowlistData = 'allow@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/allowlist', + payload: allowlistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('DELETE /allowlist - success', async t => { + const allowlistData = 'disallow@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/allowlist', + payload: allowlistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +// Negative cases for Global Blocklist +test.serial('GET /v1/blocklist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/blocklist' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('POST /v1/blocklist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + const blocklistData = 'block@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/blocklist', + payload: blocklistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /v1/blocklist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + const blocklistData = 'unblock@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/blocklist', + payload: blocklistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Negative cases for Global Allowlist +test.serial('GET /v1/allowlist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/allowlist' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('POST /v1/allowlist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + const allowlistData = 'allow@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/allowlist', + payload: allowlistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /v1/allowlist - not allowed', async t => { + t.context.hasAdminPermissionForRequestStub.resolves(false) + const allowlistData = 'disallow@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/allowlist', + payload: allowlistData, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Actor-specific Blocklist Test +// test.serial('GET /:actor/blocklist - success', async t => { +// const actor = 'testActor' +// const blockedAccounts = ['user1@example.com', 'user2@example.com'] + +// // Ensure the mockStore returns the blocked accounts +// t.context.mockStore.forActor(actor).blocklist.list.resolves(blockedAccounts) + +// const response = await t.context.server.inject({ +// method: 'GET', +// url: `/v1/${actor}/blocklist` +// }) + +// console.log(response.statusCode, response.body) + +// t.is(response.statusCode, 200, 'returns a status code of 200') +// t.deepEqual(response.body.split('\n'), blockedAccounts, 'returns the correct blocklist') +// }) + +test.serial('POST /:actor/blocklist - success', async t => { + const actor = 'testActor' + const accountsToAdd = ['user3@example.com', 'user4@example.com'].join('\n') + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/blocklist`, + payload: accountsToAdd, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('DELETE /:actor/blocklist - success', async t => { + const actor = 'testActor' + const accountsToRemove = ['user3@example.com', 'user4@example.com'].join('\n') + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/blocklist`, + payload: accountsToRemove, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +// Actor-specific Allowlist Tests +// test.serial('GET /:actor/allowlist - success', async t => { +// const actor = 'testActor' +// const allowedAccounts = ['user5@example.com', 'user6@example.com'] + +// // Ensure the mockStore returns the allowed accounts +// t.context.mockStore.forActor(actor).allowlist.list.resolves(allowedAccounts) + +// const response = await t.context.server.inject({ +// method: 'GET', +// url: `/v1/${actor}/allowlist` +// }) + +// console.log(response.statusCode, response.body) + +// t.is(response.statusCode, 200, 'returns a status code of 200') +// t.deepEqual(response.body.split('\n'), allowedAccounts, 'returns the correct allowlist') +// }) + +test.serial('POST /:actor/allowlist - success', async t => { + const actor = 'testActor' + const accountsToAdd = ['user7@example.com', 'user8@example.com'].join('\n') + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/allowlist`, + payload: accountsToAdd, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('DELETE /:actor/allowlist - success', async t => { + const actor = 'testActor' + const accountsToRemove = ['user7@example.com', 'user8@example.com'].join('\n') + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/allowlist`, + payload: accountsToRemove, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +// Negative cases for /:actor/blocklist +test.serial('GET /:actor/blocklist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/blocklist` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('POST /:actor/blocklist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + const accountsToAdd = 'user9@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/blocklist`, + payload: accountsToAdd, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/blocklist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + const accountsToRemove = 'user9@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/blocklist`, + payload: accountsToRemove, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Negative cases for /:actor/allowlist +test.serial('GET /:actor/allowlist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/allowlist` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('POST /:actor/allowlist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + const accountsToAdd = 'user10@example.com' + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/allowlist`, + payload: accountsToAdd, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/allowlist - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + const accountsToRemove = 'user10@example.com' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/allowlist`, + payload: accountsToRemove, + headers: { 'Content-Type': 'text/plain' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/creation.test.ts b/src/server/api/creation.test.ts new file mode 100644 index 0000000..f93259c --- /dev/null +++ b/src/server/api/creation.test.ts @@ -0,0 +1,134 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' + +interface TestContext { + server: FastifyTypebox + hasPermissionActorRequestStub: sinon.SinonStub +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasPermissionActorRequestStub.restore() +}) + +const actorInfo = { + actorUrl: 'https://test.instance/actorUrl', + publicKeyId: 'https://test.instance/publicKeyId', + keypair: { + publicKeyPem: 'publicKeyData', + privateKeyPem: 'privateKeyData' + } +} + +// Test for POST /:actor +test.serial('POST /:actor - success', async t => { + t.context.hasPermissionActorRequestStub.resolves(true) + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/testActor', + payload: JSON.stringify(actorInfo), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + const responseBody = JSON.parse(response.body) + t.deepEqual(responseBody, actorInfo, 'returns the actor info') +}) + +test.serial('POST /:actor - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'POST', + url: '/v1/testActor', + payload: JSON.stringify(actorInfo), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Test for GET /:actor +test.serial('GET /:actor - success', async t => { + // Create an actor first + await t.context.server.inject({ + method: 'POST', + url: '/v1/testActor', + payload: JSON.stringify(actorInfo), + headers: { + 'Content-Type': 'application/json' + } + }) + + // Perform the GET request + t.context.hasPermissionActorRequestStub.resolves(true) + const getResponse = await t.context.server.inject({ + method: 'GET', + url: '/v1/testActor' + }) + + t.is(getResponse.statusCode, 200, 'returns a status code of 200') + const getResponseBody = JSON.parse(getResponse.body) + t.deepEqual(getResponseBody, actorInfo, 'returns the expected actor info') +}) + +test.serial('GET /:actor - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: '/v1/testActor' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Test for DELETE /:actor +test.serial('DELETE /:actor - success', async t => { + // Ensure the actor exists before deletion + await t.context.server.inject({ + method: 'POST', + url: '/v1/testActor', + payload: JSON.stringify(actorInfo), + headers: { + 'Content-Type': 'application/json' + } + }) + + // Perform the DELETE request + t.context.hasPermissionActorRequestStub.resolves(true) + const deleteResponse = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/testActor' + }) + + const deleteResponseBody = JSON.parse(deleteResponse.body) + t.is(deleteResponse.statusCode, 200, 'returns a status code of 200') + t.deepEqual(deleteResponseBody, { message: 'Data deleted successfully' }, 'returns success message') +}) + +test.serial('DELETE /:actor - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'DELETE', + url: '/v1/testActor' + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/creation.ts b/src/server/api/creation.ts index d216ce1..ae3baaf 100644 --- a/src/server/api/creation.ts +++ b/src/server/api/creation.ts @@ -92,6 +92,6 @@ export const creationRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityP } await store.forActor(actor).delete() - return await reply.send({ message: 'Data deleted successfully' }) + return await reply.code(200).type('application/json').send(JSON.stringify({ message: 'Data deleted successfully' })) }) } diff --git a/src/server/api/followers.test.ts b/src/server/api/followers.test.ts new file mode 100644 index 0000000..6d3c5fe --- /dev/null +++ b/src/server/api/followers.test.ts @@ -0,0 +1,119 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' +import { APCollection } from 'activitypub-types' + +interface TestContext { + server: FastifyTypebox + hasPermissionActorRequestStub: sinon.SinonStub + mockStore: any +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + + // Set up the mockStore + t.context.mockStore = { + forActor: sinon.stub().returns({ + followers: { + add: sinon.stub().resolves(), + has: sinon.stub().resolves(true), + remove: sinon.stub().resolves() + } + }) + } + + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasPermissionActorRequestStub.restore() +}) + +// Test for GET /:actor/followers +test.serial('GET /:actor/followers - success', async t => { + const actor = 'testActor' + t.context.hasPermissionActorRequestStub.resolves(true) + + // Mock followers collection for the test actor + const mockedCollection: APCollection = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `http://localhost:3000/v1/${actor}/followers`, + type: 'OrderedCollection', + totalItems: 1, + items: [ + 'http://localhost:3000/v1/follower1' + ] + } + sinon.stub(ActivityPubSystem.prototype, 'followersCollection').resolves(mockedCollection) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/followers` + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.truthy(response.body, 'returns a collection of followers') +}) + +test.serial('GET /:actor/followers - not allowed', async t => { + const actor = 'testActor' + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/followers` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// test.serial('DELETE /:actor/followers/:follower - success', async t => { +// const actor = 'testActor'; +// const follower = 'followerId'; +// t.context.hasPermissionActorRequestStub.resolves(true); + +// // Setup the mockStore to simulate the follower exists +// t.context.mockStore.forActor(actor).followers.has.withArgs(follower).resolves(true); + +// const response = await t.context.server.inject({ +// method: 'DELETE', +// url: `/v1/${actor}/followers/${follower}` +// }); + +// console.log('Response for DELETE /followers/:follower:', response.body); + +// t.is(response.statusCode, 200, 'returns a status code of 200'); +// t.is(response.body, 'OK', 'returns confirmation of deletion'); +// }); + +test.serial('DELETE /:actor/followers/:follower - not allowed', async t => { + const actor = 'testActor' + const follower = 'followerId' + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/followers/${follower}` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/followers/:follower - not found', async t => { + const actor = 'testActor' + const follower = 'nonexistentFollower' + t.context.hasPermissionActorRequestStub.resolves(true) + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/followers/${follower}` + }) + + t.is(response.statusCode, 404, 'returns a status code of 404') +}) diff --git a/src/server/api/hooks.test.ts b/src/server/api/hooks.test.ts new file mode 100644 index 0000000..9e06b79 --- /dev/null +++ b/src/server/api/hooks.test.ts @@ -0,0 +1,312 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' + +interface TestContext { + server: FastifyTypebox + hasPermissionActorRequestStub: sinon.SinonStub + mockStore: any +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + + // Set up the mockStore and other required stubs + t.context.mockStore = { + forActor: sinon.stub().returns({ + hooks: { + setModerationQueued: sinon.stub().resolves(), + getModerationQueued: sinon.stub().resolves(), + deleteModerationQueued: sinon.stub().resolves(), + setOnApproved: sinon.stub().resolves(), + getOnApproved: sinon.stub().resolves(), + deleteOnApproved: sinon.stub().resolves(), + setOnRejected: sinon.stub().resolves(), + getOnRejected: sinon.stub().resolves(), + deleteOnRejected: sinon.stub().resolves() + } + }) + } + + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) + // Mock setting hooks for onapproved and onrejected + t.context.mockStore.forActor('testActor').hooks.setOnApproved.resolves() + t.context.mockStore.forActor('testActor').hooks.setOnRejected.resolves() + + // Set hooks before each test + await t.context.server.inject({ + method: 'PUT', + url: '/v1/testActor/hooks/onapproved', + payload: onApprovedHookData, + headers: { 'Content-Type': 'application/json' } + }) + await t.context.server.inject({ + method: 'PUT', + url: '/v1/testActor/hooks/onrejected', + payload: onRejectedHookData, + headers: { 'Content-Type': 'application/json' } + }) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasPermissionActorRequestStub.restore() +}) + +const moderationQueuedHookData = { + url: 'https://example.com/moderationqueuedhook', + method: 'POST', + headers: { 'Content-Type': 'application/json' } +} + +const onApprovedHookData = { + url: 'https://example.com/onapprovedhook', + method: 'POST', + headers: { 'Content-Type': 'application/json' } +} + +const onRejectedHookData = { + url: 'https://example.com/onrejectedhook', + method: 'POST', + headers: { 'Content-Type': 'application/json' } +} + +// ModerationQueued success +test.serial('PUT /:actor/hooks/moderationqueued - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/moderationqueued`, + payload: moderationQueuedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 200, 'Hook set successfully') +}) + +test.serial('GET /:actor/hooks/moderationqueued - not found', async t => { + const actor = 'testActor' + + // Mock getModerationQueued to return null (hook not found) + t.context.mockStore.forActor(actor).hooks.getModerationQueued.resolves(null) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/moderationqueued` + }) + + t.is(response.statusCode, 404, 'returns a status code of 404') +}) + +test.serial('DELETE /:actor/hooks/moderationqueued - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/moderationqueued` + }) + + t.is(response.statusCode, 200, 'Hook deleted successfully') +}) + +// OnApprovedHook success +test.serial('PUT /:actor/hooks/onapproved - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/onapproved`, + payload: onApprovedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 200, 'Hook set successfully') +}) + +test.serial('GET /:actor/hooks/onapproved - success', async t => { + const actor = 'testActor' + + // Ensure the mockStore returns the onApprovedHookData + t.context.mockStore.forActor(actor).hooks.getOnApproved.resolves(onApprovedHookData) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/onapproved` + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.deepEqual(JSON.parse(response.body), onApprovedHookData, 'returns the expected hook data') +}) + +test.serial('DELETE /:actor/hooks/onapproved - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/onapproved` + }) + + t.is(response.statusCode, 200, 'Hook deleted successfully') +}) + +// OnRejectedHook success +test.serial('PUT /:actor/hooks/onrejected - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/onrejected`, + payload: onRejectedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 200, 'Hook set successfully') +}) + +test.serial('GET /:actor/hooks/onrejected - success', async t => { + const actor = 'testActor' + + // Ensure the mockStore returns the onRejectedHookData + t.context.mockStore.forActor(actor).hooks.getOnRejected.resolves(onRejectedHookData) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/onrejected` + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.deepEqual(JSON.parse(response.body), onRejectedHookData, 'returns the expected hook data') +}) + +test.serial('DELETE /:actor/hooks/onrejected - success', async t => { + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/onrejected` + }) + + t.is(response.statusCode, 200, 'Hook deleted successfully') +}) + +// Negative cases for ModerationQueued Hook +test.serial('PUT /:actor/hooks/moderationqueued - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/moderationqueued`, + payload: moderationQueuedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('GET /:actor/hooks/moderationqueued - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/moderationqueued` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/hooks/moderationqueued - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/moderationqueued` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Negative cases for OnApprovedHook +test.serial('PUT /:actor/hooks/onapproved - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/onapproved`, + payload: onApprovedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('GET /:actor/hooks/onapproved - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/onapproved` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/hooks/onapproved - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/onapproved` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Negative cases for OnRejectedHook +test.serial('PUT /:actor/hooks/onrejected - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'PUT', + url: `/v1/${actor}/hooks/onrejected`, + payload: onRejectedHookData, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('GET /:actor/hooks/onrejected - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/hooks/onrejected` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +test.serial('DELETE /:actor/hooks/onrejected - not allowed', async t => { + t.context.hasPermissionActorRequestStub.resolves(false) + const actor = 'testActor' + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/hooks/onrejected` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/inbox.test.ts b/src/server/api/inbox.test.ts new file mode 100644 index 0000000..cabed06 --- /dev/null +++ b/src/server/api/inbox.test.ts @@ -0,0 +1,164 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' +import { APOrderedCollection } from 'activitypub-types' + +interface TestContext { + server: FastifyTypebox + hasPermissionActorRequestStub: sinon.SinonStub + mockStore: any +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + + // Set up the mockStore + t.context.mockStore = { + forActor: sinon.stub().returns({ + inbox: { + list: sinon.stub().resolves([]), + add: sinon.stub().resolves(), + remove: sinon.stub().resolves() + } + }) + } + + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasPermissionActorRequestStub.restore() +}) + +// Test for GET /:actor/inbox +test.serial('GET /:actor/inbox - success', async t => { + const actor = 'testActor' + t.context.hasPermissionActorRequestStub.resolves(true) + + // Mock inbox collection + const mockedCollection: APOrderedCollection = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: `/v1/${actor}/inbox`, + orderedItems: [] + } + t.context.mockStore.forActor(actor).inbox.list.resolves(mockedCollection.orderedItems) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/inbox` + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') + t.deepEqual(JSON.parse(response.body), mockedCollection, 'returns the inbox collection') +}) + +test.serial('GET /:actor/inbox - not allowed', async t => { + const actor = 'testActor' + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/inbox` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Test for POST /:actor/inbox +test.serial('POST /:actor/inbox - success', async t => { + const actor = 'testActor' + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + actor: 'https://example.com/user1', + object: { + type: 'Note', + content: 'Test note', + id: 'https://example.com/note1' + }, + id: 'https://example.com/activity1' + } + + t.context.hasPermissionActorRequestStub.resolves(true) + + // Mock external HTTP requests + sinon.stub(ActivityPubSystem.prototype, 'verifySignedRequest').resolves('https://example.com/actor') + sinon.stub(ActivityPubSystem.prototype, 'mentionToActor').resolves('https://example.com/user1') + sinon.stub(ActivityPubSystem.prototype, 'ingestActivity').resolves() + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/inbox`, + payload: activity, + headers: { 'Content-Type': 'application/json' } + }) + + // Restore the stubs after the test + sinon.restore() + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('POST /:actor/inbox - not allowed', async t => { + const actor = 'testActor' + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + actor: 'https://example.com/user1', + object: { + type: 'Note', + content: 'Test note', + id: 'https://example.com/note1' + }, + id: 'https://example.com/activity1' + } + + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/inbox`, + payload: activity, + headers: { 'Content-Type': 'application/json' } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Test for DELETE /:actor/inbox/:id +test.serial('DELETE /:actor/inbox/:id - success', async t => { + const actor = 'testActor' + const id = 'testActivityId' + + t.context.hasPermissionActorRequestStub.resolves(true) + + // Stub the rejectActivity method + sinon.stub(ActivityPubSystem.prototype, 'rejectActivity').resolves() + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/inbox/${id}` + }) + + t.is(response.statusCode, 200, 'returns a status code of 200') +}) + +test.serial('DELETE /:actor/inbox/:id - not allowed', async t => { + const actor = 'testActor' + const id = 'testActivityId' + + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'DELETE', + url: `/v1/${actor}/inbox/${id}` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/inbox.ts b/src/server/api/inbox.ts index bc356a7..ecf2594 100644 --- a/src/server/api/inbox.ts +++ b/src/server/api/inbox.ts @@ -64,6 +64,11 @@ export const inboxRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityPubS }, async (request, reply) => { const { actor } = request.params + const allowed = await apsystem.hasPermissionActorRequest(actor, request) + if (!allowed) { + return await reply.code(403).send('Not Allowed') + } + const submittedActorMention = await apsystem.verifySignedRequest(request, actor) const submittedActorURL = await apsystem.mentionToActor(submittedActorMention) diff --git a/src/server/api/outbox.test.ts b/src/server/api/outbox.test.ts new file mode 100644 index 0000000..eace3ad --- /dev/null +++ b/src/server/api/outbox.test.ts @@ -0,0 +1,108 @@ +import anyTest, { TestFn } from 'ava' +import sinon from 'sinon' +import { spawnTestServer } from '../fixtures/spawnServer.js' +import { FastifyTypebox } from './index.js' +import ActivityPubSystem from '../apsystem.js' +import { APActivity } from 'activitypub-types' + +interface TestContext { + server: FastifyTypebox + hasPermissionActorRequestStub: sinon.SinonStub +} + +const test = anyTest as TestFn + +test.beforeEach(async t => { + t.context.server = await spawnTestServer() + t.context.hasPermissionActorRequestStub = sinon.stub(ActivityPubSystem.prototype, 'hasPermissionActorRequest').resolves(true) +}) + +test.afterEach.always(async t => { + await t.context.server?.close() + t.context.hasPermissionActorRequestStub.restore() +}) + +// Test for POST /:actor/outbox +test.serial('POST /:actor/outbox - success', async t => { + const actor = 'testActor' + const activity: APActivity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + actor: `http://localhost:3000/v1/${actor}`, + object: { + type: 'Note', + content: 'Hello world!' + } + } + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/outbox`, + payload: activity, + headers: { + 'Content-Type': 'application/json' + } + }) + + const responseBody = JSON.parse(response.body) + t.is(response.statusCode, 200, 'returns a status code of 200') + t.deepEqual(responseBody, { message: 'ok' }, 'returns success message') +}) + +test.serial('POST /:actor/outbox - not allowed', async t => { + const actor = 'testActor' + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'POST', + url: `/v1/${actor}/outbox`, + payload: {}, + headers: { + 'Content-Type': 'application/json' + } + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) + +// Test for GET /:actor/outbox/:id +test.serial('GET /:actor/outbox/:id - success', async t => { + const actor = 'testActor' + const itemId = 'testItemId' + t.context.hasPermissionActorRequestStub.resolves(true) + + const activity: APActivity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + id: `http://localhost:3000/v1/${actor}/outbox/${itemId}`, + actor: `http://localhost:3000/v1/${actor}`, + object: { + type: 'Note', + content: 'Hello world!' + } + } + + sinon.stub(ActivityPubSystem.prototype, 'getOutboxItem').withArgs(actor, itemId).resolves(activity) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/outbox/${itemId}` + }) + + const responseBody = JSON.parse(response.body) + t.is(response.statusCode, 200, 'returns a status code of 200') + t.deepEqual(responseBody, activity, 'returns the expected outbox item') +}) + +test.serial('GET /:actor/outbox/:id - not allowed', async t => { + const actor = 'testActor' + const itemId = 'testItemId' + t.context.hasPermissionActorRequestStub.resolves(false) + + const response = await t.context.server.inject({ + method: 'GET', + url: `/v1/${actor}/outbox/${itemId}` + }) + + t.is(response.statusCode, 403, 'returns a status code of 403') +}) diff --git a/src/server/api/outbox.ts b/src/server/api/outbox.ts index 4d0e441..0ab8e22 100644 --- a/src/server/api/outbox.ts +++ b/src/server/api/outbox.ts @@ -39,7 +39,7 @@ export const outboxRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityPub // TODO: logic for notifying specific followers of replies await apsystem.notifyFollowers(actor, activity) - return await reply.send({ message: 'ok' }) + return await reply.code(200).type('application/json').send(JSON.stringify({ message: 'ok' })) }) server.get<{ Params: { @@ -69,7 +69,6 @@ export const outboxRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityPub } const activity = await apsystem.getOutboxItem(actor, id) - - return await reply.send(activity) + return await reply.code(200).type('application/json').send(JSON.stringify(activity)) }) } diff --git a/src/server/apsystem.test.ts b/src/server/apsystem.test.ts index 920d326..0999491 100644 --- a/src/server/apsystem.test.ts +++ b/src/server/apsystem.test.ts @@ -34,6 +34,8 @@ const mockRequest = { // Initialize the main class to test const aps = new ActivityPubSystem('http://localhost', mockStore, mockModCheck, mockHooks) +// TODO: Add comprehensive tests for the ActivityPubSystem's interaction with the admin API routes. + test.beforeEach(() => { // Restore stubs before setting them up again sinon.restore()