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!"