diff --git a/.github/workflows/smoke-sharkey.yml b/.github/workflows/smoke-sharkey.yml new file mode 100644 index 000000000..4e35ec9e6 --- /dev/null +++ b/.github/workflows/smoke-sharkey.yml @@ -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 diff --git a/.gitignore b/.gitignore index dad1a3cfd..9cfc419b0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/test/smoke/harness/backdoor.ts b/test/smoke/harness/backdoor.ts index 091ff8192..2db213870 100644 --- a/test/smoke/harness/backdoor.ts +++ b/test/smoke/harness/backdoor.ts @@ -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(); + +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 + } + + // 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( @@ -35,6 +79,7 @@ export async function handleBackdoor( if (url.pathname === "/_test/reset" && request.method === "POST") { store.clear(); + recipientCache.clear(); return json({ ok: true }); } @@ -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(); @@ -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({ @@ -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({ diff --git a/test/smoke/orchestrator.ts b/test/smoke/orchestrator.ts index 586f9ee04..43f230a55 100644 --- a/test/smoke/orchestrator.ts +++ b/test/smoke/orchestrator.ts @@ -62,6 +62,10 @@ type InboxItem = { async function snapshotInboxIds(): Promise> { 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)); } @@ -72,6 +76,11 @@ function pollHarnessInbox( ): Promise { 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 && @@ -147,15 +156,53 @@ async function lookupFedifyAccount(): Promise { 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; @@ -343,8 +390,7 @@ async function testUnfollowMastodonFromFedify(): Promise { 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}`, @@ -352,6 +398,9 @@ async function testUnfollowMastodonFromFedify(): Promise { 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)); } // --------------------------------------------------------------------------- diff --git a/test/smoke/sharkey/Caddyfile.fedify-harness b/test/smoke/sharkey/Caddyfile.fedify-harness new file mode 100644 index 000000000..b8f1ca64b --- /dev/null +++ b/test/smoke/sharkey/Caddyfile.fedify-harness @@ -0,0 +1,8 @@ +{ + auto_https off +} + +:443 { + tls /certs/fedify-harness.crt /certs/fedify-harness.key + reverse_proxy fedify-harness-backend:3001 +} diff --git a/test/smoke/sharkey/Caddyfile.sharkey b/test/smoke/sharkey/Caddyfile.sharkey new file mode 100644 index 000000000..31273f93b --- /dev/null +++ b/test/smoke/sharkey/Caddyfile.sharkey @@ -0,0 +1,8 @@ +{ + auto_https off +} + +:443 { + tls /certs/sharkey.crt /certs/sharkey.key + reverse_proxy sharkey-web-backend:3000 +} diff --git a/test/smoke/sharkey/default.yml b/test/smoke/sharkey/default.yml new file mode 100644 index 000000000..61c8d4c52 --- /dev/null +++ b/test/smoke/sharkey/default.yml @@ -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 diff --git a/test/smoke/sharkey/docker-compose.yml b/test/smoke/sharkey/docker-compose.yml new file mode 100644 index 000000000..d0b90863a --- /dev/null +++ b/test/smoke/sharkey/docker-compose.yml @@ -0,0 +1,138 @@ +# Docker Compose for Sharkey smoke tests +# Usage: docker compose -f docker-compose.yml ... +# +# This is a standalone file (NOT an override) because Docker Compose merges +# network aliases additively and cannot remove a service's own DNS name from +# a network. Using an override would cause both the backend service and its +# Caddy proxy to resolve to the same hostname, breaking TLS routing. +# +# Architecture: +# - Backend services are renamed (e.g. fedify-harness-backend) so they +# don't collide with the TLS hostnames on the network +# - Caddy proxies claim the canonical hostnames (fedify-harness, sharkey) +# via network aliases and terminate TLS +# - All services share a single network for simplicity; DNS resolution +# is unambiguous because backend names differ from Caddy aliases + +volumes: + harness-node-modules: + +networks: + smoke: + driver: bridge + +services: + db: + image: postgres:15-alpine + env_file: docker.env + networks: [smoke] + healthcheck: + test: ["CMD", "pg_isready", "-U", "sharkey"] + interval: 5s + retries: 10 + + redis: + image: redis:7-alpine + networks: [smoke] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + retries: 10 + + # Fedify test harness — renamed to avoid colliding with the Caddy alias. + fedify-harness-backend: + image: denoland/deno:2.7.1 + working_dir: /workspace + restart: on-failure + volumes: + - ../../../:/workspace + - harness-node-modules:/workspace/node_modules + - ./.certs:/certs:ro + command: + - run + - --allow-net + - --allow-env + - --allow-read + - --allow-write + - --unstable-temporal + - test/smoke/harness/main.ts + environment: + HARNESS_ORIGIN: "https://fedify-harness" + STRICT_MODE: "1" + DENO_CERT: "/certs/ca.crt" + networks: [smoke] + ports: ["3001:3001"] + healthcheck: + test: + [ + "CMD", + "deno", + "eval", + "const r = await fetch('http://localhost:3001/_test/health'); if (!r.ok) Deno.exit(1);", + ] + interval: 5s + retries: 30 + + # Caddy TLS proxy for the Fedify harness. + # Owns the "fedify-harness" hostname so other containers reach TLS. + caddy-harness: + image: caddy:2.11.2-alpine + volumes: + - ./Caddyfile.fedify-harness:/etc/caddy/Caddyfile:ro + - ./.certs:/certs:ro + networks: + smoke: + aliases: [fedify-harness] + depends_on: + fedify-harness-backend: { condition: service_healthy } + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 5s + retries: 5 + + # Sharkey web + sharkey-web-backend: + image: registry.activitypub.software/transfem-org/sharkey:2025.4.6 + command: + - sh + - -c + - | + cat /etc/ssl/certs/ca-certificates.crt /certs/ca.crt > /tmp/ca-bundle.crt + pnpm run migrateandstart + volumes: + - ./default.yml:/sharkey/.config/default.yml:ro + - ./.certs:/certs:ro + environment: + NODE_EXTRA_CA_CERTS: /tmp/ca-bundle.crt + networks: [smoke] + ports: ["3000:3000"] + depends_on: + db: { condition: service_healthy } + redis: { condition: service_healthy } + healthcheck: + test: + [ + "CMD-SHELL", + "wget -qO- --post-data='{}' --header='Content-Type: application/json' http://127.0.0.1:3000/api/ping || exit 1", + ] + interval: 10s + retries: 30 + start_period: 300s + + # Caddy TLS proxy for Sharkey. + # Owns the "sharkey" hostname so other containers reach TLS. + caddy-sharkey: + image: caddy:2.11.2-alpine + volumes: + - ./Caddyfile.sharkey:/etc/caddy/Caddyfile:ro + - ./.certs:/certs:ro + networks: + smoke: + aliases: [sharkey] + ports: ["4443:443"] + depends_on: + sharkey-web-backend: { condition: service_healthy } + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 5s + retries: 5 diff --git a/test/smoke/sharkey/docker.env b/test/smoke/sharkey/docker.env new file mode 100644 index 000000000..d1868c54b --- /dev/null +++ b/test/smoke/sharkey/docker.env @@ -0,0 +1,3 @@ +POSTGRES_DB=sharkey +POSTGRES_USER=sharkey +POSTGRES_PASSWORD=sharkey diff --git a/test/smoke/sharkey/generate-certs.sh b/test/smoke/sharkey/generate-certs.sh new file mode 100755 index 000000000..64c87b0e4 --- /dev/null +++ b/test/smoke/sharkey/generate-certs.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Generate a self-signed CA and leaf certificates for strict-mode smoke tests. +# Usage: bash generate-certs.sh [output-dir] +# +# Output directory defaults to .certs/ (relative to this script). +# The CA is ephemeral — generated fresh each run. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OUT="${1:-$SCRIPT_DIR/.certs}" +mkdir -p "$OUT" + +HOSTS=(fedify-harness sharkey) + +echo "→ Generating CA key + certificate..." +openssl genrsa -out "$OUT/ca.key" 2048 2>/dev/null +openssl req -x509 -new -nodes \ + -key "$OUT/ca.key" \ + -sha256 -days 1 \ + -subj "/CN=Smoke Test CA" \ + -out "$OUT/ca.crt" 2>/dev/null + +for HOST in "${HOSTS[@]}"; do + echo "→ Generating certificate for $HOST..." + openssl genrsa -out "$OUT/$HOST.key" 2048 2>/dev/null + openssl req -new \ + -key "$OUT/$HOST.key" \ + -subj "/CN=$HOST" \ + -out "$OUT/$HOST.csr" 2>/dev/null + + # Create a SAN extension config so the cert is valid for the hostname + cat > "$OUT/$HOST.ext" </dev/null + + rm -f "$OUT/$HOST.csr" "$OUT/$HOST.ext" +done + +rm -f "$OUT/ca.srl" + +echo "✓ Certificates written to $OUT/" +ls -la "$OUT" diff --git a/test/smoke/sharkey/provision.sh b/test/smoke/sharkey/provision.sh new file mode 100755 index 000000000..97dad1eb2 --- /dev/null +++ b/test/smoke/sharkey/provision.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Provision Sharkey for smoke tests. +# +# Uses ap/show API to discover Fedify account over HTTPS (via Caddy TLS). +# Talks to sharkey-web-backend directly (HTTP on port 3000) for API calls. +set -euo pipefail + +SHARKEY_URL="http://localhost:3000" +SETUP_PASSWORD="smoke-test-setup" + +echo "→ Creating admin account..." +ADMIN_RAW=$(curl -sf -X POST "$SHARKEY_URL/api/admin/accounts/create" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"admin\", + \"password\": \"adminpassword123\", + \"setupPassword\": \"$SETUP_PASSWORD\" + }" 2>&1) || true +echo " admin creation response: ${ADMIN_RAW:0:200}" + +ADMIN_TOKEN=$(echo "$ADMIN_RAW" | jq -r '.token // empty' 2>/dev/null || true) +if [ -z "$ADMIN_TOKEN" ]; then + echo " Admin may already exist, signing in..." + SIGN_IN=$(curl -sf -X POST "$SHARKEY_URL/api/signin" \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "adminpassword123"}') + ADMIN_TOKEN=$(echo "$SIGN_IN" | jq -r '.i // empty' 2>/dev/null || true) +fi + +if [ -z "$ADMIN_TOKEN" ]; then + echo "✗ Failed to obtain admin token" + exit 1 +fi +echo " admin token: ${ADMIN_TOKEN:0:8}..." + +echo "→ Creating test user via admin API..." +TEST_RAW=$(curl -sf -X POST "$SHARKEY_URL/api/admin/accounts/create" \ + -H "Content-Type: application/json" \ + -d "{ + \"i\": \"$ADMIN_TOKEN\", + \"username\": \"testuser\", + \"password\": \"testpassword123\" + }" 2>&1) || true +echo " testuser creation response: ${TEST_RAW:0:200}" + +# Try to extract token directly from the creation response +TEST_TOKEN=$(echo "$TEST_RAW" | jq -r '.token // empty' 2>/dev/null || true) + +# If no token in creation response, sign in +if [ -z "$TEST_TOKEN" ]; then + echo "→ Signing in as testuser..." + SIGN_IN_RAW=$(curl -sf -X POST "$SHARKEY_URL/api/signin" \ + -H "Content-Type: application/json" \ + -d '{"username": "testuser", "password": "testpassword123"}' 2>&1) || true + echo " signin response: ${SIGN_IN_RAW:0:200}" + TEST_TOKEN=$(echo "$SIGN_IN_RAW" | jq -r '.i // empty' 2>/dev/null || true) +fi + +if [ -z "$TEST_TOKEN" ]; then + echo " Trying password reset flow..." + TESTUSER_INFO=$(curl -sf -X POST "$SHARKEY_URL/api/users/show" \ + -H "Content-Type: application/json" \ + -d "{\"i\": \"$ADMIN_TOKEN\", \"username\": \"testuser\"}" 2>&1) || true + TESTUSER_ID=$(echo "$TESTUSER_INFO" | jq -r '.id // empty' 2>/dev/null || true) + + if [ -n "$TESTUSER_ID" ]; then + RESET_RAW=$(curl -sf -X POST "$SHARKEY_URL/api/admin/reset-password" \ + -H "Content-Type: application/json" \ + -d "{\"i\": \"$ADMIN_TOKEN\", \"userId\": \"$TESTUSER_ID\"}" 2>&1) || true + echo " reset-password response: ${RESET_RAW:0:200}" + NEW_PASS=$(echo "$RESET_RAW" | jq -r '.password // empty' 2>/dev/null || true) + if [ -n "$NEW_PASS" ]; then + SIGN_IN_RAW=$(curl -sf -X POST "$SHARKEY_URL/api/signin" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"testuser\", \"password\": \"$NEW_PASS\"}" 2>&1) || true + echo " signin after reset response: ${SIGN_IN_RAW:0:200}" + TEST_TOKEN=$(echo "$SIGN_IN_RAW" | jq -r '.i // empty' 2>/dev/null || true) + fi + fi +fi + +if [ -z "$TEST_TOKEN" ]; then + echo "✗ Failed to obtain test user token" + exit 1 +fi +echo " testuser token: ${TEST_TOKEN:0:8}..." + +TOKEN="$TEST_TOKEN" + +echo "→ Verifying Mastodon-compatible API access..." +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" \ + "$SHARKEY_URL/api/v1/accounts/verify_credentials") +echo " verify_credentials → HTTP $HTTP_CODE" +if [ "$HTTP_CODE" != "200" ]; then + echo "✗ Token verification failed (HTTP $HTTP_CODE)" + exit 1 +fi + +echo "→ Resolving Fedify account via ap/show (HTTPS via Caddy)..." +FEDIFY_USER_ID="" +for i in $(seq 1 5); do + RESOLVE_RAW=$(curl -s -X POST "$SHARKEY_URL/api/ap/show" \ + -H "Content-Type: application/json" \ + -d "{ + \"i\": \"$ADMIN_TOKEN\", + \"uri\": \"https://fedify-harness/users/testuser\" + }" 2>&1) || true + echo " ap/show attempt $i: ${RESOLVE_RAW:0:300}" + FEDIFY_USER_ID=$(echo "$RESOLVE_RAW" | jq -r '.object.id // empty' 2>/dev/null || true) + if [ -n "$FEDIFY_USER_ID" ]; then + break + fi + echo " Retrying in 5s..." + sleep 5 +done + +if [ -z "$FEDIFY_USER_ID" ]; then + echo "✗ Failed to resolve Fedify user via ap/show — WebFinger/HTTPS discovery failed" + exit 1 +fi +echo " Fedify user resolved: $FEDIFY_USER_ID" + +echo "→ Creating follow relationship (testuser follows Fedify account)..." +echo " Fedify user ID in Sharkey: $FEDIFY_USER_ID" +FOLLOW_RAW=$(curl -s -X POST "$SHARKEY_URL/api/following/create" \ + -H "Content-Type: application/json" \ + -d "{ + \"i\": \"$TEST_TOKEN\", + \"userId\": \"$FEDIFY_USER_ID\" + }" 2>&1) || true +echo " follow response: ${FOLLOW_RAW:0:200}" + +echo "→ Writing test env..." +cat > test/smoke/.env.test <