Testing Your Implementation
Building an ActivityPub implementation requires thorough testing to ensure compatibility with the broader fediverse. This guide covers testing tools, validation strategies, and test suites available for verifying your implementation.
Testing Levels
Test Suites
ActivityPub Test Suite (activitypub-testsuite)
The official W3C-endorsed test suite for validating ActivityPub compliance:
# Clone the test suite
git clone https://github.com/w3c/activitypub
# Run against your server
npm install
npm test -- --server https://your-server.example
Tests include:
- Actor endpoint validation
- Inbox/Outbox delivery
- Activity type handling
- HTTP Signatures verification
- Content negotiation
ap-test
A lightweight testing tool for quick validation:
# Install
npm install -g ap-test
# Test an actor
ap-test actor https://your-server.example/users/alice
# Test WebFinger
ap-test webfinger alice@your-server.example
# Test inbox delivery
ap-test inbox https://your-server.example/users/alice/inbox
Fediverse Test Suite
Community-maintained comprehensive tests:
const FediTest = require('fediverse-test-suite');
const tester = new FediTest({
server: 'https://your-server.example',
testUser: 'testbot'
});
// Run all tests
const results = await tester.runAll();
// Run specific category
const actorResults = await tester.runCategory('actors');
const deliveryResults = await tester.runCategory('delivery');
Unit Testing
Testing JSON-LD Parsing
const { expect } = require('chai');
const { parseActivity } = require('../src/activitypub');
describe('Activity Parsing', () => {
it('should parse Create activity', () => {
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Create',
actor: 'https://example.com/users/alice',
object: {
type: 'Note',
content: 'Hello world'
}
};
const parsed = parseActivity(activity);
expect(parsed.type).to.equal('Create');
expect(parsed.object.type).to.equal('Note');
});
it('should handle compact IRIs', () => {
const activity = {
'@context': [
'https://www.w3.org/ns/activitystreams',
{ 'toot': 'http://joinmastodon.org/ns#' }
],
type: 'Create',
'toot:sensitive': true
};
const parsed = parseActivity(activity);
expect(parsed.sensitive).to.be.true;
});
});
Testing HTTP Signatures
describe('HTTP Signatures', () => {
const { signRequest, verifySignature } = require('../src/signatures');
const crypto = require('crypto');
let keyPair;
before(() => {
keyPair = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
});
it('should sign a request', () => {
const request = {
method: 'POST',
url: 'https://example.com/inbox',
headers: {
host: 'example.com',
date: new Date().toUTCString()
},
body: JSON.stringify({ type: 'Create' })
};
const signed = signRequest(request, {
keyId: 'https://example.com/users/alice#main-key',
privateKey: keyPair.privateKey
});
expect(signed.headers.signature).to.exist;
});
it('should verify a valid signature', async () => {
const request = signRequest(/* ... */);
const isValid = await verifySignature(request, async (keyId) => {
return keyPair.publicKey;
});
expect(isValid).to.be.true;
});
it('should reject tampered requests', async () => {
const request = signRequest(/* ... */);
request.body = 'tampered content';
const isValid = await verifySignature(request, /* ... */);
expect(isValid).to.be.false;
});
});
Testing Activity Handlers
describe('Inbox Handlers', () => {
const { processActivity } = require('../src/inbox');
const db = require('../src/db');
beforeEach(async () => {
await db.clear();
});
describe('Follow', () => {
it('should create follower record', async () => {
const activity = {
type: 'Follow',
actor: 'https://remote.example/users/bob',
object: 'https://local.example/users/alice'
};
await processActivity(activity, { verified: true });
const record = await db.followers.findOne({
actor: activity.object,
follower: activity.actor
});
expect(record).to.exist;
});
it('should auto-accept for unlocked accounts', async () => {
// ... test Accept generation
});
it('should require approval for locked accounts', async () => {
// ... test pending follow requests
});
});
describe('Create', () => {
it('should store Note in inbox', async () => {
const activity = {
type: 'Create',
actor: 'https://remote.example/users/bob',
object: {
type: 'Note',
id: 'https://remote.example/notes/123',
content: 'Hello!'
},
to: ['https://local.example/users/alice']
};
await processActivity(activity, { verified: true });
const note = await db.notes.findOne({
id: activity.object.id
});
expect(note).to.exist;
expect(note.content).to.equal('Hello!');
});
});
});
Integration Testing
Testing with a Mock Server
const express = require('express');
const { expect } = require('chai');
describe('Federation Integration', () => {
let mockServer;
let mockServerUrl;
let inboxReceived = [];
before((done) => {
const app = express();
app.use(express.json({ type: '*/*' }));
// Mock actor endpoint
app.get('/users/remote', (req, res) => {
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Person',
id: `${mockServerUrl}/users/remote`,
inbox: `${mockServerUrl}/inbox`,
outbox: `${mockServerUrl}/outbox`,
preferredUsername: 'remote'
});
});
// Mock inbox
app.post('/inbox', (req, res) => {
inboxReceived.push(req.body);
res.status(202).end();
});
mockServer = app.listen(0, () => {
mockServerUrl = `http://localhost:${mockServer.address().port}`;
done();
});
});
after(() => mockServer.close());
it('should deliver activities to remote inbox', async () => {
const { sendFollow } = require('../src/outbox');
await sendFollow(
localActor,
{ id: `${mockServerUrl}/users/remote` }
);
// Wait for delivery
await new Promise(r => setTimeout(r, 1000));
expect(inboxReceived).to.have.length(1);
expect(inboxReceived[0].type).to.equal('Follow');
});
});
Testing WebFinger
describe('WebFinger', () => {
it('should return JRD for valid user', async () => {
const response = await fetch(
'https://your-server.example/.well-known/webfinger?resource=acct:alice@your-server.example',
{ headers: { Accept: 'application/jrd+json' } }
);
expect(response.status).to.equal(200);
expect(response.headers.get('content-type')).to.include('application/jrd+json');
const jrd = await response.json();
expect(jrd.subject).to.equal('acct:alice@your-server.example');
expect(jrd.links).to.be.an('array');
const selfLink = jrd.links.find(l => l.rel === 'self');
expect(selfLink.type).to.equal('application/activity+json');
});
it('should return 404 for unknown user', async () => {
const response = await fetch(
'https://your-server.example/.well-known/webfinger?resource=acct:nobody@your-server.example'
);
expect(response.status).to.equal(404);
});
});
End-to-End Testing
Testing with Real Instances
Use disposable test instances:
describe('Mastodon Compatibility', () => {
const mastodonInstance = 'https://mastodon.social';
it('should be discoverable via WebFinger', async () => {
// Test that Mastodon can find your users
const response = await fetch(
`${mastodonInstance}/api/v2/search?q=alice@your-server.example&resolve=true`,
{ headers: { Authorization: `Bearer ${testToken}` } }
);
const data = await response.json();
expect(data.accounts).to.have.length.gt(0);
});
});
Using Docker for Test Environments
# docker-compose.test.yml
version: '3.8'
services:
your-server:
build: .
environment:
- DATABASE_URL=postgres://test:test@db/test
- DOMAIN=test.localhost
ports:
- "3000:3000"
mastodon:
image: tootsuite/mastodon:latest
environment:
- LOCAL_DOMAIN=mastodon.localhost
ports:
- "3001:3000"
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=test
# Run integration tests
docker-compose -f docker-compose.test.yml up -d
npm run test:e2e
docker-compose -f docker-compose.test.yml down
Validation Checklist
Actor Validation
- Returns valid JSON-LD with @context
- Has unique, dereferenceable id
- Contains inbox and outbox URLs
- Has valid preferredUsername
- publicKey contains valid RSA public key
- Content negotiation returns ActivityPub for appropriate Accept headers
- Returns HTML for browser requests
Inbox Validation
- Accepts POST requests
- Verifies HTTP Signatures
- Returns 202 Accepted for valid activities
- Returns 401/403 for invalid signatures
- Handles all required activity types
- Rejects activities with wrong actor
Outbox Validation
- Supports GET for fetching activities
- Delivers activities to recipient inboxes
- Signs requests with valid HTTP Signatures
- Uses shared inbox when available
- Implements retry logic for failed deliveries
WebFinger Validation
- Responds at /.well-known/webfinger
- Returns application/jrd+json
- Includes CORS headers
- Contains self link with ActivityPub type
- Handles both acct: and URL resources
Automated Testing Tools
Continuous Integration
# .github/workflows/test.yml
name: ActivityPub Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:test@localhost/test
- name: Run ActivityPub compliance tests
run: npm run test:activitypub
Linting ActivityPub Objects
const Ajv = require('ajv');
const ajv = new Ajv();
// ActivityStreams schema (simplified)
const activitySchema = {
type: 'object',
required: ['@context', 'type'],
properties: {
'@context': {
oneOf: [
{ type: 'string', const: 'https://www.w3.org/ns/activitystreams' },
{ type: 'array', items: { type: ['string', 'object'] } }
]
},
type: { type: 'string' },
id: { type: 'string', format: 'uri' },
actor: { type: 'string', format: 'uri' },
object: { type: ['string', 'object'] }
}
};
const validate = ajv.compile(activitySchema);
function lintActivity(activity) {
const valid = validate(activity);
if (!valid) {
return { valid: false, errors: validate.errors };
}
return { valid: true };
}
Debugging Federation Issues
Request Logging
app.use((req, res, next) => {
if (req.path.includes('/inbox') || req.path.includes('/outbox')) {
console.log('Federation Request:', {
method: req.method,
path: req.path,
headers: {
signature: req.headers.signature,
digest: req.headers.digest,
contentType: req.headers['content-type']
},
body: req.body
});
}
next();
});
Testing Locally with Tunnels
# Use ngrok to expose local server
ngrok http 3000
# Your server is now accessible at https://xxxx.ngrok.io
# Test federation with real instances
Common Test Scenarios
| Scenario | What to Test |
|---|---|
| User follows remote user | WebFinger → Fetch Actor → Send Follow → Receive Accept |
| User receives post | Verify signature → Store in inbox → Display in timeline |
| User creates post | Create activity → Deliver to followers → Verify delivery |
| User mentions remote user | Resolve mention → Include in activity → Deliver to inbox |
| User deletes post | Send Delete → Verify tombstone → Remote removal |
Next Steps
- Mastodon Compatibility - Mastodon-specific requirements
- Scaling and Performance - Production optimization
- WebFinger Implementation - User discovery