Skip to content

Commit 458220e

Browse files
sij411claude
andcommitted
Add Sharkey (Misskey-family) smoke tests
Adds interoperability smoke tests for Sharkey, a Misskey fork. Sharkey requires HTTPS for all ActivityPub federation lookups (hard-coded in checkHttps() and WebFingerService), so the setup uses Caddy TLS proxies with self-signed certificates. Key changes: - New GitHub Actions workflow (smoke-sharkey) that boots a Sharkey stack with Caddy TLS proxies, provisions users, and runs E2E scenarios via the shared orchestrator - Test harness backdoor now resolves recipients via WebFinger + actor document fetch instead of hardcoding Mastodon URL patterns, with a cache that is cleared on /_test/reset - Orchestrator account lookup falls back through Mastodon-compat search, lookup, and Misskey-native /api/users/show endpoints - res.ok checks before JSON parsing in inbox poll helpers - poll() catches transient errors from callbacks and retries until timeout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb22a55 commit 458220e

File tree

11 files changed

+590
-21
lines changed

11 files changed

+590
-21
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2+
#
3+
# Interoperability smoke tests for Sharkey (Misskey fork).
4+
# Uses Caddy TLS proxies because Sharkey requires HTTPS for all ActivityPub
5+
# federation lookups (hardcoded in checkHttps() and WebFingerService).
6+
# See: https://github.com/fedify-dev/fedify/issues/654
7+
name: smoke-sharkey
8+
9+
on:
10+
push:
11+
branches:
12+
- main
13+
- next
14+
- "*.*-maintenance"
15+
schedule:
16+
- cron: "0 6 * * *"
17+
workflow_dispatch:
18+
19+
concurrency:
20+
group: ${{ github.workflow }}-${{ github.ref }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
smoke:
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 25
27+
28+
env:
29+
COMPOSE: >-
30+
docker compose
31+
-f test/smoke/sharkey/docker-compose.yml
32+
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- uses: ./.github/actions/setup-mise
37+
38+
- name: Generate TLS certificates
39+
run: bash test/smoke/sharkey/generate-certs.sh test/smoke/sharkey/.certs
40+
41+
- name: Verify certificates
42+
run: |
43+
openssl verify -CAfile test/smoke/sharkey/.certs/ca.crt \
44+
test/smoke/sharkey/.certs/fedify-harness.crt
45+
openssl verify -CAfile test/smoke/sharkey/.certs/ca.crt \
46+
test/smoke/sharkey/.certs/sharkey.crt
47+
48+
- name: Pull Sharkey image
49+
run: |
50+
docker pull registry.activitypub.software/transfem-org/sharkey:2025.4.6
51+
52+
- name: Pre-cache harness dependencies
53+
run: deno cache test/smoke/harness/main.ts
54+
55+
- name: Start database and redis
56+
run: |
57+
$COMPOSE up -d db redis
58+
$COMPOSE exec -T db \
59+
sh -c 'until pg_isready -U sharkey; do sleep 1; done'
60+
61+
- name: Start Sharkey stack
62+
run: $COMPOSE up --wait
63+
timeout-minutes: 12
64+
65+
- name: Provision Sharkey
66+
run: bash test/smoke/sharkey/provision.sh
67+
68+
- name: Verify connectivity
69+
run: |
70+
echo "=== Harness health (from sharkey-web-backend, via Caddy TLS) ==="
71+
$COMPOSE exec -T \
72+
-e NODE_EXTRA_CA_CERTS=/tmp/ca-bundle.crt \
73+
sharkey-web-backend \
74+
node -e "fetch('https://fedify-harness/_test/health').then(r=>{if(!r.ok)process.exit(1);return r.text()}).then(console.log)"
75+
echo " OK"
76+
77+
- name: Run smoke tests
78+
run: |
79+
set -a && source test/smoke/.env.test && set +a
80+
deno run --allow-net --allow-env --unstable-temporal \
81+
test/smoke/orchestrator.ts
82+
83+
- name: Collect logs on failure
84+
if: failure()
85+
run: |
86+
echo "=== Docker Compose logs ==="
87+
$COMPOSE logs --tail=500
88+
89+
- name: Teardown
90+
if: always()
91+
run: $COMPOSE down -v

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ test/smoke/.env.test
1111
test/smoke/mastodon/.certs/
1212
test/smoke/mastodon/mastodon.env
1313
test/smoke/mastodon/mastodon-strict.env
14+
test/smoke/sharkey/.certs/
15+
test/smoke/sharkey/sharkey.env
1416
smoke.log
1517
t.ts
1618
t2.ts

test/smoke/harness/backdoor.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,62 @@ function json(data: unknown, status = 200): Response {
99
});
1010
}
1111

