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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1126 lines · 31.9 KB Raw History
// Repo handler tests - record writes, scope checks, validation, applyWrites
import { describe, expect, it } from 'vitest';
import { createRepoHandlers } from '../packages/core/src/handlers/xrpc-repo.js';
import { cborDecode } from '../packages/core/src/repo.js';

const DID = 'did:plc:repotestaccount23456';
const COLLECTION = 'app.bsky.feed.post';
const FULL = { did: DID, scope: 'com.atproto.access' };

/**
 * @param {{
 *   did?: string|null, readOnly?: boolean, inactive?: Response|null,
 *   resolver?: {assertValid: (nsid: string, record: Object) => Promise<string>}|null,
 *   proxied?: Response|null,
 * }} [options]
 */
function createRepo({
  did = DID,
  readOnly = false,
  inactive = null,
  resolver = null,
  proxied = null,
} = {}) {
  /** @type {Map<string, {cid: string, value: Uint8Array}>} */
  const records = new Map();
  /** @type {Map<string, Uint8Array>} */
  const blocks = new Map();
  /** @type {Array<{blobCid: string, uri: string}>} */
  const blobLinks = [];
  /** @type {string[]} */
  const unlinked = [];
  /** @type {Array<Array<{action: string, uri: string, cid?: string}>>} */
  const commits = [];
  /** @type {Array<{did: string, olderThan: number, only?: string[]}>} */
  const reaped = [];
  let seq = 0;

  const actorStorage = {
    records,
    blocks,
    blobLinks,
    unlinked,
    async getHandle() {
      return 'alice.example.com';
    },
    async listAllRecords() {
      return [...records.keys()].map((uri) => ({
        key: uri.split(`${DID}/`)[1],
        uri,
      }));
    },
    async putBlock(/** @type {string} */ cid, /** @type {Uint8Array} */ data) {
      blocks.set(cid, data);
    },
    async putRecord(
      /** @type {string} */ uri,
      /** @type {string} */ cid,
      /** @type {string} */ _collection,
      /** @type {string} */ _rkey,
      /** @type {Uint8Array} */ value,
    ) {
      records.set(uri, { cid, value });
    },
    async getRecord(/** @type {string} */ uri) {
      return records.get(uri) || null;
    },
    async deleteRecord(/** @type {string} */ uri) {
      records.delete(uri);
    },
    async listRecords(
      /** @type {string} */ collection,
      /** @type {string|null} */ _cursor,
      /** @type {number} */ limit,
      /** @type {boolean|undefined} */ reverse,
    ) {
      const rows = [...records.entries()]
        .filter(([uri]) => uri.includes(`/${collection}/`))
        .sort(([a], [b]) => (a < b ? -1 : 1));
      if (reverse) rows.reverse();
      return {
        records: rows
          .slice(0, limit)
          .map(([uri, r]) => ({ uri, cid: r.cid, value: r.value })),
        cursor: null,
      };
    },
    async linkBlobToRecord(
      /** @type {string} */ blobCid,
      /** @type {string} */ uri,
    ) {
      blobLinks.push({ blobCid, uri });
    },
    async unlinkBlobsFromRecord(/** @type {string} */ uri) {
      unlinked.push(uri);
    },
    async getLatestCommit() {
      return seq === 0
        ? null
        : { seq, cid: `bafycommit${seq}`, rev: `rev${seq}` };
    },
  };

  const repo = createRepoHandlers({
    actorStorage: /** @type {any} */ (actorStorage),
    lexiconResolver: /** @type {any} */ (resolver),
    readOnly,
    getDid: async () => did,
    readOnlyError: () =>
      Response.json(
        { error: 'AuthenticationRequired', message: 'This PDS is read-only' },
        { status: 401 },
      ),
    withRepoLock: (fn) => fn(),
    createCommit: async (_did, ops) => {
      commits.push(ops);
      seq += 1;
      return {};
    },
    checkAccountActive: async () => inactive,
    readJsonBounded: async (request) => request.json(),
    handleProxyHeader: async () => proxied,
    reapOrphanedBlobs: async (d, olderThan, only) => {
      reaped.push({ did: d, olderThan, only });
    },
  });
  return { repo, actorStorage, records, commits, blobLinks, unlinked, reaped };
}

