A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
1#!/bin/bash 2# E2E tests for PDS - runs against local wrangler dev 3set -e 4 5BASE="http://localhost:8787" 6# Generate unique test DID (or use env var for consistency) 7DID="${TEST_DID:-did:plc:test$(openssl rand -hex 8)}" 8 9# Helper for colored output 10pass() { echo "$1"; } 11fail() { 12 echo "$1" >&2 13 cleanup 14 exit 1 15} 16 17# Cleanup function 18cleanup() { 19 if [ -n "$WRANGLER_PID" ]; then 20 echo "Shutting down wrangler..." 21 kill $WRANGLER_PID 2>/dev/null || true 22 wait $WRANGLER_PID 2>/dev/null || true 23 fi 24} 25trap cleanup EXIT 26 27# Start wrangler dev with local R2 persistence 28echo "Starting wrangler dev..." 29npx wrangler dev --port 8787 --persist-to .wrangler/state >/dev/null 2>&1 & 30WRANGLER_PID=$! 31 32# Wait for server to be ready 33for i in {1..30}; do 34 if curl -sf "$BASE/" >/dev/null 2>&1; then 35 break 36 fi 37 sleep 0.5 38done 39 40# Verify server is up 41curl -sf "$BASE/" >/dev/null || fail "Server failed to start" 42pass "Server started" 43 44# Initialize PDS 45PRIVKEY=$(openssl rand -hex 32) 46curl -sf -X POST "$BASE/init?did=$DID" \ 47 -H "Content-Type: application/json" \ 48 -d "{\"did\":\"$DID\",\"privateKey\":\"$PRIVKEY\",\"handle\":\"test.local\"}" >/dev/null && 49 pass "PDS initialized" || fail "PDS init" 50 51echo 52echo "Running tests..." 53echo 54 55# Root returns ASCII art 56curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 57 58# describeServer works 59curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 60 pass "describeServer" || fail "describeServer" 61 62# resolveHandle works 63curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 64 jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 65 66# createSession returns tokens 67SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 68 -H "Content-Type: application/json" \ 69 -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 70TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 71[ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 72 73# getSession works with token 74curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 75 -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 76 pass "getSession with valid token" || fail "getSession" 77 78# refreshSession returns new tokens 79REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') 80REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 81 -H "Authorization: Bearer $REFRESH_TOKEN") 82NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') 83NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') 84[ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && 85 pass "refreshSession returns new tokens" || fail "refreshSession" 86 87# New access token from refresh works 88curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 89 -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && 90 pass "refreshed access token works" || fail "refreshed token" 91 92# refreshSession rejects access token (wrong type) 93STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 94 -H "Authorization: Bearer $TOKEN") 95[ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" 96 97# refreshSession rejects missing auth 98STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") 99[ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" 100 101# refreshSession rejects malformed token 102STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 103 -H "Authorization: Bearer not-a-valid-jwt") 104[ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" 105 106# Protected endpoint rejects without auth 107STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 108 -H "Content-Type: application/json" \ 109 -d '{"repo":"x","collection":"x","record":{}}') 110[ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 111 112# getPreferences works (returns empty array initially) 113curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 114 -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 115 pass "getPreferences" || fail "getPreferences" 116 117# putPreferences works 118curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 119 -H "Authorization: Bearer $TOKEN" \ 120 -H "Content-Type: application/json" \ 121 -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 122 pass "putPreferences" || fail "putPreferences" 123 124# createRecord works with auth 125RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 126 -H "Authorization: Bearer $TOKEN" \ 127 -H "Content-Type: application/json" \ 128 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"test\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}") 129URI=$(echo "$RECORD" | jq -r '.uri') 130[ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 131 132# getRecord retrieves it 133RKEY=$(echo "$URI" | sed 's|.*/||') 134curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 135 jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 136 137# putRecord updates the record 138curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 139 -H "Authorization: Bearer $TOKEN" \ 140 -H "Content-Type: application/json" \ 141 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 142 jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 143 144# listRecords shows the record 145curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 146 jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 147 148# describeRepo returns repo info 149curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 150 jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 151 152# applyWrites batch operation (create then delete a record) 153APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 154 -H "Authorization: Bearer $TOKEN" \ 155 -H "Content-Type: application/json" \ 156 -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)\"}}]}") 157echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 158 159# applyWrites delete 160curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 161 -H "Authorization: Bearer $TOKEN" \ 162 -H "Content-Type: application/json" \ 163 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 164 jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 165 166# sync.getLatestCommit returns head 167curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 168 jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 169 170# sync.getRepoStatus returns status 171curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 172 jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 173 174# sync.getRepo returns CAR file 175REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 176[ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 177 178# sync.getRecord returns record with proof (binary CAR data) 179RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 180[ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 181 182# sync.listRepos lists repos 183curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 184 jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 185 186# Error handling tests 187echo 188echo "Testing error handling..." 189 190# Invalid password rejected 191STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 192 -H "Content-Type: application/json" \ 193 -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 194[ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 195 196# Wrong repo rejected (can't modify another user's repo) 197STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 198 -H "Authorization: Bearer $TOKEN" \ 199 -H "Content-Type: application/json" \ 200 -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 201[ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 202 203# Non-existent record returns 404 204STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") 205[ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 206 207# Blob tests 208echo 209echo "Testing blob endpoints..." 210 211# Create a minimal valid PNG (1x1 transparent pixel) 212# PNG signature + IHDR + IDAT + IEND 213PNG_FILE=$(mktemp) 214printf '\x89PNG\r\n\x1a\n' >"$PNG_FILE" 215printf '\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' >>"$PNG_FILE" 216printf '\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4' >>"$PNG_FILE" 217printf '\x00\x00\x00\x00IEND\xaeB`\x82' >>"$PNG_FILE" 218 219# uploadBlob requires auth 220STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ 221 -H "Content-Type: image/png" \ 222 --data-binary @"$PNG_FILE") 223[ "$STATUS" = "401" ] && pass "uploadBlob rejects without auth" || fail "uploadBlob should require auth" 224 225# uploadBlob works with auth 226BLOB_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ 227 -H "Authorization: Bearer $TOKEN" \ 228 -H "Content-Type: image/png" \ 229 --data-binary @"$PNG_FILE") 230BLOB_CID=$(echo "$BLOB_RESULT" | jq -r '.blob.ref."$link"') 231BLOB_MIME=$(echo "$BLOB_RESULT" | jq -r '.blob.mimeType') 232[ "$BLOB_CID" != "null" ] && [ -n "$BLOB_CID" ] && pass "uploadBlob returns CID" || fail "uploadBlob" 233[ "$BLOB_MIME" = "image/png" ] && pass "uploadBlob detects PNG mime type" || fail "uploadBlob mime detection" 234 235# listBlobs shows the uploaded blob 236curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | 237 jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "listBlobs includes uploaded blob" || fail "listBlobs" 238 239# getBlob retrieves the blob 240BLOB_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID" | wc -c) 241[ "$BLOB_SIZE" -gt 0 ] && pass "getBlob retrieves blob data" || fail "getBlob" 242 243# getBlob returns correct headers 244BLOB_HEADERS=$(curl -sI "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID") 245echo "$BLOB_HEADERS" | grep -qi "content-type: image/png" && pass "getBlob Content-Type header" || fail "getBlob Content-Type" 246echo "$BLOB_HEADERS" | grep -qi "x-content-type-options: nosniff" && pass "getBlob security headers" || fail "getBlob security headers" 247 248# getBlob rejects wrong DID 249STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=$BLOB_CID") 250[ "$STATUS" = "400" ] && pass "getBlob rejects wrong DID" || fail "getBlob should reject wrong DID" 251 252# getBlob returns 400 for invalid CID format 253STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=invalid") 254[ "$STATUS" = "400" ] && pass "getBlob rejects invalid CID format" || fail "getBlob should reject invalid CID" 255 256# getBlob returns 404 for non-existent blob (valid format CID - 59 chars) 257STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 258[ "$STATUS" = "404" ] && pass "getBlob 404 for missing blob" || fail "getBlob should 404" 259 260# Create a record with blob reference 261BLOB_POST=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 262 -H "Authorization: Bearer $TOKEN" \ 263 -H "Content-Type: application/json" \ 264 -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\"}]}}}") 265BLOB_POST_URI=$(echo "$BLOB_POST" | jq -r '.uri') 266BLOB_POST_RKEY=$(echo "$BLOB_POST_URI" | sed 's|.*/||') 267[ "$BLOB_POST_URI" != "null" ] && [ -n "$BLOB_POST_URI" ] && pass "createRecord with blob ref" || fail "createRecord with blob" 268 269# Blob still exists after record creation 270curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | 271 jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "blob persists after record creation" || fail "blob should persist" 272 273# Delete the record with blob 274curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 275 -H "Authorization: Bearer $TOKEN" \ 276 -H "Content-Type: application/json" \ 277 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$BLOB_POST_RKEY\"}" >/dev/null && 278 pass "deleteRecord with blob" || fail "deleteRecord with blob" 279 280# Blob should be cleaned up (orphaned) 281BLOB_COUNT=$(curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | jq '.cids | length') 282[ "$BLOB_COUNT" = "0" ] && pass "orphaned blob cleaned up on delete" || fail "blob should be cleaned up" 283 284# Clean up temp file 285rm -f "$PNG_FILE" 286 287# Cleanup: delete the test record 288curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 289 -H "Authorization: Bearer $TOKEN" \ 290 -H "Content-Type: application/json" \ 291 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\"}" >/dev/null && 292 pass "deleteRecord (cleanup)" || fail "deleteRecord" 293 294echo 295echo "All tests passed!"