A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
46
fork

Configure Feed

Select the types of activity you want to include in your feed.

at c08faf5b927d4148e160d8723733bcf184b36fdb 295 lines 14 kB view raw
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!"