/**
 * @param {ReturnType<typeof createRepoHandlers>} repo
 * @param {string} path
 * @param {{body?: Object, auth?: {did: string, scope: string}|null}} [options]
 */
async function call(repo, path, { body, auth = null } = {}) {
  const route = repo.routes[path.split('?')[0]];
  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 request = new Request(url, init);
  const handler =
    /** @type {(request: Request, url: URL, auth: unknown) => Promise<Response>} */ (
      route.handler
    );
  return handler(request, url, auth);
}

const DESCRIBE = '/xrpc/com.atproto.repo.describeRepo';
const CREATE = '/xrpc/com.atproto.repo.createRecord';
const GET = '/xrpc/com.atproto.repo.getRecord';
const LIST = '/xrpc/com.atproto.repo.listRecords';
const PUT = '/xrpc/com.atproto.repo.putRecord';
const APPLY = '/xrpc/com.atproto.repo.applyWrites';
const DELETE = '/xrpc/com.atproto.repo.deleteRecord';

describe('describeRepo', () => {
  it('lists the collections the repo holds', async () => {
    const { repo } = createRepo();
    await call(repo, CREATE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, record: { text: 'a' } },
    });

    const body = await (await call(repo, DESCRIBE)).json();

    expect(body.did).toBe(DID);
    expect(body.handle).toBe('alice.example.com');
    expect(body.handleIsCorrect).toBe(true);
    expect(body.collections).toEqual([COLLECTION]);
  });

  it('is a RepoNotFound for another repo', async () => {
    const { repo } = createRepo();
    const response = await call(repo, `${DESCRIBE}?repo=did:plc:someoneelse`);

    expect(response.status).toBe(404);
    expect((await response.json()).error).toBe('RepoNotFound');
  });
});

describe('createRecord', () => {
  it('stores the record, links its blobs, and commits once', async () => {
    const { repo, records, commits, blobLinks } = createRepo();
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        record: {
          text: 'hello',
          image: { $type: 'blob', ref: { $link: 'bafkblob1' } },
        },
      },
    });
    const body = await response.json();

    expect(response.status).toBe(200);
    expect(body.uri).toMatch(new RegExp(`^at://${DID}/${COLLECTION}/`));
    expect(records.get(body.uri)?.cid).toBe(body.cid);
    expect(commits).toHaveLength(1);
    expect(commits[0][0].action).toBe('create');
    expect(blobLinks).toEqual([{ blobCid: 'bafkblob1', uri: body.uri }]);
    expect(body.commit.rev).toBe('rev1');
  });

  it('sets $type from the collection', async () => {
    const { repo, records } = createRepo();
    const body = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: { repo: DID, collection: COLLECTION, record: { text: 'a' } },
      })
    ).json();

    const stored = cborDecode(
      /** @type {Uint8Array} */ (records.get(body.uri)?.value),
    );
    expect(stored.$type).toBe(COLLECTION);
  });

  it('takes a caller-supplied rkey', async () => {
    const { repo } = createRepo();
    const body = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey: 'chosen',
          record: { text: 'a' },
        },
      })
    ).json();

    expect(body.uri).toBe(`at://${DID}/${COLLECTION}/chosen`);
  });

  it('refuses a key the repo already holds, and keeps the record', async () => {
    const { repo, records, commits } = createRepo();
    const body = { repo: DID, collection: COLLECTION, rkey: 'taken' };
    const first = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: { ...body, record: { text: 'first' } },
      })
    ).json();

    const response = await call(repo, CREATE, {
      auth: FULL,
      body: { ...body, record: { text: 'second' } },
    });

    expect(response.status).toBe(400);
    expect(await response.json()).toEqual({
      error: 'InvalidRequest',
      message: `Record already exists: ${first.uri}`,
    });
    expect(records.get(first.uri)?.cid).toBe(first.cid);
    expect(commits).toHaveLength(1);
  });

  it('needs a session', async () => {
    const { repo } = createRepo();
    const response = await call(repo, CREATE, {
      body: { repo: DID, collection: COLLECTION, record: {} },
    });

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

  it('refuses a read-only server', async () => {
    const { repo } = createRepo({ readOnly: true });
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, record: {} },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toMatch(/read-only/);
  });

  it('passes on the reason an account is inactive', async () => {
    const { repo } = createRepo({
      inactive: Response.json({ error: 'AccountDeactivated' }, { status: 400 }),
    });
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, record: {} },
    });

    expect((await response.json()).error).toBe('AccountDeactivated');
  });

  it('refuses a repo that is not this one', async () => {
    const { repo } = createRepo();
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: 'did:plc:someoneelse',
        collection: COLLECTION,
        record: {},
      },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toMatch(/Not authorized/);
  });

  it('refuses a collection the scope does not cover', async () => {
    const { repo, records } = createRepo();
    const response = await call(repo, CREATE, {
      auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
      body: { repo: DID, collection: COLLECTION, record: {} },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toContain(COLLECTION);
    expect(records.size).toBe(0);
  });

  it('reports a schema failure as InvalidRecord', async () => {
    const { repo, records } = createRepo({
      resolver: {
        async assertValid() {
          throw new Error('text must be a string');
        },
      },
    });
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, record: { text: 1 } },
    });

    expect(response.status).toBe(400);
    const body = await response.json();
    expect(body.error).toBe('InvalidRecord');
    expect(body.message).toMatch(/text must be a string/);
    expect(records.size).toBe(0);
  });

  it('skips validation when the caller asks it to', async () => {
    let called = false;
    const { repo } = createRepo({
      resolver: {
        async assertValid() {
          called = true;
          return 'valid';
        },
      },
    });
    const response = await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        record: { text: 1 },
        validate: false,
      },
    });

    expect(response.status).toBe(200);
    expect(called).toBe(false);
  });
});

