A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
1// Read-only mode tests
2import { describe, it, expect, beforeEach } from 'vitest';
3import { PersonalDataServer } from '../packages/core/src/pds.js';
4
5// Create minimal mock storage
6function createMockStorage() {
7 return {
8 async getRecord() { return null; },
9 async putRecord() {},
10 async listRecords() { return { records: [], cursor: null }; },
11 async listAllRecords() { return []; },
12 async deleteRecord() {},
13 async getBlock() { return null; },
14 async putBlock() {},
15 async getLatestCommit() { return { seq: 1, cid: 'bafytest', rev: '3abc' }; },
16 async putCommit() {},
17 async getEvents() { return { events: [], cursor: 0 }; },
18 async putEvent() {},
19 async getBlob() { return null; },
20 async putBlob() {},
21 async deleteBlob() {},
22 async listBlobs() { return { cids: [], cursor: null }; },
23 async getOrphanedBlobs() { return []; },
24 async linkBlobToRecord() {},
25 async unlinkBlobsFromRecord() {},
26 async getDid() { return 'did:plc:test123'; },
27 async setDid() {},
28 async getHandle() { return 'test.handle'; },
29 async setHandle() {},
30 async getPrivateKey() { return null; },
31 async setPrivateKey() {},
32 async getPreferences() { return []; },
33 async setPreferences() {},
34 };
35}
36
37function createMockSharedStorage() {
38 return {
39 async getActor() { return null; },
40 async resolveHandle() { return null; },
41 async putActor() {},
42 async deleteActor() {},
43 async getOAuthRequest() { return null; },
44 async putOAuthRequest() {},
45 async deleteOAuthRequest() {},
46 async getOAuthToken() { return null; },
47 async putOAuthToken() {},
48 async deleteOAuthToken() {},
49 async listOAuthTokensByDid() { return []; },
50 async checkAndStoreDpopJti() { return true; },
51 async cleanupExpiredDpopJtis() {},
52 };
53}
54
55function createMockBlobs() {
56 return {
57 async put() {},
58 async get() { return null; },
59 async delete() {},
60 async has() { return false; },
61 async list() { return { cids: [], cursor: null }; },
62 };
63}
64
65describe('Read-only mode', () => {
66 /** @type {PersonalDataServer} */
67 let pds;
68
69 beforeEach(() => {
70 pds = new PersonalDataServer({
71 actorStorage: createMockStorage(),
72 sharedStorage: createMockSharedStorage(),
73 blobs: createMockBlobs(),
74 jwtSecret: 'test-secret',
75 readOnly: true,
76 });
77 });
78
79 it('should allow GET requests (describeServer)', async () => {
80 const request = new Request('https://pds.example.com/xrpc/com.atproto.server.describeServer');
81 const response = await pds.fetch(request);
82 expect(response.status).toBe(200);
83 });
84
85 it('should allow GET requests (listRepos)', async () => {
86 const request = new Request('https://pds.example.com/xrpc/com.atproto.sync.listRepos');
87 const response = await pds.fetch(request);
88 expect(response.status).toBe(200);
89 });
90
91 it('should reject createSession with 401', async () => {
92 const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', {
93 method: 'POST',
94 headers: { 'Content-Type': 'application/json' },
95 body: JSON.stringify({ identifier: 'test', password: 'pass' }),
96 });
97 const response = await pds.fetch(request);
98 expect(response.status).toBe(401);
99 const body = await response.json();
100 expect(body.error).toBe('AuthenticationRequired');
101 expect(body.message).toBe('This PDS is read-only');
102 });
103
104 it('should reject refreshSession with 401', async () => {
105 const request = new Request('https://pds.example.com/xrpc/com.atproto.server.refreshSession', {
106 method: 'POST',
107 headers: { 'Authorization': 'Bearer test-token' },
108 });
109 const response = await pds.fetch(request);
110 expect(response.status).toBe(401);
111 const body = await response.json();
112 expect(body.error).toBe('AuthenticationRequired');
113 });
114
115 it('should reject createRecord with 401', async () => {
116 const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.createRecord', {
117 method: 'POST',
118 headers: {
119 'Content-Type': 'application/json',
120 'Authorization': 'Bearer test-token',
121 },
122 body: JSON.stringify({
123 repo: 'did:plc:test',
124 collection: 'app.bsky.feed.post',
125 record: { text: 'test' },
126 }),
127 });
128 const response = await pds.fetch(request);
129 expect(response.status).toBe(401);
130 const body = await response.json();
131 expect(body.error).toBe('AuthenticationRequired');
132 expect(body.message).toBe('This PDS is read-only');
133 });
134
135 it('should reject putRecord with 401', async () => {
136 const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.putRecord', {
137 method: 'POST',
138 headers: {
139 'Content-Type': 'application/json',
140 'Authorization': 'Bearer test-token',
141 },
142 body: JSON.stringify({
143 repo: 'did:plc:test',
144 collection: 'app.bsky.feed.post',
145 rkey: '3abc',
146 record: { text: 'test' },
147 }),
148 });
149 const response = await pds.fetch(request);
150 expect(response.status).toBe(401);
151 const body = await response.json();
152 expect(body.error).toBe('AuthenticationRequired');
153 });
154
155 it('should reject deleteRecord with 401', async () => {
156 const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.deleteRecord', {
157 method: 'POST',
158 headers: {
159 'Content-Type': 'application/json',
160 'Authorization': 'Bearer test-token',
161 },
162 body: JSON.stringify({
163 repo: 'did:plc:test',
164 collection: 'app.bsky.feed.post',
165 rkey: '3abc',
166 }),
167 });
168 const response = await pds.fetch(request);
169 expect(response.status).toBe(401);
170 const body = await response.json();
171 expect(body.error).toBe('AuthenticationRequired');
172 });
173
174 it('should reject applyWrites with 401', async () => {
175 const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.applyWrites', {
176 method: 'POST',
177 headers: {
178 'Content-Type': 'application/json',
179 'Authorization': 'Bearer test-token',
180 },
181 body: JSON.stringify({
182 repo: 'did:plc:test',
183 writes: [],
184 }),
185 });
186 const response = await pds.fetch(request);
187 expect(response.status).toBe(401);
188 const body = await response.json();
189 expect(body.error).toBe('AuthenticationRequired');
190 });
191
192 it('should reject uploadBlob with 401', async () => {
193 const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.uploadBlob', {
194 method: 'POST',
195 headers: {
196 'Content-Type': 'image/png',
197 'Authorization': 'Bearer test-token',
198 },
199 body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
200 });
201 const response = await pds.fetch(request);
202 expect(response.status).toBe(401);
203 const body = await response.json();
204 expect(body.error).toBe('AuthenticationRequired');
205 });
206
207 it('should reject putPreferences with 401', async () => {
208 const request = new Request('https://pds.example.com/xrpc/app.bsky.actor.putPreferences', {
209 method: 'POST',
210 headers: {
211 'Content-Type': 'application/json',
212 'Authorization': 'Bearer test-token',
213 },
214 body: JSON.stringify({ preferences: [] }),
215 });
216 const response = await pds.fetch(request);
217 expect(response.status).toBe(401);
218 const body = await response.json();
219 expect(body.error).toBe('AuthenticationRequired');
220 });
221
222 it('should reject /init with 401', async () => {
223 const request = new Request('https://pds.example.com/init', {
224 method: 'POST',
225 headers: { 'Content-Type': 'application/json' },
226 body: JSON.stringify({ did: 'did:plc:test', privateKey: 'abc123' }),
227 });
228 const response = await pds.fetch(request);
229 expect(response.status).toBe(401);
230 const body = await response.json();
231 expect(body.error).toBe('AuthenticationRequired');
232 });
233
234 it('should reject OAuth PAR with 401', async () => {
235 const request = new Request('https://pds.example.com/oauth/par', {
236 method: 'POST',
237 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
238 body: 'client_id=test&redirect_uri=http://localhost',
239 });
240 const response = await pds.fetch(request);
241 expect(response.status).toBe(401);
242 const body = await response.json();
243 expect(body.error).toBe('AuthenticationRequired');
244 });
245
246 it('should reject OAuth token with 401', async () => {
247 const request = new Request('https://pds.example.com/oauth/token', {
248 method: 'POST',
249 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
250 body: 'grant_type=authorization_code&code=test',
251 });
252 const response = await pds.fetch(request);
253 expect(response.status).toBe(401);
254 const body = await response.json();
255 expect(body.error).toBe('AuthenticationRequired');
256 });
257
258 it('should reject OAuth revoke with 401', async () => {
259 const request = new Request('https://pds.example.com/oauth/revoke', {
260 method: 'POST',
261 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
262 body: 'token=test-token',
263 });
264 const response = await pds.fetch(request);
265 expect(response.status).toBe(401);
266 const body = await response.json();
267 expect(body.error).toBe('AuthenticationRequired');
268 });
269});
270
271describe('Normal mode (readOnly=false)', () => {
272 /** @type {PersonalDataServer} */
273 let pds;
274
275 beforeEach(() => {
276 pds = new PersonalDataServer({
277 actorStorage: createMockStorage(),
278 sharedStorage: createMockSharedStorage(),
279 blobs: createMockBlobs(),
280 jwtSecret: 'test-secret',
281 readOnly: false,
282 });
283 });
284
285 it('should allow createSession (fail for other reasons, not read-only)', async () => {
286 const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', {
287 method: 'POST',
288 headers: { 'Content-Type': 'application/json' },
289 body: JSON.stringify({ identifier: 'test', password: 'pass' }),
290 });
291 const response = await pds.fetch(request);
292 // Won't get read-only error, will get another error because of missing JWT setup
293 const body = await response.json();
294 expect(body.message).not.toBe('This PDS is read-only');
295 });
296});
297
298describe('Default mode (readOnly not specified)', () => {
299 it('should default to non-read-only mode', async () => {
300 const pds = new PersonalDataServer({
301 actorStorage: createMockStorage(),
302 sharedStorage: createMockSharedStorage(),
303 blobs: createMockBlobs(),
304 jwtSecret: 'test-secret',
305 // readOnly not specified
306 });
307
308 const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', {
309 method: 'POST',
310 headers: { 'Content-Type': 'application/json' },
311 body: JSON.stringify({ identifier: 'test', password: 'pass' }),
312 });
313 const response = await pds.fetch(request);
314 const body = await response.json();
315 expect(body.message).not.toBe('This PDS is read-only');
316 });
317});