diff --git a/docker-compose.yml b/docker-compose.yml index 5e59662..070768f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,7 @@ services: - NODE_ENV=testing - SKIP_SIGNATURE_VERIFICATION=true - ALLOW_PRIVATE_ADDRESS=true + - NODE_TLS_REJECT_UNAUTHORIZED=0 command: node --import tsx --watch src/app.ts depends_on: mysql-testing: @@ -112,7 +113,6 @@ services: activitypub-testing: condition: service_healthy - mysql-testing: image: mysql:8.0.31 environment: @@ -120,6 +120,8 @@ services: - MYSQL_USER=ghost - MYSQL_PASSWORD=password - MYSQL_DATABASE=activitypub + ports: + - "3308:3306" healthcheck: test: "mysql -ughost -ppassword activitypub -e 'select 1'" interval: 1s @@ -127,13 +129,13 @@ services: fake-ghost-activitypub: image: wiremock/wiremock:3.9.2-1 - entrypoint: ["/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose", "--port=80"] + entrypoint: ["/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose", "--port=80", "--https-port=443"] volumes: - ./wiremock/fake-ghost/mappings:/home/wiremock/mappings fake-external-activitypub: image: wiremock/wiremock:3.9.2-1 - entrypoint: ["/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose", "--port=80"] + entrypoint: ["/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose", "--port=80", "--https-port=443"] fake-mastodon: image: wiremock/wiremock:3.9.2-1 diff --git a/features/api/profile.feature b/features/api/profile.feature new file mode 100644 index 0000000..50b6fca --- /dev/null +++ b/features/api/profile.feature @@ -0,0 +1,11 @@ +@api +Feature: Profile + In order to view the details of a profile + As an API user + I want to be able to retrieve a profile + + Scenario: Succesfully retreiving a profile + Given an Actor "Alice" + When I request the profile for "Alice" + Then the response has a 200 status code + And the response body contains the profile for "Alice" diff --git a/features/api/search.feature b/features/api/search.feature new file mode 100644 index 0000000..39f20bf --- /dev/null +++ b/features/api/search.feature @@ -0,0 +1,11 @@ +@api +Feature: Search + In order to find the details of an actor + As an API user + I want to be able to search for an actor + + Scenario: Succesfully searching for an actor + Given an Actor "Alice" + When I search for "Alice" + Then the response has a 200 status code + And the response body contains search results for "Alice" diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 25fc189..953a609 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -87,6 +87,10 @@ async function createActivity(activityType, object, actor, remote = true) { } } +function getActorHandle(actor, remote = true) { + return `@${actor.preferredUsername}@${remote ? 'fake-external-activitypub' : 'fake-ghost-activitypub'}`; +} + async function createActor(name = 'Test', remote = true) { if (remote === false) { return { @@ -118,42 +122,120 @@ async function createActor(name = 'Test', remote = true) { } } - // Register endpoints with wiremock - for now just inbox - - externalActivityPub.register({ - method: 'POST', - endpoint: `/inbox/${name}` - }, { - status: 202 - }); - - return { + const actor = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/data-integrity/v1', ], - 'id': `http://fake-external-activitypub/user/${name}`, - 'url': `http://fake-external-activitypub/user/${name}`, - 'type': 'Person', + id: `http://fake-external-activitypub/user/${name}`, + url: `http://fake-external-activitypub/user/${name}`, + type: 'Person', - 'preferredUsername': name, - 'name': name, - 'summary': 'A test actor for testing', + preferredUsername: name, + name: name, + summary: 'A test actor for testing', - 'inbox': `http://fake-external-activitypub/inbox/${name}`, - 'outbox': `http://fake-external-activitypub/inbox/${name}`, - 'followers': `http://fake-external-activitypub/followers/${name}`, - 'following': `http://fake-external-activitypub/following/${name}`, + inbox: `http://fake-external-activitypub/inbox/${name}`, + outbox: `http://fake-external-activitypub/outbox/${name}`, + followers: `http://fake-external-activitypub/followers/${name}`, + following: `http://fake-external-activitypub/following/${name}`, 'https://w3id.org/security#publicKey': { - 'id': 'http://fake-external-activitypub/user#main-key', - 'type': 'https://w3id.org/security#Key', + id: 'http://fake-external-activitypub/user#main-key', + type: 'https://w3id.org/security#Key', 'https://w3id.org/security#owner': { - 'id': 'http://fake-external-activitypub/user' + id: 'http://fake-external-activitypub/user' }, 'https://w3id.org/security#publicKeyPem': '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtSc3IqGjRaO3vcFdQ15D\nF90WVJC6tb2QwYBh9kQYVlQ1VhBiF6E4GK2okvyvukIL5PHLCgfQrfJmSiopk9Xo\n46Qri6rJbcPoWoZz/jWN0pfmU20hNuTQx6ebSoSkg6rHv1MKuy5LmDGLFC2ze3kU\nsY8u7X6TOBrifs/N+goLaH3+SkT2hZDKWJrmDyHzj043KLvXs/eiyu50M+ERoSlg\n70uO7QAXQFuLMILdy0UNJFM4xjlK6q4Jfbm4MC8QRG+i31AkmNvpY9JqCLqu0mGD\nBrdfJeN8PN+7DHW/Pzspf5RlJtlvBx1dS8Bxo2xteUyLGIaTZ9HZFhHc3IrmmKeW\naQIDAQAB\n-----END PUBLIC KEY-----\n' } }; + + // Mock webfinger + externalActivityPub.register({ + method: 'GET', + endpoint: `/.well-known/webfinger?resource=${encodeURIComponent(`acct:${getActorHandle(actor).substring(1)}`)}` + }, { + status: 200, + body: { + links: [ + { + rel: "self", + type: "application/activity+json", + href: actor.id + }, + ] + } + }); + + // Mock user + externalActivityPub.register({ + method: 'GET', + endpoint: `/user/${name}` + }, { + status: 200, + body: actor + }); + + // Mock followers collection + externalActivityPub.register({ + method: 'GET', + endpoint: `/followers/${name}` + }, { + status: 200, + body: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + id: actor.followers, + type: "OrderedCollection", + orderedItems: [] + } + }); + + // Mock following collection + externalActivityPub.register({ + method: 'GET', + endpoint: `/following/${name}` + }, { + status: 200, + body: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + id: actor.following, + type: "OrderedCollection", + orderedItems: [] + } + }); + + // Mock outbox collection + externalActivityPub.register({ + method: 'GET', + endpoint: `/outbox/${name}` + }, { + status: 200, + body: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + ], + id: actor.outbox, + type: "OrderedCollection", + orderedItems: [] + } + }); + + // Mock inbox + externalActivityPub.register({ + method: 'POST', + endpoint: `/inbox/${name}` + }, { + status: 202 + }); + + return actor; } function generateObject(type) { @@ -309,7 +391,7 @@ Before(async function () { } }); -async function fetchActivityPub(url, options) { +async function fetchActivityPub(url, options = { method: 'GET' }) { if (!options.headers) { options.headers = {}; } @@ -663,3 +745,49 @@ Then('a {string} activity is sent to {string}', async function (activityString, assert(found); }); + +When('I request the profile for {string}', async function (name) { + const actor = this.actors[name]; + + this.response = await fetchActivityPub(`http://fake-ghost-activitypub/.ghost/activitypub/profile/${getActorHandle(actor)}`); +}); + +Then('the response has a {int} status code', function (statusCode) { + assert.strictEqual(this.response.status, statusCode); +}); + +Then('the response body contains the profile for {string}', async function (name) { + const result = await this.response.json(); + const actor = this.actors[name]; + + assert.equal(result.actor.id, actor.id); + assert.equal(result.handle, getActorHandle(actor)); + assert.equal(result.followerCount, 0); + assert.equal(result.followingCount, 0); + assert.equal(result.isFollowing, false); + assert.deepEqual(result.posts, []); +}); + +When('I search for {string}', async function (name) { + const actor = this.actors[name]; + + this.response = await fetchActivityPub(`http://fake-ghost-activitypub/.ghost/activitypub/actions/search?query=${getActorHandle(actor)}`); +}); + +Then('the response body contains search results for {string}', async function (name) { + const results = await this.response.json(); + + assert.ok(results.profiles); + assert.equal(results.profiles.length, 1); + + assert.ok(results.profiles[0].actor); + + const actor = this.actors[results.profiles[0].actor.name]; + + assert.equal(results.profiles[0].actor.id, actor.id); + assert.equal(results.profiles[0].handle, getActorHandle(actor)); + assert.equal(results.profiles[0].followerCount, 0); + assert.equal(results.profiles[0].followingCount, 0); + assert.equal(results.profiles[0].isFollowing, false); + assert.deepEqual(results.profiles[0].posts, []); +}); diff --git a/src/app.ts b/src/app.ts index 218ebe4..3fd4756 100644 --- a/src/app.ts +++ b/src/app.ts @@ -103,7 +103,7 @@ await configure({ filters: {}, loggers: [ { category: 'activitypub', sinks: ['console'], level: 'info' }, - { category: 'fedify', sinks: ['console'], level: 'warning' } + { category: 'fedify', sinks: ['console'], level: process.env.NODE_ENV === 'testing' ? 'debug' : 'warning' } ], }); diff --git a/src/helpers/activitypub/actor.ts b/src/helpers/activitypub/actor.ts index e7319ed..e90cb44 100644 --- a/src/helpers/activitypub/actor.ts +++ b/src/helpers/activitypub/actor.ts @@ -88,5 +88,6 @@ export async function isFollowing(actor: Actor, options: { } export function isHandle(handle: string): boolean { - return /^@([\w-]+)@([\w-]+\.[\w.-]+)$/.test(handle); + // https://github.com/dahlia/fedify/blob/main/src/vocab/lookup.ts#L29-L30 + return /^@?((?:[-A-Za-z0-9._~!$&'()*+,;=]|%[A-Fa-f0-9]{2})+)@([^@]+)$/.test(handle); } diff --git a/src/helpers/activitypub/actor.unit.test.ts b/src/helpers/activitypub/actor.unit.test.ts index 8aaad7f..99665d4 100644 --- a/src/helpers/activitypub/actor.unit.test.ts +++ b/src/helpers/activitypub/actor.unit.test.ts @@ -266,8 +266,7 @@ describe('isFollowing', () => { describe('isHandle', () => { it('should return a boolean indicating if the provided string is a handle', () => { expect(isHandle('@foo@example.com')).toBe(true); - expect(isHandle('@foo@example.com/bar')).toBe(false); - expect(isHandle('@foo@example')).toBe(false); + expect(isHandle('@foo@example')).toBe(true); expect(isHandle('@example.com')).toBe(false); expect(isHandle('@foo')).toBe(false); expect(isHandle('@@foo')).toBe(false);