describe('getRecord', () => {
  it('answers with the stored value', async () => {
    const { repo } = createRepo();
    const created = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey: 'one',
          record: { text: 'hi' },
        },
      })
    ).json();

    const body = await (
      await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`)
    ).json();

    expect(body.uri).toBe(created.uri);
    expect(body.cid).toBe(created.cid);
    expect(body.value.text).toBe('hi');
  });

  it('is a RecordNotFound for an rkey that is not there', async () => {
    const { repo } = createRepo();
    const response = await call(
      repo,
      `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=absent`,
    );

    expect(response.status).toBe(404);
    expect((await response.json()).error).toBe('RecordNotFound');
  });
});

describe('listRecords', () => {
  it('lists a collection', async () => {
    const { repo } = createRepo();
    for (const rkey of ['a', 'b']) {
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey,
          record: { text: rkey },
        },
      });
    }

    const body = await (
      await call(repo, `${LIST}?repo=${DID}&collection=${COLLECTION}`)
    ).json();

    expect(body.records).toHaveLength(2);
    expect(body.records[0].value.text).toBeTruthy();
  });

  it('lists newest rkey first, oldest first with reverse', async () => {
    const { repo } = createRepo();
    for (const rkey of ['a', 'b']) {
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey,
          record: { text: rkey },
        },
      });
    }
    const texts = async (/** @type {string} */ params) => {
      const body = await (
        await call(
          repo,
          `${LIST}?repo=${DID}&collection=${COLLECTION}${params}`,
        )
      ).json();
      return body.records.map(
        (/** @type {{value: {text: string}}} */ r) => r.value.text,
      );
    };

    expect(await texts('')).toEqual(['b', 'a']);
    expect(await texts('&reverse=true')).toEqual(['a', 'b']);
  });
});

describe('putRecord', () => {
  it('replaces the value and commits an update', async () => {
    const { repo, commits } = createRepo();
    await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'one',
        record: { text: 'first' },
      },
    });

    const response = await call(repo, PUT, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'one',
        record: { text: 'second' },
      },
    });

    expect(response.status).toBe(200);
    const body = await (
      await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`)
    ).json();
    expect(body.value.text).toBe('second');
    expect(commits[1][0].action).toBe('update');
  });
});

