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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
581 lines · 16.8 KB Raw History
// Server root endpoint tests - health, root, atproto-did, status, /init, and
// the two endpoints that announce repo state
import { describe, expect, it } from 'vitest';
import { decodeStoredKey } from '../packages/core/src/crypto.js';
import { createServerMetaHandlers } from '../packages/core/src/handlers/server-meta.js';

const DID = 'did:plc:servermetaaccount2345672';
const KEY_HEX = 'ab'.repeat(32);

/**
 * @param {{
 *   did?: string|null, handle?: string|null, readOnly?: boolean,
 *   auth?: {did: string, scope: string}|null, k256?: boolean,
 *   latestCommit?: {cid: string, rev: string}|null,
 *   rebuildMst?: () => Promise<{rev: string, cid: string, changed: boolean}|null>,
 * }} [options]
 */
function createMeta({
  did = null,
  handle = null,
  readOnly = false,
  auth = null,
  k256 = false,
  latestCommit = null,
  rebuildMst = async () => null,
} = {}) {
  const stored = {
    did,
    handle,
    /** @type {Uint8Array|null} */ privateKey: null,
    /** @type {Uint8Array|null} */ kemKey: null,
  };
  /** @type {Array<{type: string, body: Object}>} */
  const events = [];
  /** @type {Object[]} */
  const syncEvents = [];
  /** @type {Array<{did: string, handle: string|null}>} */
  const actors = [];
  let cacheCleared = 0;

  const handlers = createServerMetaHandlers({
    actorStorage: /** @type {any} */ ({
      async getDid() {
        return stored.did;
      },
      async setDid(/** @type {string} */ d) {
        stored.did = d;
      },
      async getHandle() {
        return stored.handle;
      },
      async setHandle(/** @type {string} */ h) {
        stored.handle = h;
      },
      async setPrivateKey(/** @type {Uint8Array} */ k) {
        stored.privateKey = k;
      },
      async getKemKey() {
        return stored.kemKey ?? null;
      },
      async setKemKey(/** @type {Uint8Array} */ k) {
        stored.kemKey = k;
      },
      async getLatestCommit() {
        return latestCommit;
      },
      async getAccountStatus() {
        return 'active';
      },
    }),
    sharedStorage: /** @type {any} */ ({
      async putActor(/** @type {string} */ d, /** @type {string|null} */ h) {
        actors.push({ did: d, handle: h });
      },
    }),
    readOnly,
    version: '9.9.9',
    k256Signer: k256 ? /** @type {any} */ ({}) : undefined,
    getDid: async () => stored.did,
    authenticate: async () => auth,
    emitEvent: async (type, body) => {
      events.push({ type, body });
    },
    emitSyncEvent: async (commit) => {
      syncEvents.push(commit);
    },
    rebuildMst,
    readOnlyError: () =>
      Response.json(
        { error: 'AuthenticationRequired', message: 'This PDS is read-only' },
        { status: 401 },
      ),
    setCachedIdentity: () => {
      cacheCleared += 1;
    },
  });
  return {
    handlers,
    stored,
    events,
    syncEvents,
    actors,
    cleared: () => cacheCleared,
  };
}

/**
 * @param {ReturnType<typeof createServerMetaHandlers>} handlers
 * @param {string} path
 * @param {Object} [body]
 * @param {{did: string, scope: string}|null} [auth] - What dispatch resolved, for the routes it authenticates
 */
async function call(handlers, path, body, auth = null) {
  const route = handlers.routes[path];
  const url = new URL(`https://pds.example.com${path}`);
  /** @type {RequestInit} */
  const init = { method: body ? 'POST' : 'GET' };
  if (body) init.body = JSON.stringify(body);
  const handler =
    /** @type {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise<Response>} */ (
      route.handler
    );
  return handler(new Request(url, init), url, auth);
}

describe('root and health', () => {
  it('serves the banner as plain text', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/');

    expect(response.headers.get('Content-Type')).toBe(
      'text/plain; charset=utf-8',
    );
    expect(await response.text()).toContain('ATProto PDS');
  });

  it('answers health with the version', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/xrpc/_health');

    expect(response.status).toBe(200);
    expect((await response.json()).version).toBe('9.9.9');
  });
});

describe('atproto-did', () => {
  it('serves the DID once one is adopted', async () => {
    const { handlers } = createMeta({ did: DID });
    const response = await call(handlers, '/.well-known/atproto-did');

    expect(response.status).toBe(200);
    expect((await response.text()).trim()).toBe(DID);
  });

  it('is a 404 before one is', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/.well-known/atproto-did');

    expect(response.status).toBe(404);
  });
});

