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