describe('deleteRecord', () => {
  it('removes the record and unlinks its blobs', async () => {
    const { repo, records, unlinked, commits } = createRepo();
    const created = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey: 'one',
          record: { text: 'a' },
        },
      })
    ).json();

    const response = await call(repo, DELETE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, rkey: 'one' },
    });

    expect(response.status).toBe(200);
    expect(records.has(created.uri)).toBe(false);
    expect(unlinked).toContain(created.uri);
    expect(commits[1][0].action).toBe('delete');
  });

  it('reaps only the blobs the deleted record held', async () => {
    // Another orphan can be a blob a client uploaded for a record it is
    // about to write. Reaping it would leave that record without its blob.
    const { repo, reaped } = createRepo();
    const blob = 'bafkreiheldbytherecordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
    await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'one',
        record: {
          text: 'a',
          log: { $type: 'blob', ref: { $link: blob }, mimeType: 'text/plain' },
        },
      },
    });
    await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'two',
        record: { text: 'b' },
      },
    });
    const before = Date.now();

    for (const rkey of ['one', 'two']) {
      await call(repo, DELETE, {
        auth: FULL,
        body: { repo: DID, collection: COLLECTION, rkey },
      });
    }

    expect(reaped).toHaveLength(1);
    expect(reaped[0].only).toEqual([blob]);
    expect(reaped[0].olderThan).toBeGreaterThan(before);
    expect(reaped[0].olderThan).toBeLessThanOrEqual(Date.now() + 1);
  });

  it('deleting a record that is not there commits nothing', async () => {
    // The tree does not change, so there is nothing to announce. Committing
    // anyway puts an op on the firehose that the MST diff does not contain —
    // a delete for a record the repo never had — and clients retry, so one
    // stray delete became a run of impossible commits.
    const { repo, commits } = createRepo();

    const response = await call(repo, DELETE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, rkey: 'never-existed' },
    });

    expect(response.status).toBe(200);
    expect(commits).toHaveLength(0);
  });

  it('deleting the same record twice commits once', async () => {
    const { repo, commits } = createRepo();
    await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'one',
        record: { text: 'a' },
      },
    });
    const body = { repo: DID, collection: COLLECTION, rkey: 'one' };

    await call(repo, DELETE, { auth: FULL, body });
    const second = await call(repo, DELETE, { auth: FULL, body });

    expect(second.status).toBe(200);
    // One for the create, one for the delete that did something.
    expect(commits).toHaveLength(2);
  });
});

describe('applyWrites', () => {
  it('a delete of a record that is not there contributes no op', async () => {
    const { repo, commits } = createRepo();

    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#delete',
            collection: COLLECTION,
            rkey: 'never-existed',
          },
        ],
      },
    });

    expect(response.status).toBe(200);
    // The result still stands, one per write, but nothing was committed.
    const body = await response.json();
    expect(body.results).toHaveLength(1);
    expect(body.results[0].$type).toBe(
      'com.atproto.repo.applyWrites#deleteResult',
    );
    expect(commits).toHaveLength(0);
  });

  it('lands every operation against one commit', async () => {
    const { repo, records, commits } = createRepo();

    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'a',
            value: { text: 'a' },
          },
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'b',
            value: { text: 'b' },
          },
        ],
      },
    });

    expect(response.status).toBe(200);
    expect(records.size).toBe(2);
    // One commit carrying both operations, not one commit each.
    expect(commits).toHaveLength(1);
    expect(commits[0]).toHaveLength(2);
  });

  it('refuses a create at a key the repo already holds, writing nothing', async () => {
    const { repo, records, commits } = createRepo();
    const first = await (
      await call(repo, CREATE, {
        auth: FULL,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey: 'taken',
          record: { text: 'first' },
        },
      })
    ).json();

    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'other',
            value: { text: 'other' },
          },
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'taken',
            value: { text: 'second' },
          },
        ],
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toBe(
      `Record already exists: ${first.uri}`,
    );
    expect(records.size).toBe(1);
    expect(records.get(first.uri)?.cid).toBe(first.cid);
    expect(commits).toHaveLength(1);
  });

  it('refuses two creates of one key in a batch', async () => {
    const { repo, records, commits } = createRepo();
    const create = {
      $type: 'com.atproto.repo.applyWrites#create',
      collection: COLLECTION,
      rkey: 'twice',
      value: { text: 'a' },
    };

    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [create, { ...create, value: { text: 'b' } }],
      },
    });

    expect(response.status).toBe(400);
    expect(records.size).toBe(0);
    expect(commits).toHaveLength(0);
  });

  it('refuses a read-only server', async () => {
    const { repo } = createRepo({ readOnly: true });
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: { repo: DID, writes: [] },
    });

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

  it('needs a session', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      body: { repo: DID, writes: [] },
    });

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

