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