#!/bin/bash # E2E tests for PDS - runs against local wrangler dev set -e BASE="http://localhost:8787" # Generate unique test DID (or use env var for consistency) DID="${TEST_DID:-did:plc:test$(openssl rand -hex 8)}" # Helper for colored output pass() { echo "✓ $1"; } fail() { echo "✗ $1" >&2 cleanup exit 1 } # Cleanup function cleanup() { if [ -n "$WRANGLER_PID" ]; then echo "Shutting down wrangler..." kill $WRANGLER_PID 2>/dev/null || true wait $WRANGLER_PID 2>/dev/null || true fi } trap cleanup EXIT # Start wrangler dev with local R2 persistence echo "Starting wrangler dev..." npx wrangler dev --port 8787 --persist-to .wrangler/state >/dev/null 2>&1 & WRANGLER_PID=$! # Wait for server to be ready for i in {1..30}; do if curl -sf "$BASE/" >/dev/null 2>&1; then break fi sleep 0.5 done # Verify server is up curl -sf "$BASE/" >/dev/null || fail "Server failed to start" pass "Server started" # Initialize PDS PRIVKEY=$(openssl rand -hex 32) curl -sf -X POST "$BASE/init?did=$DID" \ -H "Content-Type: application/json" \ -d "{\"did\":\"$DID\",\"privateKey\":\"$PRIVKEY\",\"handle\":\"test.local\"}" >/dev/null && pass "PDS initialized" || fail "PDS init" echo echo "Running tests..." echo # Root returns ASCII art curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" # describeServer works curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && pass "describeServer" || fail "describeServer" # resolveHandle works curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" # createSession returns tokens SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ -H "Content-Type: application/json" \ -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" # getSession works with token curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && pass "getSession with valid token" || fail "getSession" # refreshSession returns new tokens REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ -H "Authorization: Bearer $REFRESH_TOKEN") NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') [ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && pass "refreshSession returns new tokens" || fail "refreshSession" # New access token from refresh works curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && pass "refreshed access token works" || fail "refreshed token" # refreshSession rejects access token (wrong type) STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ -H "Authorization: Bearer $TOKEN") [ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" # refreshSession rejects missing auth STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") [ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" # refreshSession rejects malformed token STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ -H "Authorization: Bearer not-a-valid-jwt") [ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" # Protected endpoint rejects without auth STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Content-Type: application/json" \ -d '{"repo":"x","collection":"x","record":{}}') [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" # getPreferences works (returns empty array initially) curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && pass "getPreferences" || fail "getPreferences" # putPreferences works curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && pass "putPreferences" || fail "putPreferences" # createRecord works with auth RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"test\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}") URI=$(echo "$RECORD" | jq -r '.uri') [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" # getRecord retrieves it RKEY=$(echo "$URI" | sed 's|.*/||') curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" # putRecord updates the record curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" # listRecords shows the record curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" # describeRepo returns repo info curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" # applyWrites batch operation (create then delete a record) APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#create\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\",\"value\":{\"text\":\"batch\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}]}") echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" # applyWrites delete curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" # sync.getLatestCommit returns head curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" # sync.getRepoStatus returns status curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" # sync.getRepo returns CAR file REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" # sync.getRecord returns record with proof (binary CAR data) RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" # sync.listRepos lists repos curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" # Error handling tests echo echo "Testing error handling..." # Invalid password rejected STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ -H "Content-Type: application/json" \ -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" # Wrong repo rejected (can't modify another user's repo) STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" # Non-existent record returns 404 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" # Blob tests echo echo "Testing blob endpoints..." # Create a minimal valid PNG (1x1 transparent pixel) # PNG signature + IHDR + IDAT + IEND PNG_FILE=$(mktemp) printf '\x89PNG\r\n\x1a\n' > "$PNG_FILE" printf '\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' >> "$PNG_FILE" printf '\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4' >> "$PNG_FILE" printf '\x00\x00\x00\x00IEND\xaeB`\x82' >> "$PNG_FILE" # uploadBlob requires auth STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ -H "Content-Type: image/png" \ --data-binary @"$PNG_FILE") [ "$STATUS" = "401" ] && pass "uploadBlob rejects without auth" || fail "uploadBlob should require auth" # uploadBlob works with auth BLOB_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: image/png" \ --data-binary @"$PNG_FILE") BLOB_CID=$(echo "$BLOB_RESULT" | jq -r '.blob.ref."$link"') BLOB_MIME=$(echo "$BLOB_RESULT" | jq -r '.blob.mimeType') [ "$BLOB_CID" != "null" ] && [ -n "$BLOB_CID" ] && pass "uploadBlob returns CID" || fail "uploadBlob" [ "$BLOB_MIME" = "image/png" ] && pass "uploadBlob detects PNG mime type" || fail "uploadBlob mime detection" # listBlobs shows the uploaded blob curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "listBlobs includes uploaded blob" || fail "listBlobs" # getBlob retrieves the blob BLOB_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID" | wc -c) [ "$BLOB_SIZE" -gt 0 ] && pass "getBlob retrieves blob data" || fail "getBlob" # getBlob returns correct headers BLOB_HEADERS=$(curl -sI "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID") echo "$BLOB_HEADERS" | grep -qi "content-type: image/png" && pass "getBlob Content-Type header" || fail "getBlob Content-Type" echo "$BLOB_HEADERS" | grep -qi "x-content-type-options: nosniff" && pass "getBlob security headers" || fail "getBlob security headers" # getBlob rejects wrong DID STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=$BLOB_CID") [ "$STATUS" = "400" ] && pass "getBlob rejects wrong DID" || fail "getBlob should reject wrong DID" # getBlob returns 400 for invalid CID format STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=invalid") [ "$STATUS" = "400" ] && pass "getBlob rejects invalid CID format" || fail "getBlob should reject invalid CID" # getBlob returns 404 for non-existent blob (valid format CID - 59 chars) STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") [ "$STATUS" = "404" ] && pass "getBlob 404 for missing blob" || fail "getBlob should 404" # Create a record with blob reference BLOB_POST=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"post with image\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"embed\":{\"\$type\":\"app.bsky.embed.images\",\"images\":[{\"image\":{\"\$type\":\"blob\",\"ref\":{\"\$link\":\"$BLOB_CID\"},\"mimeType\":\"image/png\",\"size\":$(wc -c < "$PNG_FILE")},\"alt\":\"test\"}]}}}") BLOB_POST_URI=$(echo "$BLOB_POST" | jq -r '.uri') BLOB_POST_RKEY=$(echo "$BLOB_POST_URI" | sed 's|.*/||') [ "$BLOB_POST_URI" != "null" ] && [ -n "$BLOB_POST_URI" ] && pass "createRecord with blob ref" || fail "createRecord with blob" # Blob still exists after record creation curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "blob persists after record creation" || fail "blob should persist" # Delete the record with blob curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$BLOB_POST_RKEY\"}" >/dev/null && pass "deleteRecord with blob" || fail "deleteRecord with blob" # Blob should be cleaned up (orphaned) BLOB_COUNT=$(curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | jq '.cids | length') [ "$BLOB_COUNT" = "0" ] && pass "orphaned blob cleaned up on delete" || fail "blob should be cleaned up" # Clean up temp file rm -f "$PNG_FILE" # Cleanup: delete the test record curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\"}" >/dev/null && pass "deleteRecord (cleanup)" || fail "deleteRecord" echo echo "All tests passed!"