describe('applyWrites operations', () => {
  it('updates and deletes in the same commit as a create', async () => {
    const { repo, records, commits, unlinked } = createRepo();
    await call(repo, CREATE, {
      auth: FULL,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'old',
        record: { text: 'first' },
      },
    });

    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#update',
            collection: COLLECTION,
            rkey: 'old',
            value: { text: 'second' },
          },
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'fresh',
            value: { text: 'new' },
          },
          {
            $type: 'com.atproto.repo.applyWrites#delete',
            collection: COLLECTION,
            rkey: 'fresh',
          },
        ],
      },
    });
    const body = await response.json();

    expect(response.status).toBe(200);
    expect(
      body.results.map((/** @type {{$type: string}} */ r) => r.$type),
    ).toEqual([
      'com.atproto.repo.applyWrites#updateResult',
      'com.atproto.repo.applyWrites#createResult',
      'com.atproto.repo.applyWrites#deleteResult',
    ]);
    // The batch is one commit carrying all three actions.
    expect(commits[1].map((o) => o.action)).toEqual([
      'update',
      'create',
      'delete',
    ]);
    expect(records.has(`at://${DID}/${COLLECTION}/fresh`)).toBe(false);
    expect(unlinked).toContain(`at://${DID}/${COLLECTION}/fresh`);
  });

  it('needs an rkey to update', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#update',
            collection: COLLECTION,
            value: {},
          },
        ],
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/rkey required for update/);
  });

  it('needs an rkey to delete', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#delete',
            collection: COLLECTION,
          },
        ],
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/rkey required for delete/);
  });

  it('refuses a write type it does not know', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [{ $type: 'com.atproto.repo.applyWrites#frobnicate' }],
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/Unknown write operation/);
  });

  it('refuses a batch operation the scope does not cover', async () => {
    const { repo, records, commits } = createRepo();
    const response = await call(repo, APPLY, {
      auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'a',
            value: {},
          },
        ],
      },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toContain(COLLECTION);
    expect(records.size).toBe(0);
    expect(commits).toHaveLength(0);
  });

  // The whole batch is checked before anything is written, so a partly covered
  // batch changes nothing rather than landing its allowed half.
  it('writes nothing when one operation in a batch is not covered', async () => {
    const { repo, records, commits } = createRepo();
    const response = await call(repo, APPLY, {
      auth: { did: DID, scope: `repo:${COLLECTION}?action=create` },
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'allowed',
            value: {},
          },
          {
            $type: 'com.atproto.repo.applyWrites#delete',
            collection: COLLECTION,
            rkey: 'allowed',
          },
        ],
      },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toMatch(/delete/);
    expect(records.size).toBe(0);
    expect(commits).toHaveLength(0);
  });

  it('needs writes to be an array', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: { repo: DID, writes: 'nope' },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/must be an array/);
  });

  it('refuses a repo that is not this one', async () => {
    const { repo } = createRepo();
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: { repo: 'did:plc:someoneelse', writes: [] },
    });

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

  it('needs an account', async () => {
    const { repo } = createRepo({ did: null });
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: { repo: DID, writes: [] },
    });

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

  it('passes on the reason an account is inactive', async () => {
    const { repo } = createRepo({
      inactive: Response.json({ error: 'AccountDeactivated' }, { status: 400 }),
    });
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: { repo: DID, writes: [] },
    });

    expect((await response.json()).error).toBe('AccountDeactivated');
  });

  it('reports a schema failure and writes nothing', async () => {
    const { repo, records } = createRepo({
      resolver: {
        async assertValid() {
          throw new Error('text must be a string');
        },
      },
    });
    const response = await call(repo, APPLY, {
      auth: FULL,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            value: { text: 1 },
          },
        ],
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('InvalidRecord');
    expect(records.size).toBe(0);
  });
});

