Skip to content
Draft
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-sharkey.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 for Sharkey (Misskey fork).
# Uses Caddy TLS proxies because Sharkey requires HTTPS for all ActivityPub
# federation lookups (hardcoded in checkHttps() and WebFingerService).
# See: https://github.com/fedify-dev/fedify/issues/654
name: smoke-sharkey

on:
push:
branches:
- main
- next
- "*.*-maintenance"
schedule:
- cron: "0 6 * * *"
workflow_dispatch:

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

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

env:
COMPOSE: >-
docker compose
-f test/smoke/sharkey/docker-compose.yml

steps:
- uses: actions/checkout@v4

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

- name: Generate TLS certificates
run: bash test/smoke/sharkey/generate-certs.sh test/smoke/sharkey/.certs

- name: Verify certificates
run: |
openssl verify -CAfile test/smoke/sharkey/.certs/ca.crt \
test/smoke/sharkey/.certs/fedify-harness.crt
openssl verify -CAfile test/smoke/sharkey/.certs/ca.crt \
test/smoke/sharkey/.certs/sharkey.crt

- name: Pull Sharkey image
run: |
docker pull registry.activitypub.software/transfem-org/sharkey:2025.4.6

- name: Pre-cache harness dependencies
run: deno cache test/smoke/harness/main.ts

- name: Start database and redis
run: |
$COMPOSE up -d db redis
$COMPOSE exec -T db \
sh -c 'until pg_isready -U sharkey; do sleep 1; done'

- name: Start Sharkey stack
run: $COMPOSE up --wait
timeout-minutes: 12

- name: Provision Sharkey
run: bash test/smoke/sharkey/provision.sh

- name: Verify connectivity
run: |
echo "=== Harness health (from sharkey-web-backend, via Caddy TLS) ==="
$COMPOSE exec -T \
-e NODE_EXTRA_CA_CERTS=/tmp/ca-bundle.crt \
sharkey-web-backend \
node -e "fetch('https://fedify-harness/_test/health').then(r=>{if(!r.ok)process.exit(1);return r.text()}).then(console.log)"
echo " OK"

- 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 ==="
$COMPOSE logs --tail=500

- name: Teardown
if: always()
run: $COMPOSE down -v
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ test/smoke/.env.test
test/smoke/mastodon/.certs/
test/smoke/mastodon/mastodon.env
test/smoke/mastodon/mastodon-strict.env
test/smoke/sharkey/.certs/
test/smoke/sharkey/sharkey.env
smoke.log
t.ts
t2.ts
Expand Down
65 changes: 55 additions & 10 deletions test/smoke/harness/backdoor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,62 @@ function json(data: unknown, status = 200): Response {
});
}

