Skip to content

Commit

Permalink
Setup Cucumber.js for E2E testing
Browse files Browse the repository at this point in the history
Now that we're able to skip HTTP signature verification we can easily run E2E
tests with arbitrary data in the body. We have a basic setup for cumcumber
tests here, including a breaking test for the post.published webhook handler.
  • Loading branch information
allouis committed Aug 4, 2024
1 parent 3cdd1e9 commit 191398c
Show file tree
Hide file tree
Showing 8 changed files with 962 additions and 9 deletions.
1 change: 1 addition & 0 deletions cucumber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default `--format-options '{"snippetInterface": "synchronous"}'`
49 changes: 49 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,52 @@ services:
test: "mysql -ughost -ppassword activitypub -e 'select 1'"
interval: 1s
retries: 120

# Testing

activitypub-testing:
build: .
volumes:
- ./src:/opt/activitypub/src
environment:
- PORT=8080
- MYSQL_USER=ghost
- MYSQL_PASSWORD=password
- MYSQL_HOST=mysql-testing
- MYSQL_PORT=3306
- MYSQL_DATABASE=activitypub
- NODE_ENV=testing
- SKIP_SIGNATURE_VERIFICATION=true
command: node --import tsx src/app.ts
depends_on:
mysql-testing:
condition: service_healthy
healthcheck:
test: "wget --spider http://0.0.0.0:8080/ping"
interval: 1s
retries: 120

cucumber-tests:
build: .
volumes:
- ./features:/opt/activitypub/features
- ./cucumber.js:/opt/activitypub/cucumber.js
environment:
- NODE_ENV=testing
command: yarn run cucumber-js
depends_on:
activitypub-testing:
condition: service_healthy


mysql-testing:
image: mysql:lts
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_USER=ghost
- MYSQL_PASSWORD=password
- MYSQL_DATABASE=activitypub
healthcheck:
test: "mysql -ughost -ppassword activitypub -e 'select 1'"
interval: 1s
retries: 120
7 changes: 7 additions & 0 deletions features/create-article-from-post.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Feature: Deliver Create(Article) activities when a post.published webhook is received

Scenario: We recieve a webhook for the post.published event
Given a valid "post.published" webhook
When it is sent to the webhook endpoint
Then the request is accepted
Then a "Create(Article)" activity is in the Outbox
9 changes: 9 additions & 0 deletions features/handle-create-article.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Feature: Create(Article)
We want to handle Create(Article) activities in the Inbox

Scenario: We recieve a Create(Article) activity from someone we follow
Given a valid "Create(Article)" activity
Given the actor is "known"
When it is sent to the Inbox
Then the request is accepted
Then the activity is in the Inbox
193 changes: 193 additions & 0 deletions features/step_definitions/stepdefs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import assert from 'assert';
import { Given, When, Then } from '@cucumber/cucumber';

const activites = {
Create: {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/data-integrity/v1',
],
'type': 'Create',
'id': 'https://www.site.com/create/1',
'to': 'as:Public',
'cc': 'https://www.site.com/followers',
},
};

const objects = {
Article: {
'type': 'Article',
'id': 'https://www.site.com/article/1',
'to': 'as:Public',
'cc': 'https://www.site.com/followers',
'url': 'https://www.site.com/article/1',
'content': '<p>This is a test article</p>',
'published': '2020-04-20T04:20:00Z',
'attributedTo': 'https://site.com/user'
},
Note: {
'type': 'Note',
'id': 'https://www.site.com/note/1',
'to': 'as:Public',
'cc': 'https://www.site.com/followers',
'url': 'https://www.site.com/note/1',
'content': '<p>This is a test note</p>',
'published': '2020-04-20T04:20:00Z',
'attributedTo': 'https://site.com/user'
}
};

