// Read-only mode tests import { describe, it, expect, beforeEach } from 'vitest'; import { PersonalDataServer } from '../packages/core/src/pds.js'; // Create minimal mock storage function createMockStorage() { return { async getRecord() { return null; }, async putRecord() {}, async listRecords() { return { records: [], cursor: null }; }, async listAllRecords() { return []; }, async deleteRecord() {}, async getBlock() { return null; }, async putBlock() {}, async getLatestCommit() { return { seq: 1, cid: 'bafytest', rev: '3abc' }; }, async putCommit() {}, async getEvents() { return { events: [], cursor: 0 }; }, async putEvent() {}, async getBlob() { return null; }, async putBlob() {}, async deleteBlob() {}, async listBlobs() { return { cids: [], cursor: null }; }, async getOrphanedBlobs() { return []; }, async linkBlobToRecord() {}, async unlinkBlobsFromRecord() {}, async getDid() { return 'did:plc:test123'; }, async setDid() {}, async getHandle() { return 'test.handle'; }, async setHandle() {}, async getPrivateKey() { return null; }, async setPrivateKey() {}, async getPreferences() { return []; }, async setPreferences() {}, }; } function createMockSharedStorage() { return { async getActor() { return null; }, async resolveHandle() { return null; }, async putActor() {}, async deleteActor() {}, async getOAuthRequest() { return null; }, async putOAuthRequest() {}, async deleteOAuthRequest() {}, async getOAuthToken() { return null; }, async putOAuthToken() {}, async deleteOAuthToken() {}, async listOAuthTokensByDid() { return []; }, async checkAndStoreDpopJti() { return true; }, async cleanupExpiredDpopJtis() {}, }; } function createMockBlobs() { return { async put() {}, async get() { return null; }, async delete() {}, async has() { return false; }, async list() { return { cids: [], cursor: null }; }, }; } describe('Read-only mode', () => { /** @type {PersonalDataServer} */ let pds; beforeEach(() => { pds = new PersonalDataServer({ actorStorage: createMockStorage(), sharedStorage: createMockSharedStorage(), blobs: createMockBlobs(), jwtSecret: 'test-secret', readOnly: true, }); }); it('should allow GET requests (describeServer)', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.server.describeServer'); const response = await pds.fetch(request); expect(response.status).toBe(200); }); it('should allow GET requests (listRepos)', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.sync.listRepos'); const response = await pds.fetch(request); expect(response.status).toBe(200); }); it('should reject createSession with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: 'test', password: 'pass' }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); expect(body.message).toBe('This PDS is read-only'); }); it('should reject refreshSession with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.server.refreshSession', { method: 'POST', headers: { 'Authorization': 'Bearer test-token' }, }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject createRecord with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.createRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test-token', }, body: JSON.stringify({ repo: 'did:plc:test', collection: 'app.bsky.feed.post', record: { text: 'test' }, }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); expect(body.message).toBe('This PDS is read-only'); }); it('should reject putRecord with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.putRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test-token', }, body: JSON.stringify({ repo: 'did:plc:test', collection: 'app.bsky.feed.post', rkey: '3abc', record: { text: 'test' }, }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject deleteRecord with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.deleteRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test-token', }, body: JSON.stringify({ repo: 'did:plc:test', collection: 'app.bsky.feed.post', rkey: '3abc', }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject applyWrites with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.applyWrites', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test-token', }, body: JSON.stringify({ repo: 'did:plc:test', writes: [], }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject uploadBlob with 401', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.repo.uploadBlob', { method: 'POST', headers: { 'Content-Type': 'image/png', 'Authorization': 'Bearer test-token', }, body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject putPreferences with 401', async () => { const request = new Request('https://pds.example.com/xrpc/app.bsky.actor.putPreferences', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test-token', }, body: JSON.stringify({ preferences: [] }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject /init with 401', async () => { const request = new Request('https://pds.example.com/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: 'did:plc:test', privateKey: 'abc123' }), }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject OAuth PAR with 401', async () => { const request = new Request('https://pds.example.com/oauth/par', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'client_id=test&redirect_uri=http://localhost', }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject OAuth token with 401', async () => { const request = new Request('https://pds.example.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=authorization_code&code=test', }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); it('should reject OAuth revoke with 401', async () => { const request = new Request('https://pds.example.com/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'token=test-token', }); const response = await pds.fetch(request); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe('AuthenticationRequired'); }); }); describe('Normal mode (readOnly=false)', () => { /** @type {PersonalDataServer} */ let pds; beforeEach(() => { pds = new PersonalDataServer({ actorStorage: createMockStorage(), sharedStorage: createMockSharedStorage(), blobs: createMockBlobs(), jwtSecret: 'test-secret', readOnly: false, }); }); it('should allow createSession (fail for other reasons, not read-only)', async () => { const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: 'test', password: 'pass' }), }); const response = await pds.fetch(request); // Won't get read-only error, will get another error because of missing JWT setup const body = await response.json(); expect(body.message).not.toBe('This PDS is read-only'); }); }); describe('Default mode (readOnly not specified)', () => { it('should default to non-read-only mode', async () => { const pds = new PersonalDataServer({ actorStorage: createMockStorage(), sharedStorage: createMockSharedStorage(), blobs: createMockBlobs(), jwtSecret: 'test-secret', // readOnly not specified }); const request = new Request('https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: 'test', password: 'pass' }), }); const response = await pds.fetch(request); const body = await response.json(); expect(body.message).not.toBe('This PDS is read-only'); }); });