// Build recipient manually — in non-strict mode Mastodon's WebFinger requires
// HTTPS but our harness only has HTTP, so we use http:// for the inbox URL.
// In strict mode, Caddy terminates TLS, so we use https:// everywhere.
function parseRecipient(
// Resolve a handle (user@domain) to the correct actor URI and inbox URL
// via WebFinger + actor document fetch. Falls back to the Mastodon URL
// convention (/users/{username}) when WebFinger is unavailable.
const recipientCache = new Map<string, { inboxId: URL; actorId: URL }>();

async function parseRecipient(
handle: string,
): { inboxId: URL; actorId: URL } {
): Promise<{ inboxId: URL; actorId: URL }> {
const cached = recipientCache.get(handle);
if (cached) return cached;

const [user, domain] = handle.split("@");
const scheme = Deno.env.get("STRICT_MODE") ? "https" : "http";

// Try WebFinger resolution first — this discovers the correct actor URI
// regardless of server software (Mastodon, Sharkey, etc.)
try {
const wfUrl = `${scheme}://${domain}/.well-known/webfinger?resource=${
encodeURIComponent(`acct:${handle}`)
}`;
const wfRes = await fetch(wfUrl, {
headers: { Accept: "application/jrd+json" },
});
if (wfRes.ok) {
const wf = await wfRes.json() as {
links?: { rel: string; type?: string; href?: string }[];
};
const self = wf.links?.find(
(l) => l.rel === "self" && l.type === "application/activity+json",
);
if (self?.href) {
const actorId = new URL(self.href);
// Fetch the actor document to discover the inbox URL
const actorRes = await fetch(self.href, {
headers: { Accept: "application/activity+json" },
});
if (actorRes.ok) {
const actor = await actorRes.json() as { inbox?: string };
if (actor.inbox) {
const result = { inboxId: new URL(actor.inbox), actorId };
recipientCache.set(handle, result);
return result;
}
}
}
}
} catch {
// WebFinger failed; fall back to Mastodon convention
}
Comment on lines +28 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fetch calls within this try block for WebFinger and actor document retrieval lack timeouts. If the remote server becomes unresponsive, these requests could hang, causing the test to time out with a generic error message. To make the test harness more robust, consider adding a timeout to these fetch calls using an AbortSignal.


// Fallback: construct URLs using Mastodon convention
const inboxId = new URL(`${scheme}://${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 };
const result = { inboxId, actorId };
recipientCache.set(handle, result);
return result;
}

export async function handleBackdoor(
Expand All @@ -35,6 +79,7 @@ export async function handleBackdoor(

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

Expand All @@ -57,7 +102,7 @@ export async function handleBackdoor(
undefined as void,
);

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

const noteId = crypto.randomUUID();
Expand Down Expand Up @@ -100,7 +145,7 @@ export async function handleBackdoor(
undefined as void,
);

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

const follow = new Follow({
Expand Down Expand Up @@ -134,7 +179,7 @@ export async function handleBackdoor(
undefined as void,
);

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

const undo = new Undo({
Expand Down
71 changes: 60 additions & 11 deletions test/smoke/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ type InboxItem = {

async function snapshotInboxIds(): Promise<Set<string>> {
const res = await fetch(`${HARNESS_URL}/_test/inbox`);
if (!res.ok) {
const body = await res.text();
throw new Error(`Harness inbox fetch failed → ${res.status}: ${body}`);
}
const items = await res.json() as InboxItem[];
return new Set(items.map((a) => a.id));
}
Expand All @@ -72,6 +76,11 @@ function pollHarnessInbox(
): Promise<InboxItem> {
return poll(`${activityType} in harness inbox`, async () => {
const res = await fetch(`${HARNESS_URL}/_test/inbox`);
if (!res.ok) {
throw new Error(
`Harness inbox poll failed → ${res.status}: ${await res.text()}`,
);
}
const items = await res.json() as InboxItem[];
return items.find((a) =>
a.type === activityType &&
Expand Down Expand Up @@ -147,15 +156,53 @@ async function lookupFedifyAccount(): Promise<string> {
const handle = `testuser@${HARNESS_HOST}`;

const searchResult = await poll("Fedify user resolvable", async () => {
const results = await serverGet(
`/api/v1/accounts/search?q=${
encodeURIComponent(`@${handle}`)
}&resolve=false&limit=5`,
) as RemoteAccount[];
const match = results?.find((a) =>
a.acct === handle || a.acct === `@${handle}`
);
return match ?? null;
// Try /api/v1/accounts/search (Mastodon standard).
// Fall back to /api/v1/accounts/lookup (exact match, supported by Sharkey)
// if search returns 404.
try {
const results = await serverGet(
`/api/v1/accounts/search?q=${
encodeURIComponent(`@${handle}`)
}&resolve=true&limit=5`,
) as RemoteAccount[];
const match = results?.find((a) =>
a.acct === handle || a.acct === `@${handle}`
);
if (match) return match;
} catch {
// Search endpoint may return 404 on some servers (e.g. Sharkey);
// fall through to the lookup endpoint.
}

try {
const account = await serverGet(
`/api/v1/accounts/lookup?acct=${encodeURIComponent(handle)}`,
) as RemoteAccount;
if (account?.id) return account;
} catch {
// lookup also failed
}

// Misskey-native fallback: POST /api/users/show with username + host.
// Sharkey's Mastodon-compat search/lookup endpoints have bugs with
// remote users, but the native API works reliably. The returned id
// is the same internal ID used by the Mastodon-compat layer.
try {
const [user, host] = handle.split("@");
const res = await fetch(`${SERVER_URL}/api/users/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: user, host }),
});
if (res.ok) {
const data = await res.json() as { id?: string };
if (data?.id) return { id: data.id, acct: handle };
}
} catch {
// Not a Misskey-family server
}

return null;
});

fedifyAccountId = searchResult.id;
Expand Down Expand Up @@ -343,15 +390,17 @@ async function testUnfollowMastodonFromFedify(): Promise<void> {
const accountId = await lookupFedifyAccount();
await serverPost(`/api/v1/accounts/${accountId}/unfollow`);

await pollHarnessInbox("Undo", (a) => !knownIds.has(a.id));

// Primary assertion: the server-side relationship must show following=false.
await poll("unfollow confirmed", async () => {
const rels = await serverGet(
`/api/v1/accounts/relationships?id[]=${accountId}`,
) as Relationship[];
const rel = rels.find((r) => r.id === accountId);
return rel && !rel.following ? rel : null;
});

// The harness should receive an Undo Follow activity.
await pollHarnessInbox("Undo", (a) => !knownIds.has(a.id));
}

// ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions test/smoke/sharkey/Caddyfile.fedify-harness
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
auto_https off
}

:443 {
tls /certs/fedify-harness.crt /certs/fedify-harness.key
reverse_proxy fedify-harness-backend:3001
}
8 changes: 8 additions & 0 deletions test/smoke/sharkey/Caddyfile.sharkey
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
auto_https off
}

:443 {
tls /certs/sharkey.crt /certs/sharkey.key
reverse_proxy sharkey-web-backend:3000
}
31 changes: 31 additions & 0 deletions test/smoke/sharkey/default.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Sharkey configuration for smoke tests.

url: https://sharkey
port: 3000

db:
host: db
port: 5432
db: sharkey
user: sharkey
pass: sharkey

redis:
host: redis
port: 6379

id: aidx

# Allow Docker-internal traffic (private IPs, both IPv4 and IPv6)
allowedPrivateNetworks:
- 0.0.0.0/0
- ::/0

# Require signatures on ActivityPub GET requests (strict mode)
signToActivityPubGet: true

# Setup password used for initial admin account creation via API
setupPassword: smoke-test-setup

# Disable unnecessary features for smoke tests
clusterLimit: 1
Loading
Loading