describe('status', () => {
  it('reports whether an identity is adopted', async () => {
    const { handlers } = createMeta({ did: DID, handle: 'alice.example.com' });
    const body = await (await call(handlers, '/status')).json();

    expect(body.initialized).toBe(true);
    expect(body.did).toBe(DID);
  });

  it('reports an empty server as uninitialized', async () => {
    const { handlers } = createMeta();
    const body = await (await call(handlers, '/status')).json();

    expect(body.initialized).toBe(false);
    expect(body.did).toBe(null);
  });
});

describe('init', () => {
  it('stores the KEM key the genesis operation published', async () => {
    const { handlers, stored } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      kemKey: 'cd'.repeat(32),
    });

    expect(response.status).toBe(200);
    expect(stored.kemKey).toEqual(new Uint8Array(32).fill(0xcd));
  });

  it('makes a KEM key when the caller brings none', async () => {
    const { handlers, stored } = createMeta();
    await call(handlers, '/init', { did: DID, privateKey: KEY_HEX });
    const made = stored.kemKey;

    expect(made).toHaveLength(32);
    // Repointing the server keeps the key it has.
    await call(
      handlers,
      '/init',
      { did: DID, privateKey: KEY_HEX },
      {
        did: DID,
        scope: 'com.atproto.access',
      },
    );
    expect(stored.kemKey).toBe(made);
  });

  it('refuses a KEM key that is not 32 bytes of hex', async () => {
    const { handlers, stored } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      kemKey: 'cd'.repeat(31),
    });

    expect(response.status).toBe(400);
    expect(stored.did).toBeNull();
  });

  it('adopts an identity on an empty server without auth', async () => {
    const { handlers, stored, events, actors, cleared } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      handle: 'alice.example.com',
    });

    expect(response.status).toBe(200);
    expect(stored.did).toBe(DID);
    expect(stored.handle).toBe('alice.example.com');
    expect(
      decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve,
    ).toBe('p256');
    expect(actors).toEqual([{ did: DID, handle: 'alice.example.com' }]);
    expect(cleared()).toBe(1);
    // A fresh bootstrap announces both the identity and the account.
    expect(events.map((e) => e.type)).toEqual(['identity', 'account']);
  });

  it('announces a sync event when a repo already exists', async () => {
    const { handlers, syncEvents } = createMeta({
      latestCommit: { cid: 'bafycommit', rev: 'rev1' },
    });
    await call(handlers, '/init', { did: DID, privateKey: KEY_HEX });

    expect(syncEvents).toEqual([{ cid: 'bafycommit', rev: 'rev1' }]);
  });

  // Once a DID is adopted this endpoint can repoint the account at another
  // identity, so it stops being open.
  it('needs the account credentials once a DID is adopted', async () => {
    const { handlers, stored } = createMeta({ did: DID });
    const response = await call(handlers, '/init', {
      did: 'did:plc:attackeraccount234567234',
      privateKey: KEY_HEX,
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toMatch(/already initialized/);
    expect(stored.did).toBe(DID);
  });

  it('lets the account itself repoint the server', async () => {
    const other = 'did:plc:secondaccount23456723456';
    const { handlers, stored } = createMeta({
      did: DID,
      auth: { did: DID, scope: 'com.atproto.access' },
    });
    const response = await call(handlers, '/init', {
      did: other,
      privateKey: KEY_HEX,
    });

    expect(response.status).toBe(200);
    expect(stored.did).toBe(other);
  });

  it('refuses a read-only server', async () => {
    const { handlers, stored } = createMeta({ readOnly: true });
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
    });

    expect(response.status).toBe(401);
    expect(stored.did).toBe(null);
  });

  it('needs a did and a privateKey', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', { did: DID });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(
      /missing did or privateKey/,
    );
  });

  it('refuses a did it cannot parse', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: 'not-a-did',
      privateKey: KEY_HEX,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/invalid DID format/);
  });

  it('refuses a privateKey that is not hex', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: 'zz'.repeat(32),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/valid hex/);
  });

  it('takes a privateKey as a byte array', async () => {
    const { handlers, stored } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: Array.from({ length: 32 }, () => 7),
    });

    expect(response.status).toBe(200);
    expect(stored.privateKey).toBeTruthy();
  });

  it('refuses a privateKey of the wrong length', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: 'ab'.repeat(16),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/32 bytes/);
  });

  it('refuses a privateKey that is neither hex nor bytes', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: { nope: true },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/hex string or byte array/);
  });

  it('refuses a curve it does not know', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      curve: 'ed25519',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/p256 or secp256k1/);
  });

  // secp256k1 needs the injected signer, so a deployment without one must say so
  // rather than storing a key it cannot sign with.
  it('refuses secp256k1 without an injected signer', async () => {
    const { handlers, stored } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      curve: 'secp256k1',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(
      /cannot sign with secp256k1/,
    );
    expect(stored.privateKey).toBe(null);
  });

  it('stores a secp256k1 key when a signer is present', async () => {
    const { handlers, stored } = createMeta({ k256: true });
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      curve: 'secp256k1',
    });

    expect(response.status).toBe(200);
    expect(
      decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve,
    ).toBe('secp256k1');
  });

  it('refuses a handle it cannot parse', async () => {
    const { handlers } = createMeta();
    const response = await call(handlers, '/init', {
      did: DID,
      privateKey: KEY_HEX,
      handle: 'not a handle',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/invalid handle format/);
  });
});