12-
// Build recipient manually — in non-strict mode Mastodon's WebFinger requires
13-
// HTTPS but our harness only has HTTP, so we use http:// for the inbox URL.
14-
// In strict mode, Caddy terminates TLS, so we use https:// everywhere.
15-
function parseRecipient(
12+
// Resolve a handle (user@domain) to the correct actor URI and inbox URL
13+
// via WebFinger + actor document fetch. Falls back to the Mastodon URL
14+
// convention (/users/{username}) when WebFinger is unavailable.
15+
const recipientCache = new Map<string, { inboxId: URL; actorId: URL }>();
16+
17+
async function parseRecipient(
1618
handle: string,
17-
): { inboxId: URL; actorId: URL } {
19+
): Promise<{ inboxId: URL; actorId: URL }> {
20+
const cached = recipientCache.get(handle);
21+
if (cached) return cached;
22+
1823
const [user, domain] = handle.split("@");
1924
const scheme = Deno.env.get("STRICT_MODE") ? "https" : "http";
25+
26+
// Try WebFinger resolution first — this discovers the correct actor URI
27+
// regardless of server software (Mastodon, Sharkey, etc.)
28+
try {
29+
const wfUrl = `${scheme}://${domain}/.well-known/webfinger?resource=${
30+
encodeURIComponent(`acct:${handle}`)
31+
}`;
32+
const wfRes = await fetch(wfUrl, {
33+
headers: { Accept: "application/jrd+json" },
34+
});
35+
if (wfRes.ok) {
36+
const wf = await wfRes.json() as {
37+
links?: { rel: string; type?: string; href?: string }[];
38+
};
39+
const self = wf.links?.find(
40+
(l) => l.rel === "self" && l.type === "application/activity+json",
41+
);
42+
if (self?.href) {
43+
const actorId = new URL(self.href);
44+
// Fetch the actor document to discover the inbox URL
45+
const actorRes = await fetch(self.href, {
46+
headers: { Accept: "application/activity+json" },
47+
});
48+
if (actorRes.ok) {
49+
const actor = await actorRes.json() as { inbox?: string };
50+
if (actor.inbox) {
51+
const result = { inboxId: new URL(actor.inbox), actorId };
52+
recipientCache.set(handle, result);
53+
return result;
54+
}
55+
}
56+
}
57+
}
58+
} catch {
59+
// WebFinger failed; fall back to Mastodon convention
60+
}
61+
62+
// Fallback: construct URLs using Mastodon convention
2063
const inboxId = new URL(`${scheme}://${domain}/users/${user}/inbox`);
21-
// Mastodon generates https:// actor URIs; use that as the canonical id
2264
const actorId = new URL(`https://${domain}/users/${user}`);
23-
return { inboxId, actorId };
65+
const result = { inboxId, actorId };
66+
recipientCache.set(handle, result);
67+
return result;
2468
}
2569

2670
export async function handleBackdoor(
@@ -35,6 +79,7 @@ export async function handleBackdoor(
3579

3680
if (url.pathname === "/_test/reset" && request.method === "POST") {
3781
store.clear();
82+
recipientCache.clear();
3883
return json({ ok: true });
3984
}
4085

@@ -57,7 +102,7 @@ export async function handleBackdoor(
57102
undefined as void,
58103
);
59104

60-
const { actorId, inboxId } = parseRecipient(to);
105+
const { actorId, inboxId } = await parseRecipient(to);
61106
const recipient = { id: actorId, inboxId };
62107

63108
const noteId = crypto.randomUUID();
@@ -100,7 +145,7 @@ export async function handleBackdoor(
100145
undefined as void,
101146
);
102147

103-
const { actorId, inboxId } = parseRecipient(target);
148+
const { actorId, inboxId } = await parseRecipient(target);
104149
const recipient = { id: actorId, inboxId };
105150

106151
const follow = new Follow({
@@ -134,7 +179,7 @@ export async function handleBackdoor(
134179
undefined as void,
135180
);
136181

137-
const { actorId, inboxId } = parseRecipient(target);
182+
const { actorId, inboxId } = await parseRecipient(target);
138183
const recipient = { id: actorId, inboxId };
139184

140185
const undo = new Undo({

test/smoke/orchestrator.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ type InboxItem = {
6262

6363
async function snapshotInboxIds(): Promise<Set<string>> {
6464
const res = await fetch(`${HARNESS_URL}/_test/inbox`);
65+
if (!res.ok) {
66+
const body = await res.text();
67+
throw new Error(`Harness inbox fetch failed → ${res.status}: ${body}`);
68+
}
6569
const items = await res.json() as InboxItem[];
6670
return new Set(items.map((a) => a.id));
6771
}
@@ -72,6 +76,11 @@ function pollHarnessInbox(
7276
): Promise<InboxItem> {
7377
return poll(`${activityType} in harness inbox`, async () => {
7478
const res = await fetch(`${HARNESS_URL}/_test/inbox`);
79+
if (!res.ok) {
80+
throw new Error(
81+
`Harness inbox poll failed → ${res.status}: ${await res.text()}`,
82+
);
83+
}
7584
const items = await res.json() as InboxItem[];
7685
return items.find((a) =>
7786
a.type === activityType &&
@@ -147,15 +156,53 @@ async function lookupFedifyAccount(): Promise<string> {
147156
const handle = `testuser@${HARNESS_HOST}`;
148157

149158
const searchResult = await poll("Fedify user resolvable", async () => {
150-
const results = await serverGet(
151-
`/api/v1/accounts/search?q=${
152-
encodeURIComponent(`@${handle}`)
153-
}&resolve=false&limit=5`,
154-
) as RemoteAccount[];
155-
const match = results?.find((a) =>
156-
a.acct === handle || a.acct === `@${handle}`
157-
);
158-
return match ?? null;
159+
// Try /api/v1/accounts/search (Mastodon standard).
160+
// Fall back to /api/v1/accounts/lookup (exact match, supported by Sharkey)
161+
// if search returns 404.
162+
try {
163+
const results = await serverGet(
164+
`/api/v1/accounts/search?q=${
165+
encodeURIComponent(`@${handle}`)
166+
}&resolve=true&limit=5`,
167+
) as RemoteAccount[];
168+
const match = results?.find((a) =>
169+
a.acct === handle || a.acct === `@${handle}`
170+
);
171+
if (match) return match;
172+
} catch {
173+
// Search endpoint may return 404 on some servers (e.g. Sharkey);
174+
// fall through to the lookup endpoint.
175+
}
176+
177+
try {
178+
const account = await serverGet(
179+
`/api/v1/accounts/lookup?acct=${encodeURIComponent(handle)}`,
180+
) as RemoteAccount;
181+
if (account?.id) return account;
182+
} catch {
183+
// lookup also failed
184+
}
185+
186+
// Misskey-native fallback: POST /api/users/show with username + host.
187+
// Sharkey's Mastodon-compat search/lookup endpoints have bugs with
188+
// remote users, but the native API works reliably. The returned id
189+
// is the same internal ID used by the Mastodon-compat layer.
190+
try {
191+
const [user, host] = handle.split("@");
192+
const res = await fetch(`${SERVER_URL}/api/users/show`, {
193+
method: "POST",
194+
headers: { "Content-Type": "application/json" },
195+
body: JSON.stringify({ username: user, host }),
196+
});
197+
if (res.ok) {
198+
const data = await res.json() as { id?: string };
199+
if (data?.id) return { id: data.id, acct: handle };
200+
}
201+
} catch {
202+
// Not a Misskey-family server
203+
}
204+
205+
return null;
159206
});
160207

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

346-
await pollHarnessInbox("Undo", (a) => !knownIds.has(a.id));
347-
393+
// Primary assertion: the server-side relationship must show following=false.
348394
await poll("unfollow confirmed", async () => {
349395
const rels = await serverGet(
350396
`/api/v1/accounts/relationships?id[]=${accountId}`,
351397
) as Relationship[];
352398
const rel = rels.find((r) => r.id === accountId);
353399
return rel && !rel.following ? rel : null;
354400
});
401+
402+
// The harness should receive an Undo Follow activity.
403+
await pollHarnessInbox("Undo", (a) => !knownIds.has(a.id));
355404
}
356405

357406
// ---------------------------------------------------------------------------
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
auto_https off
3+
}
4+
5+
:443 {
6+
tls /certs/fedify-harness.crt /certs/fedify-harness.key
7+
reverse_proxy fedify-harness-backend:3001
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
auto_https off
3+
}
4+
5+
:443 {
6+
tls /certs/sharkey.crt /certs/sharkey.key
7+
reverse_proxy sharkey-web-backend:3000
8+
}

test/smoke/sharkey/default.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Sharkey configuration for smoke tests.
2+
3+
url: https://sharkey
4+
port: 3000
5+
6+
db:
7+
host: db
8+
port: 5432
9+
db: sharkey
10+
user: sharkey
11+
pass: sharkey
12+
13+
redis:
14+
host: redis
15+
port: 6379
16+
17+
id: aidx
18+
19+
# Allow Docker-internal traffic (private IPs, both IPv4 and IPv6)
20+
allowedPrivateNetworks:
21+
- 0.0.0.0/0
22+
- ::/0
23+
24+
# Require signatures on ActivityPub GET requests (strict mode)
25+
signToActivityPubGet: true
26+
27+
# Setup password used for initial admin account creation via API
28+
setupPassword: smoke-test-setup
29+
30+
# Disable unnecessary features for smoke tests
31+
clusterLimit: 1

0 commit comments

Comments
 (0)