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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
203 lines · 6.1 KB Raw History
// applyWrites discriminates its writes two ways. The proposal made them a
// closed union tagged by `$type` after clients had shipped against an `action`
// string, so both have to be read.
import { sign } from '@pdsjs/core/crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import {
  createMemoryBlobContext,
  createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';

const AUTHORITY = 'did:plc:aaaa';
const DID = 'did:plc:writer';
const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`;
const COL = 'com.example.post';
const AUTH = {
  did: DID,
  scope: `space:com.example.forum?authority=${AUTHORITY}&collection=${COL}`,
};
const PATH = '/xrpc/com.atproto.space.applyWrites';

/** @type {any} */ let routes;
/** @type {any} */ let store;

beforeEach(async () => {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  store = createMemorySpaceStorage();
  routes = createSpaceRoutes({
    spaceStorage: store,
    ...createMemoryBlobContext(),
    getDid: async () => DID,
    getKemKey: async () => null,
    getSigner: async () => ({
      /** @param {Uint8Array} bytes */
      sign: (bytes) => sign(keys.privateKey, bytes),
    }),
    resolveDid: async () => null,
    verifier: { verify: async () => true },
  });
});

/** @param {any[]} writes */
function applyWrites(writes) {
  const url = new URL(`https://pds.example${PATH}`);
  return routes[PATH].handler(
    new Request(url, {
      method: 'POST',
      body: JSON.stringify({ space: SPACE, repo: DID, writes }),
    }),
    url,
    AUTH,
  );
}

describe('applyWrites write shapes', () => {
  it('reads a write tagged by $type', async () => {
    const res = await applyWrites([
      {
        $type: 'com.atproto.space.applyWrites#create',
        collection: COL,
        rkey: 'a',
        value: { text: 'hi' },
      },
    ]);
    expect(res.status).toBe(200);
    expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();
  });

  it('reads a write named by an action string', async () => {
    const res = await applyWrites([
      { action: 'create', collection: COL, rkey: 'a', value: { text: 'hi' } },
    ]);
    expect(res.status).toBe(200);
    const body = await res.json();
    // The result is a union either way; only the input spelling differs.
    expect(body.results[0].$type).toBe(
      'com.atproto.space.applyWrites#createResult',
    );
    expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();
  });

  it('mints a key for a create that leaves rkey empty', async () => {
    const res = await applyWrites([
      { action: 'create', collection: COL, rkey: '', value: { text: 'hi' } },
    ]);
    const body = await res.json();
    expect(body.results[0].uri.split('/').pop()).toHaveLength(13);
  });

  it('updates and deletes by action string', async () => {
    await applyWrites([
      { action: 'create', collection: COL, rkey: 'a', value: { v: 1 } },
    ]);
    await applyWrites([
      { action: 'update', collection: COL, rkey: 'a', value: { v: 2 } },
    ]);
    expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy();

    const res = await applyWrites([
      { action: 'delete', collection: COL, rkey: 'a' },
    ]);
    expect(res.status).toBe(200);
    expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeNull();
  });

  it('names what it could not place when neither spelling is present', async () => {
    const res = await applyWrites([{ collection: COL, rkey: 'a', value: {} }]);
    expect(res.status).toBe(400);
    expect((await res.json()).message).toContain('(none)');
  });

  it('refuses an action it does not implement', async () => {
    const res = await applyWrites([
      { action: 'upsert', collection: COL, rkey: 'a', value: {} },
    ]);
    expect(res.status).toBe(400);
    expect((await res.json()).message).toContain('upsert');
  });
});

// putRecord carries an optional swapRecord, a pds.js extension: a CID names
// the version the record must be at, null requires that it does not exist.
describe('putRecord swapRecord', () => {
  const PUT_PATH = '/xrpc/com.atproto.space.putRecord';

  /** @param {Record<string, unknown>} body */
  function putRecord(body) {
    const url = new URL(`https://pds.example${PUT_PATH}`);
    return routes[PUT_PATH].handler(
      new Request(url, {
        method: 'POST',
        body: JSON.stringify({ space: SPACE, repo: DID, ...body }),
      }),
      url,
      AUTH,
    );
  }

  it('accepts a matching CID and rejects a stale one', async () => {
    const first = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 1 },
      swapRecord: null,
    });
    expect(first.status).toBe(200);
    const { cid } = await first.json();

    const second = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 2 },
      swapRecord: cid,
    });
    expect(second.status).toBe(200);

    const stale = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 3 },
      swapRecord: cid,
    });
    expect(stale.status).toBe(400);
    expect((await stale.json()).error).toBe('InvalidSwap');
  });

  it('rejects swapRecord null when the record exists', async () => {
    await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } });
    const res = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 2 },
      swapRecord: null,
    });
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidSwap');
  });

  it('rejects a swapRecord that is not a CID string or null', async () => {
    const res = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 1 },
      swapRecord: 7,
    });
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('InvalidRequest');
  });

  it('writes without a check when swapRecord is absent', async () => {
    await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } });
    const res = await putRecord({
      collection: COL,
      rkey: 'a',
      record: { v: 2 },
    });
    expect(res.status).toBe(200);
  });
});