const actors = {
known: {
'id': 'https://site.com/user',
'url': 'https://site.com/user',
'name': 'Test Actor',
'type': 'Person',
'inbox': 'https://site.com/inbox',
'outbox': 'https://site.com/outbox',
'summary': 'A test actor for testing',
'followers': 'https://site.com/followers',
'following': 'https://site.com/following',
'published': '2024-02-21T00:00:00Z',
'preferredUsername': 'index',
'as:manuallyApprovesFollowers': false,
'https://w3id.org/security#publicKey': {
'id': 'https://www.site.com/user#main-key',
'type': 'https://w3id.org/security#Key',
'https://w3id.org/security#owner': {
'id': 'https://site.com/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'
}
},
unknown: {
'id': 'https://site.com/user',
'url': 'https://site.com/user',
'name': 'Test Actor',
'type': 'Person',
'inbox': 'https://site.com/inbox',
'outbox': 'https://site.com/outbox',
'summary': 'A test actor for testing',
'followers': 'https://site.com/followers',
'following': 'https://site.com/following',
'published': '2024-02-21T00:00:00Z',
'preferredUsername': 'index',
'as:manuallyApprovesFollowers': false,
'https://w3id.org/security#publicKey': {
'id': 'https://www.site.com/user#main-key',
'type': 'https://w3id.org/security#Key',
'https://w3id.org/security#owner': {
'id': 'https://site.com/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'
}
},
};

Given('a valid {string} activity', function (string) {
const [match, activity, object] = string.match(/(\w+)\((\w+)\)/) || [null]
if (!match) {
throw new Error(`Could not match ${string} to an activity`);
}
this.activity = activity;
this.object = object;
this.actor = 'known';
});

Given('the actor is {string}', function (string) {
this.actor = string;
});

const webhooks = {
'post.published': {
"post": {
"current": {
"uuid": "986108d9-3d50-4701-9808-eab62e0885cf",
"title": "This is a title.",
"html": "<p> This is some content. </p>",
"feature_image": null,
"visibility": "paid",
"published_at": "1970-01-01T00:00:00.000Z",
"url": "https://site.com/post/",
"excerpt": "This is some content.",
}
}
}
};

const endpoints = {
'post.published': 'http://activitypub-testing:8080/.ghost/activitypub/webhooks/post/published'
};

Given('a valid {string} webhook', function (string) {
this.payloadType = string;
});

When('it is sent to the webhook endpoint', async function () {
const endpoint = endpoints[this.payloadType];
const payload = webhooks[this.payloadType];
this.response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify(payload)
});
});

Then('a {string} activity is in the Outbox', async function (string) {
const [match, activity, object] = string.match(/(\w+)\((\w+)\)/) || [null]
if (!match) {
throw new Error(`Could not match ${string} to an activity`);
}
const response = await fetch('http://activitypub-testing:8080/.ghost/activitypub/outbox/index', {
headers: {
Accept: 'application/ld+json'
}
});
const outbox = await response.json();
const found = outbox.orderedItems.find((item) => {
return item.type === activity && item.object?.type === object
});
assert.ok(found);
});

When('it is sent to the Inbox', async function () {
if (!this.activity || !this.object || !this.actor) {
throw new Error(`Incomplete information for activity`);
}
const activity = activites[this.activity];
const object = objects[this.object];
const actor = actors[this.actor];

const payload = {
...activity,
...{object},
...{actor},
};

this.response = await fetch('http://activitypub-testing:8080/.ghost/activitypub/inbox/index', {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify(payload)
});
});

Then('the request is rejected', function () {
assert(!this.response.ok);
});

Then('the request is accepted', async function () {
assert(this.response.ok, `Expected OK response - got ${this.response.status} ${await this.response.clone().text()}`);
});

Then('the activity is in the Inbox', async function () {
const response = await fetch('http://activitypub-testing:8080/.ghost/activitypub/inbox/index', {
headers: {
Accept: 'application/ld+json'
}
});
const inbox = await response.json();
});
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
"main": "src/app.ts",
"type": "module",
"scripts": {
"dev": "docker compose up -d --no-recreate",
"dev": "docker compose up activitypub nginx -d --no-recreate",
"stop": "docker compose stop",
"fix": "docker compose rm activitypub nginx -sf && docker compose build activitypub nginx",
"logs": "docker compose logs activitypub -f",
"test": "yarn dev && docker compose exec activitypub yarn test:all",
"test:cucumber": "docker compose rm mysql-testing -sf && docker compose up cucumber-tests --abort-on-container-exit",
"test": "yarn dev && docker compose exec activitypub yarn test:all && yarn test:cucumber",
"test:types": "tsc --noEmit",
"test:unit": "c8 --src src --all --reporter text --reporter cobertura mocha -r tsx './src/**/*.unit.test.ts'",
"test:integration": "c8 --src src --all --reporter text --reporter cobertura mocha -r tsx './src/**/*.integration.test.ts'",
Expand All @@ -24,6 +25,7 @@
"src"
],
"devDependencies": {
"@cucumber/cucumber": "10.8.0",
"@types/mocha": "10.0.7",
"@types/node": "20.12.12",
"@types/sanitize-html": "^2.11.0",
Expand Down
7 changes: 7 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const fedifyKv = await KnexKvStore.create(client, 'key_value');

export const fedify = createFederation<ContextData>({
kv: fedifyKv,
skipSignatureVerification: process.env.SKIP_SIGNATURE_VERIFICATION === 'true' && process.env.NODE_ENV === 'testing',
});

export const db = await KnexKvStore.create(client, 'key_value');
Expand Down Expand Up @@ -242,6 +243,12 @@ app.use(async (ctx, next) => {

/** Custom API routes */

app.get('/ping', (ctx) => {
return new Response('', {
status: 200
});
});

app.get('/.ghost/activitypub/inbox/:handle', inboxHandler);
app.post('/.ghost/activitypub/webhooks/post/published', postPublishedWebhook);
app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook);
Expand Down
Loading

0 comments on commit 191398c

Please sign in to comment.