describe('putRecord and deleteRecord guards', () => {
  it('needs a session to put', async () => {
    const { repo } = createRepo();
    const response = await call(repo, PUT, {
      body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
    });

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

  it('refuses a read-only server on put', async () => {
    const { repo } = createRepo({ readOnly: true });
    const response = await call(repo, PUT, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
    });

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

  // putRecord is an upsert, so a scope allowing only one of create or update is
  // not enough to overwrite a record.
  it('refuses a collection the scope does not cover on put', async () => {
    const { repo, records } = createRepo();
    const response = await call(repo, PUT, {
      auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
      body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toContain(COLLECTION);
    expect(records.size).toBe(0);
  });

  it('refuses a put scope that allows create but not update', async () => {
    const { repo, records } = createRepo();
    const response = await call(repo, PUT, {
      auth: { did: DID, scope: `repo:${COLLECTION}?action=create` },
      body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
    });

    expect(response.status).toBe(403);
    expect((await response.json()).message).toMatch(/update/);
    expect(records.size).toBe(0);
  });

  it('needs a session to delete', async () => {
    const { repo } = createRepo();
    const response = await call(repo, DELETE, {
      body: { repo: DID, collection: COLLECTION, rkey: 'a' },
    });

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

  it('refuses a read-only server on delete', async () => {
    const { repo } = createRepo({ readOnly: true });
    const response = await call(repo, DELETE, {
      auth: FULL,
      body: { repo: DID, collection: COLLECTION, rkey: 'a' },
    });

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

  it('refuses a collection the scope does not cover on delete', async () => {
    const { repo } = createRepo();
    const response = await call(repo, DELETE, {
      auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
      body: { repo: DID, collection: COLLECTION, rkey: 'a' },
    });

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

// An app-password session names no granular scopes, and must still be able to
// write. It is bounded elsewhere: it cannot mint app passwords or read the email.
describe('app-password sessions', () => {
  const APP_PASS = { did: DID, scope: 'com.atproto.appPass' };

  it('creates, overwrites and deletes a record', async () => {
    const { repo, records } = createRepo();
    const created = await (
      await call(repo, CREATE, {
        auth: APP_PASS,
        body: {
          repo: DID,
          collection: COLLECTION,
          rkey: 'one',
          record: { text: 'a' },
        },
      })
    ).json();
    expect(records.has(created.uri)).toBe(true);

    const put = await call(repo, PUT, {
      auth: APP_PASS,
      body: {
        repo: DID,
        collection: COLLECTION,
        rkey: 'one',
        record: { text: 'b' },
      },
    });
    expect(put.status).toBe(200);

    const removed = await call(repo, DELETE, {
      auth: APP_PASS,
      body: { repo: DID, collection: COLLECTION, rkey: 'one' },
    });
    expect(removed.status).toBe(200);
    expect(records.has(created.uri)).toBe(false);
  });

  it('applies a batch', async () => {
    const { repo, records } = createRepo();
    const response = await call(repo, APPLY, {
      auth: APP_PASS,
      body: {
        repo: DID,
        writes: [
          {
            $type: 'com.atproto.repo.applyWrites#create',
            collection: COLLECTION,
            rkey: 'a',
            value: { text: 'a' },
          },
        ],
      },
    });

    expect(response.status).toBe(200);
    expect(records.size).toBe(1);
  });
});

describe('getRecord and listRecords guards', () => {
  it('needs repo, collection and rkey to get', async () => {
    const { repo } = createRepo();
    const response = await call(repo, `${GET}?repo=${DID}`);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/Missing required/);
  });

  it('needs repo and collection to list', async () => {
    const { repo } = createRepo();
    const response = await call(repo, `${LIST}?repo=${DID}`);

    expect(response.status).toBe(400);
    expect((await response.json()).message).toMatch(/Missing required/);
  });
});