~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
406 lines · 11.7 KB Raw History
// Read-only mode tests
import { beforeEach, describe, expect, it } 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 getLatestSeq() {
      return 1;
    },
    async getAccountStatus() {
      return 'active';
    },
    async setAccountStatus() {},
    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 getKemKey() {
      return null;
    },
    async setKemKey() {},
    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: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (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: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (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: /** @type {any} */ (createMockStorage()),
      sharedStorage: /** @type {any} */ (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');
  });
});