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" 6DID="did:plc:c6vxslynzebnlk5kw2orx37o" 7 8# Helper for colored output 9pass() { echo "$1"; } 10fail() { 11 echo "$1" >&2 12 cleanup 13 exit 1 14} 15 16# Cleanup function 17cleanup() { 18 if [ -n "$WRANGLER_PID" ]; then 19 echo "Shutting down wrangler..." 20 kill $WRANGLER_PID 2>/dev/null || true 21 wait $WRANGLER_PID 2>/dev/null || true 22 fi 23} 24trap cleanup EXIT 25 26# Start wrangler dev 27echo "Starting wrangler dev..." 28npx wrangler dev --port 8787 >/dev/null 2>&1 & 29WRANGLER_PID=$! 30 31# Wait for server to be ready 32for i in {1..30}; do 33 if curl -sf "$BASE/" >/dev/null 2>&1; then 34 break 35 fi 36 sleep 0.5 37done 38 39# Verify server is up 40curl -sf "$BASE/" >/dev/null || fail "Server failed to start" 41pass "Server started" 42 43# Initialize PDS 44PRIVKEY=$(openssl rand -hex 32) 45curl -sf -X POST "$BASE/init?did=$DID" \ 46 -H "Content-Type: application/json" \ 47 -d "{\"did\":\"$DID\",\"privateKey\":\"$PRIVKEY\",\"handle\":\"test.local\"}" >/dev/null && 48 pass "PDS initialized" || fail "PDS init" 49 50echo 51echo "Running tests..." 52echo 53 54# Root returns ASCII art 55curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 56 57# describeServer works 58curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 59 pass "describeServer" || fail "describeServer" 60 61# resolveHandle works 62curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 63 jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 64 65# createSession returns tokens 66SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 67 -H "Content-Type: application/json" \ 68 -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 69TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 70[ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 71 72# getSession works with token 73curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 74 -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 75 pass "getSession with valid token" || fail "getSession" 76 77# refreshSession returns new tokens 78REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') 79REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 80 -H "Authorization: Bearer $REFRESH_TOKEN") 81NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') 82NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') 83[ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && 84 pass "refreshSession returns new tokens" || fail "refreshSession" 85 86# New access token from refresh works 87curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 88 -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && 89 pass "refreshed access token works" || fail "refreshed token" 90 91# refreshSession rejects access token (wrong type) 92STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 93 -H "Authorization: Bearer $TOKEN") 94[ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" 95 96# refreshSession rejects missing auth 97STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") 98[ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" 99 100# refreshSession rejects malformed token 101STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 102 -H "Authorization: Bearer not-a-valid-jwt") 103[ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" 104 105# Protected endpoint rejects without auth 106STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 107 -H "Content-Type: application/json" \ 108 -d '{"repo":"x","collection":"x","record":{}}') 109[ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 110 111# getPreferences works (returns empty array initially) 112curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 113 -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 114 pass "getPreferences" || fail "getPreferences" 115 116# putPreferences works 117curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 118 -H "Authorization: Bearer $TOKEN" \ 119 -H "Content-Type: application/json" \ 120 -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 121 pass "putPreferences" || fail "putPreferences" 122 123# createRecord works with auth 124RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 125 -H "Authorization: Bearer $TOKEN" \ 126 -H "Content-Type: application/json" \ 127 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"test\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}") 128URI=$(echo "$RECORD" | jq -r '.uri') 129[ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 130 131# getRecord retrieves it 132RKEY=$(echo "$URI" | sed 's|.*/||') 133curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 134 jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 135 136# putRecord updates the record 137curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 138 -H "Authorization: Bearer $TOKEN" \ 139 -H "Content-Type: application/json" \ 140 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 141 jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 142 143# listRecords shows the record 144curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 145 jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 146 147# describeRepo returns repo info 148curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 149 jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 150 151# applyWrites batch operation (create then delete a record) 152APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 153 -H "Authorization: Bearer $TOKEN" \ 154 -H "Content-Type: application/json" \ 155 -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)\"}}]}") 156echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 157 158# applyWrites delete 159curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 160 -H "Authorization: Bearer $TOKEN" \ 161 -H "Content-Type: application/json" \ 162 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 163 jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 164 165# sync.getLatestCommit returns head 166curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 167 jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 168 169# sync.getRepoStatus returns status 170curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 171 jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 172 173# sync.getRepo returns CAR file 174REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 175[ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 176 177# sync.getRecord returns record with proof (binary CAR data) 178RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 179[ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 180 181# sync.listRepos lists repos 182curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 183 jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 184 185# Error handling tests 186echo 187echo "Testing error handling..." 188 189# Invalid password rejected 190STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 191 -H "Content-Type: application/json" \ 192 -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 193[ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 194 195# Wrong repo rejected (can't modify another user's repo) 196STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 197 -H "Authorization: Bearer $TOKEN" \ 198 -H "Content-Type: application/json" \ 199 -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 200[ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 201 202# Non-existent record returns 404 203STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") 204[ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 205 206# Cleanup: delete the test record 207curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 208 -H "Authorization: Bearer $TOKEN" \ 209 -H "Content-Type: application/json" \ 210 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\"}" >/dev/null && 211 pass "deleteRecord (cleanup)" || fail "deleteRecord" 212 213echo 214echo "All tests passed!"