Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/smoke-mastodon.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
#
# Interoperability smoke tests.
# Spins up a Mastodon instance via Docker Compose and verifies that Fedify
# can correctly exchange ActivityPub messages with it.
# See: https://github.com/fedify-dev/fedify/issues/481
name: smoke-mastodon

on:
push:
branches:
- main
- next
- "*.*-maintenance"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- uses: actions/checkout@v4

- uses: ./.github/actions/setup-mise

- name: Generate Mastodon secrets
run: |
IMAGE=ghcr.io/mastodon/mastodon:v4.3.9
docker pull "$IMAGE"

SECRET1=$(docker run --rm "$IMAGE" bundle exec rails secret)
SECRET2=$(docker run --rm "$IMAGE" bundle exec rails secret)

{
echo "SECRET_KEY_BASE=$SECRET1"
echo "OTP_SECRET=$SECRET2"
docker run --rm "$IMAGE" bundle exec rails mastodon:webpush:generate_vapid_key \
| grep -E '^[A-Z_]+=.+'
docker run --rm "$IMAGE" bundle exec rails db:encryption:init \
| grep -E '^[A-Z_]+=.+'
} >> test/smoke/mastodon/mastodon.env

- name: Start database and redis
run: |
docker compose -f test/smoke/mastodon/docker-compose.yml up -d db redis
docker compose -f test/smoke/mastodon/docker-compose.yml exec -T db \
sh -c 'until pg_isready -U mastodon; do sleep 1; done'

- name: Run DB setup and migrations
run: |
docker compose -f test/smoke/mastodon/docker-compose.yml run --rm -T \
mastodon-web bundle exec rails db:setup
timeout-minutes: 5

- name: Start Mastodon stack
run: docker compose -f test/smoke/mastodon/docker-compose.yml up --wait
timeout-minutes: 12

- name: Provision Mastodon
run: bash test/smoke/mastodon/provision.sh

- name: Verify connectivity
run: |
echo "=== Harness health (from mastodon-web) ==="
docker compose -f test/smoke/mastodon/docker-compose.yml exec -T mastodon-web \
curl -sf http://fedify-harness:3001/_test/health && echo " OK" || echo " FAIL"

echo "=== Harness health (from mastodon-sidekiq) ==="
docker compose -f test/smoke/mastodon/docker-compose.yml exec -T mastodon-sidekiq \
curl -sf http://fedify-harness:3001/_test/health && echo " OK" || echo " FAIL"

- name: Run smoke tests
run: |
set -a && source test/smoke/.env.test && set +a
deno run --allow-net --allow-env --unstable-temporal \
test/smoke/orchestrator.ts

- name: Collect logs on failure
if: failure()
run: |
echo "=== Docker Compose logs ==="
docker compose -f test/smoke/mastodon/docker-compose.yml logs --tail=500

- name: Teardown
if: always()
run: docker compose -f test/smoke/mastodon/docker-compose.yml down -v
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dist/
node_modules/
package-lock.json
repomix-output.xml
test/smoke/.env.test
test/smoke/mastodon/mastodon.env
smoke.log
t.ts
t2.ts
plan.md
Expand Down
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"./packages/webfinger",
"./examples/astro",
"./examples/fresh",
"./examples/hono-sample"
"./examples/hono-sample",
"./test/smoke/harness"
],
"imports": {
"@cloudflare/workers-types": "npm:@cloudflare/workers-types@^4.20250529.0",
Expand Down
980 changes: 456 additions & 524 deletions deno.lock

Large diffs are not rendered by default.

165 changes: 165 additions & 0 deletions test/smoke/harness/backdoor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import type { Federation } from "@fedify/fedify/federation";
import { Create, Follow, Note, Undo } from "@fedify/vocab";
import { store } from "./store.ts";

function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}

// Build recipient manually — Mastodon's WebFinger requires HTTPS but our
// harness only has HTTP. Parse the handle (user@domain) to construct the
// actor URI and inbox URL directly.
function parseRecipient(
handle: string,
): { inboxId: URL; actorId: URL } {
const [user, domain] = handle.split("@");
const inboxId = new URL(`http://${domain}/users/${user}/inbox`);
// Mastodon generates https:// actor URIs; use that as the canonical id
const actorId = new URL(`https://${domain}/users/${user}`);
return { inboxId, actorId };
}

export async function handleBackdoor(
request: Request,
federation: Federation<void>,
): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/_test/health") {
return new Response("OK");
}

if (url.pathname === "/_test/reset" && request.method === "POST") {
store.clear();
return json({ ok: true });
}

if (url.pathname === "/_test/inbox") {
return json(store.all());
}

if (url.pathname === "/_test/inbox/latest") {
const item = store.latest();
if (item == null) return json(null, 404);
return json(item);
}

if (url.pathname === "/_test/create-note" && request.method === "POST") {
const body = await request.json();
const { to, content } = body as { to: string; content: string };

const ctx = federation.createContext(
new URL(request.url),
undefined as void,
);

const { actorId, inboxId } = parseRecipient(to);
const recipient = { id: actorId, inboxId };

const noteId = crypto.randomUUID();
const note = new Note({
id: new URL(`${ctx.canonicalOrigin}/notes/${noteId}`),
attribution: ctx.getActorUri("testuser"),
content,
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
ccs: [actorId],
});

const activity = new Create({
id: new URL(`${ctx.canonicalOrigin}/activities/${noteId}`),
actor: ctx.getActorUri("testuser"),
object: note,
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
ccs: [actorId],
});

try {
await ctx.sendActivity(
{ identifier: "testuser" },
recipient,
activity,
{ immediate: true },
);
} catch (e) {
return json({ error: `Failed to send: ${e}` }, 500);
}

return json({ ok: true, noteId });
}