const ACCESS = { did: DID, scope: 'com.atproto.access' };
const OTHER_DID = 'did:plc:secondaccount23456723456';
const COMMIT = { cid: 'bafycommitcid', rev: '3kzz' };
const SYNC_PATH = '/xrpc/com.atproto.sync.requestSync';
const REBUILD_PATH = '/xrpc/dev.pdsjs.repo.rebuildMst';

describe('requestSync', () => {
  it('announces the current head', async () => {
    const { handlers, syncEvents } = createMeta({
      did: DID,
      latestCommit: COMMIT,
    });
    const response = await call(handlers, SYNC_PATH, {}, ACCESS);

    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({
      rev: COMMIT.rev,
      cid: COMMIT.cid,
    });
    expect(syncEvents).toEqual([COMMIT]);
  });

  it('refuses a read-only server', async () => {
    const { handlers, syncEvents } = createMeta({
      did: DID,
      latestCommit: COMMIT,
      readOnly: true,
    });
    const response = await call(handlers, SYNC_PATH, {}, ACCESS);

    expect(response.status).toBe(401);
    expect(syncEvents).toEqual([]);
  });

  it('refuses a server with no DID', async () => {
    const { handlers, syncEvents } = createMeta({ latestCommit: COMMIT });
    const response = await call(handlers, SYNC_PATH, {}, ACCESS);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/not initialized/);
    expect(syncEvents).toEqual([]);
  });

  it('refuses an unauthenticated caller', async () => {
    const { handlers, syncEvents } = createMeta({
      did: DID,
      latestCommit: COMMIT,
    });
    const response = await call(handlers, SYNC_PATH, {});

    expect(response.status).toBe(403);
    expect(syncEvents).toEqual([]);
  });

  it('refuses another account', async () => {
    const { handlers, syncEvents } = createMeta({
      did: DID,
      latestCommit: COMMIT,
    });
    const response = await call(
      handlers,
      SYNC_PATH,
      {},
      {
        did: OTHER_DID,
        scope: 'com.atproto.access',
      },
    );

    expect(response.status).toBe(403);
    expect(syncEvents).toEqual([]);
  });

  it('refuses a repo with no commits', async () => {
    const { handlers, syncEvents } = createMeta({ did: DID });
    const response = await call(handlers, SYNC_PATH, {}, ACCESS);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/no commits/);
    expect(syncEvents).toEqual([]);
  });
});

describe('rebuildMst', () => {
  const rebuilt = { rev: '3kzz', cid: 'bafyrebuiltroot', changed: true };

  it('returns the rebuilt head', async () => {
    let calls = 0;
    const { handlers } = createMeta({
      did: DID,
      rebuildMst: async () => {
        calls += 1;
        return rebuilt;
      },
    });
    const response = await call(handlers, REBUILD_PATH, {}, ACCESS);

    expect(response.status).toBe(200);
    expect(await response.json()).toEqual(rebuilt);
    expect(calls).toBe(1);
  });

  it('refuses a read-only server', async () => {
    const { handlers } = createMeta({
      did: DID,
      readOnly: true,
      rebuildMst: async () => rebuilt,
    });
    const response = await call(handlers, REBUILD_PATH, {}, ACCESS);

    expect(response.status).toBe(401);
  });

  it('refuses a server with no DID', async () => {
    const { handlers } = createMeta({ rebuildMst: async () => rebuilt });
    const response = await call(handlers, REBUILD_PATH, {}, ACCESS);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/not initialized/);
  });

  it('refuses an unauthenticated caller', async () => {
    let calls = 0;
    const { handlers } = createMeta({
      did: DID,
      rebuildMst: async () => {
        calls += 1;
        return rebuilt;
      },
    });
    const response = await call(handlers, REBUILD_PATH, {});

    expect(response.status).toBe(403);
    expect(calls).toBe(0);
  });

  it('refuses another account', async () => {
    let calls = 0;
    const { handlers } = createMeta({
      did: DID,
      rebuildMst: async () => {
        calls += 1;
        return rebuilt;
      },
    });
    const response = await call(
      handlers,
      REBUILD_PATH,
      {},
      {
        did: OTHER_DID,
        scope: 'com.atproto.access',
      },
    );

    expect(response.status).toBe(403);
    expect(calls).toBe(0);
  });

  it('refuses a repo with no commits', async () => {
    const { handlers } = createMeta({ did: DID });
    const response = await call(handlers, REBUILD_PATH, {}, ACCESS);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/no commits/);
  });
});