if (url.pathname === "/_test/follow" && request.method === "POST") {
const body = await request.json();
const { target } = body as { target: string };

const ctx = federation.createContext(
new URL(request.url),
undefined as void,
);

const { actorId, inboxId } = parseRecipient(target);
const recipient = { id: actorId, inboxId };

const follow = new Follow({
id: new URL(
`${ctx.canonicalOrigin}/activities/${crypto.randomUUID()}`,
),
actor: ctx.getActorUri("testuser"),
object: actorId,
});

try {
await ctx.sendActivity(
{ identifier: "testuser" },
recipient,
follow,
{ immediate: true },
);
} catch (e) {
return json({ error: `Failed to send: ${e}` }, 500);
}

return json({ ok: true });
}

if (url.pathname === "/_test/unfollow" && request.method === "POST") {
const body = await request.json();
const { target } = body as { target: string };

const ctx = federation.createContext(
new URL(request.url),
undefined as void,
);

const { actorId, inboxId } = parseRecipient(target);
const recipient = { id: actorId, inboxId };

const undo = new Undo({
id: new URL(
`${ctx.canonicalOrigin}/activities/${crypto.randomUUID()}`,
),
actor: ctx.getActorUri("testuser"),
object: new Follow({
actor: ctx.getActorUri("testuser"),
object: actorId,
}),
});

try {
await ctx.sendActivity(
{ identifier: "testuser" },
recipient,
undo,
{ immediate: true },
);
} catch (e) {
return json({ error: `Failed to send: ${e}` }, 500);
}

return json({ ok: true });
}

return new Response("Not Found", { status: 404 });
}
1 change: 1 addition & 0 deletions test/smoke/harness/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
98 changes: 98 additions & 0 deletions test/smoke/harness/federation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { createFederation, MemoryKvStore } from "@fedify/fedify/federation";
import { generateCryptoKeyPair } from "@fedify/fedify/sig";
import { Accept, Activity, Create, Follow, Person } from "@fedify/vocab";
import { store } from "./store.ts";

const ORIGIN = Deno.env.get("HARNESS_ORIGIN") ??
"http://fedify-harness:3001";

const rsaKeyPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5");

const federation = createFederation<void>({
kv: new MemoryKvStore(),
origin: ORIGIN,
allowPrivateAddress: true,
skipSignatureVerification: true,
});

federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
if (identifier !== "testuser") return null;
const keys = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: "Fedify Smoke Test User",
inbox: ctx.getInboxUri(identifier),
outbox: ctx.getOutboxUri(identifier),
followers: ctx.getFollowersUri(identifier),
url: ctx.getActorUri(identifier),
publicKey: keys[0].cryptographicKey,
assertionMethods: keys.map((k) => k.multikey),
});
})
.setKeyPairsDispatcher((_ctx, identifier) => {
if (identifier !== "testuser") return [];
return [rsaKeyPair];
});

federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
const followerUri = follow.actorId;
store.push({
id: follow.id?.href ?? crypto.randomUUID(),
type: "Follow",
receivedAt: new Date().toISOString(),
});
if (!ctx.recipient || !followerUri) return;

// Build the recipient manually instead of calling getActor(), because
// Mastodon generates https:// actor URIs but only serves HTTP.
// Rewrite the scheme so sendActivity POSTs over plain HTTP.
const httpActorUri = followerUri.href.replace(/^https:\/\//, "http://");
const recipient = {
id: followerUri,
inboxId: new URL(`${httpActorUri}/inbox`),
};

const accept = new Accept({
actor: ctx.getActorUri(ctx.recipient),
object: follow,
});
await ctx.sendActivity(
{ identifier: ctx.recipient },
recipient,
accept,
{ immediate: true },
);
})
.on(Create, (_ctx, create) => {
store.push({
id: create.id?.href ?? crypto.randomUUID(),
type: "Create",
receivedAt: new Date().toISOString(),
});
})
.on(Activity, (_ctx, activity) => {
// Don't double-store Create or Follow activities (already handled above)
if (!(activity instanceof Create) && !(activity instanceof Follow)) {
store.push({
id: activity.id?.href ?? crypto.randomUUID(),
type: activity.constructor.name,
receivedAt: new Date().toISOString(),
});
}
});

federation.setOutboxDispatcher(
"/users/{identifier}/outbox",
(_ctx, _identifier, _cursor) => ({ items: [] }),
);

federation.setFollowersDispatcher(
"/users/{identifier}/followers",
(_ctx, _identifier, _cursor) => ({ items: [] }),
);

export { federation };
20 changes: 20 additions & 0 deletions test/smoke/harness/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { federation } from "./federation.ts";
import { handleBackdoor } from "./backdoor.ts";

const PORT = parseInt(Deno.env.get("HARNESS_PORT") ?? "3001");

Deno.serve({ port: PORT, hostname: "0.0.0.0" }, async (request: Request) => {
const url = new URL(request.url);

// Backdoor test-control routes
if (url.pathname.startsWith("/_test/")) {
return await handleBackdoor(request, federation);
}

// Federation routes (actor, inbox, webfinger, etc.)
return await federation.fetch(request, {
contextData: undefined,
onNotFound: () => new Response("Not Found", { status: 404 }),
onNotAcceptable: () => new Response("Not Acceptable", { status: 406 }),
});
});
Loading
Loading