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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1040 lines · 33.0 KB Raw History
// The dev.pdsjs.git XRPC methods, driven through pds.fetch against fake
// storage: anonymous reads of the public repo, the account session for
// spaces and writes, bearer tokens checked against repo scopes.
import { cborEncodeDagCbor } from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import { createAccessJwt } from '../../../packages/core/src/auth.js';
import {
  apiGet,
  BASE,
  createActorStorage,
  createPds as createBasePds,
  DID,
  SECRET,
  sessionCookie,
} from '../../../test/helpers/account-api-harness.js';
import { createFakeSpaceStorage } from '../../../test/helpers/fake-space-storage.js';
import {
  GIT_DEFS,
  GIT_METHODS,
  gitDefsLexicon,
  gitMethodLexicons,
} from '../src/api-lexicon.js';
import { createGitHosting } from '../src/hosting.js';
import { createGitMethods } from '../src/methods.js';

const SPACE = `at://${DID}/space/com.example.team/main`;
/** The space a private repository named proj keeps. */
/** The space a private repository lives in, keyed by its name. */
const ownSpace = (/** @type {string} */ rkey) =>
  `at://${DID}/space/dev.pdsjs.git.space/${rkey}`;
const SHA = 'a'.repeat(40);

const REPO_RECORD = {
  $type: 'dev.pdsjs.git.repo',
  name: 'myrepo',
  defaultBranch: 'main',
  refs: [{ name: 'refs/heads/main', sha: SHA }],
  bundles: [],
  createdAt: '2026-08-01T00:00:00.000Z',
};

function gitActorStorage() {
  const encoded = cborEncodeDagCbor(REPO_RECORD);
  return createActorStorage({
    async getRecord(/** @type {string} */ uri) {
      return uri === `at://${DID}/dev.pdsjs.git.repo/myrepo`
        ? { cid: 'bafyrepo', value: encoded }
        : null;
    },
    async listRecords(/** @type {string} */ collection) {
      if (collection !== 'dev.pdsjs.git.repo') {
        return { records: [], cursor: null };
      }
      return {
        records: [
          {
            uri: `at://${DID}/dev.pdsjs.git.repo/myrepo`,
            cid: 'bafyrepo',
            rkey: 'myrepo',
            value: encoded,
          },
        ],
        cursor: null,
      };
    },
  });
}

/** A browser port that records calls and answers canned views. */
function fakeBrowser() {
  /** @type {Array<{method: string, args: unknown[]}>} */
  const calls = [];
  /** @param {string} method @param {unknown[]} args */
  const note = (method, args) => calls.push({ method, args });
  /** @param {string|undefined} ref */
  const check = (ref) => {
    if (ref === 'huge') {
      throw Object.assign(new Error('repository huge has no object indexes'), {
        code: 'RepositoryTooLarge',
      });
    }
    if (ref && ref !== 'main') throw new Error(`no such ref: ${ref}`);
  };
  return {
    calls,
    async listTree(/** @type {unknown[]} */ ...args) {
      note('listTree', args);
      check(/** @type {string|undefined} */ (args[2]));
      return {
        ref: 'refs/heads/main',
        sha: SHA,
        path: args[3],
        entries: [
          { name: 'src', type: 'dir', mode: '40000', sha: SHA, size: null },
        ],
      };
    },
    async readFile(/** @type {unknown[]} */ ...args) {
      note('readFile', args);
      return {
        ref: 'refs/heads/main',
        path: args[3],
        size: 3,
        mode: '100644',
        mediaType: null,
        binary: false,
        truncated: false,
        text: 'hi\n',
      };
    },
    async readBytes(/** @type {unknown[]} */ ...args) {
      note('readBytes', args);
      const name = String(args[3]);
      return {
        ref: 'refs/heads/main',
        path: args[3],
        size: 3,
        mode: '100644',
        mediaType: name.endsWith('.png') ? 'image/png' : null,
        binary: name.endsWith('.bin') || name.endsWith('.png'),
        bytes: new Uint8Array([104, 105, 10]),
      };
    },
    async listFiles(/** @type {unknown[]} */ ...args) {
      note('listFiles', args);
      return {
        ref: 'refs/heads/main',
        files: [{ path: 'README.md', size: 3 }],
        truncated: false,
      };
    },
    async listCommits(/** @type {unknown[]} */ ...args) {
      note('listCommits', args);
      return { ref: 'refs/heads/main', commits: [], truncated: false };
    },
    async readCommit(/** @type {unknown[]} */ ...args) {
      note('readCommit', args);
      return {
        sha: SHA,
        parents: [],
        author: 'A <a@example.com>',
        authoredAt: 1,
        committedAt: 1,
        message: 'm',
        files: [
          {
            path: 'logo.png',
            status: 'modified',
            binary: true,
            mediaType: 'image/png',
            oldImage: new Uint8Array([1]),
            newImage: new Uint8Array([2]),
            hunks: null,
            additions: 0,
            deletions: 0,
          },
        ],
      };
    },
    async lastCommit(/** @type {unknown[]} */ ...args) {
      note('lastCommit', args);
      return { sha: SHA, parents: [], author: 'A', message: 'm' };
    },
  };
}

/**
 * @param {Object} [options]
 * @param {Object} [options.actorStorage]
 * @param {Object} [options.spaceStorage]
 * @param {Object} [options.spaceAdmin]
 * @param {ReturnType<typeof fakeBrowser>|null} [options.browser]
 * @param {boolean} [options.readOnly]
 * @param {(who: {did: string, handle: string, pds: string}) => unknown} [options.openCopy] - a browser over another account's copy, for the reads that name one
 */
function createPds({
  actorStorage = gitActorStorage(),
  spaceStorage,
  spaceAdmin,
  browser = fakeBrowser(),
  readOnly = false,
  openCopy,
} = {}) {
  return createBasePds({
    actorStorage,
    spaceStorage,
    readOnly,
    extensions: [
      (host) => ({
        name: 'git',
        collections: [
          'dev.pdsjs.git.repo',
          'dev.pdsjs.git.config',
          'dev.pdsjs.git.checkRequest',
        ],
        routes: createGitMethods({
          host,
          browser: /** @type {any} */ (browser),
          openCopy: /** @type {any} */ (openCopy),
          hosting: createGitHosting({
            host,
            httpEnabled: false,
            spaceBrowser: host.spaceBrowser ?? null,
            spaceAdmin: /** @type {any} */ (spaceAdmin ?? null),
          }),
        }),
      }),
    ],
  });
}

/** @param {string} method @param {Record<string, string>} [params] */
const path = (method, params = {}) =>
  `/xrpc/dev.pdsjs.git.${method}?${new URLSearchParams(params)}`;

/**
 * @param {ReturnType<typeof createPds>} pds
 * @param {string} method
 * @param {unknown} body
 * @param {Record<string, string>} [headers]
 */
const call = (pds, method, body, headers = {}) =>
  pds.fetch(
    new Request(`${BASE}/xrpc/dev.pdsjs.git.${method}`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...headers },
      body: typeof body === 'string' ? body : JSON.stringify(body),
    }),
  );

/** @param {string} scope */
const bearer = async (scope) => ({
  authorization: `Bearer ${await createAccessJwt(DID, SECRET, 3600, scope)}`,
});

describe('git XRPC reads', () => {
  it('lists the public repositories to anyone, with CORS', async () => {
    const pds = createPds();
    const res = await pds.fetch(new Request(BASE + path('listRepos')));
    expect(res.status).toBe(200);
    expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
    expect(res.headers.get('Cache-Control')).toBe('no-cache');
    const body = await res.json();
    expect(body.httpEnabled).toBe(false);
    expect(body.repos.map((/** @type {{name: string}} */ r) => r.name)).toEqual(
      ['myrepo'],
    );
    expect(body.repos[0].cloneUrl).toBe('atproto://test.handle/myrepo');
  });

  it('summarizes pull requests, issues and activity only when asked', async () => {
    const pds = createPds();

    const plain = await (
      await pds.fetch(new Request(BASE + path('listRepos')))
    ).json();
    expect(plain.repos[0].openPulls).toBeUndefined();
    expect(plain.repos[0].openIssues).toBeUndefined();
    expect(plain.repos[0].activity).toBeUndefined();

    const summarized = await (
      await pds.fetch(
        new Request(BASE + path('listRepos', { summary: 'true' })),
      )
    ).json();
    expect(summarized.repos[0].openPulls).toBe(0);
    expect(summarized.repos[0].openIssues).toBe(0);
    expect(Array.isArray(summarized.repos[0].activity)).toBe(true);
  });

  it('lists the repositories in spaces to the account session only', async () => {
    const spaceStorage = createFakeSpaceStorage({
      spaces: [{ uri: SPACE, deletedAt: null }],
      records: {
        [SPACE]: [
          {
            collection: 'dev.pdsjs.git.repo',
            rkey: 'private',
            cid: 'bafyprivate',
            value: { name: 'private', refs: [], bundles: [] },
          },
        ],
      },
    });
    const pds = createPds({ spaceStorage });
    const anonymous = await (
      await pds.fetch(new Request(BASE + path('listRepos')))
    ).json();
    expect(
      anonymous.repos.map((/** @type {{name: string}} */ r) => r.name),
    ).toEqual(['myrepo']);

    const owner = await apiGet(pds, path('listRepos'), await sessionCookie());
    expect(owner.headers.get('Cache-Control')).toBe('no-store');
    const names = (await owner.json()).repos.map(
      (/** @type {{name: string, space: string|null}} */ r) => [
        r.name,
        r.space,
      ],
    );
    expect(names).toEqual([
      ['myrepo', null],
      ['private', SPACE],
    ]);
  });

  it('answers one repository, and names the missing ones', async () => {
    const pds = createPds();
    const found = await pds.fetch(
      new Request(BASE + path('getRepo', { repo: 'myrepo' })),
    );
    expect(found.status).toBe(200);
    expect((await found.json()).defaultBranch).toBe('main');

    const missing = await pds.fetch(
      new Request(BASE + path('getRepo', { repo: 'nope' })),
    );
    expect(missing.status).toBe(404);
    expect((await missing.json()).error).toBe('RepoNotFound');

    const unnamed = await pds.fetch(new Request(BASE + path('getRepo')));
    expect(unnamed.status).toBe(400);
    expect((await unnamed.json()).error).toBe('InvalidRequest');
  });

  it('reads a tree under the record cid as its ETag', async () => {
    const browser = fakeBrowser();
    const pds = createPds({ browser });
    const url = BASE + path('getTree', { repo: 'myrepo', path: 'src' });
    const res = await pds.fetch(new Request(url));
    expect(res.status).toBe(200);
    expect(res.headers.get('ETag')).toBe('"bafyrepo"');
    expect(res.headers.get('Cache-Control')).toBe('no-cache');
    expect((await res.json()).entries[0].name).toBe('src');
    expect(browser.calls[0]).toEqual({
      method: 'listTree',
      args: [DID, 'myrepo', undefined, 'src', undefined],
    });

    const cached = await pds.fetch(
      new Request(url, { headers: { 'if-none-match': '"bafyrepo"' } }),
    );
    expect(cached.status).toBe(304);
    expect(browser.calls).toHaveLength(1);
  });

  it('answers immutably under the current cid and refuses a stale one', async () => {
    const pds = createPds();
    const current = await pds.fetch(
      new Request(BASE + path('getTree', { repo: 'myrepo', cid: 'bafyrepo' })),
    );
    expect(current.status).toBe(200);
    expect(current.headers.get('Cache-Control')).toBe(
      'public, max-age=31536000, immutable',
    );

    const stale = await pds.fetch(
      new Request(BASE + path('getTree', { repo: 'myrepo', cid: 'bafyold' })),
    );
    expect(stale.status).toBe(404);
    expect((await stale.json()).error).toBe('RepoNotFound');
  });

  it('reads a space repository through the account session alone', async () => {
    const browser = fakeBrowser();
    const spaceStorage = createFakeSpaceStorage({
      spaces: [{ uri: SPACE, deletedAt: null }],
      records: {
        [SPACE]: [
          {
            collection: 'dev.pdsjs.git.repo',
            rkey: 'private',
            cid: 'bafyprivate',
            value: { name: 'private', refs: [], bundles: [] },
          },
        ],
      },
    });
    const pds = createPds({ browser, spaceStorage });
    const query = path('getTree', { repo: 'private', space: SPACE });
    const anonymous = await pds.fetch(new Request(BASE + query));
    expect(anonymous.status).toBe(401);
    expect((await anonymous.json()).error).toBe('AuthenticationRequired');

    const owner = await apiGet(pds, query, await sessionCookie());
    expect(owner.status).toBe(200);
    expect(owner.headers.get('Cache-Control')).toBe('no-store');
    expect(owner.headers.get('ETag')).toBeNull();
    expect(browser.calls[0].args).toEqual([
      DID,
      'private',
      undefined,
      '',
      SPACE,
    ]);
  });

  it('reads a space repository with a bearer token naming the account', async () => {
    const browser = fakeBrowser();
    const spaceStorage = createFakeSpaceStorage({
      spaces: [{ uri: SPACE, deletedAt: null }],
      records: {
        [SPACE]: [
          {
            collection: 'dev.pdsjs.git.repo',
            rkey: 'private',
            cid: 'bafyprivate',
            value: { name: 'private', refs: [], bundles: [] },
          },
        ],
      },
    });
    const pds = createPds({ browser, spaceStorage });
    const query = path('getTree', { repo: 'private', space: SPACE });
    const res = await pds.fetch(
      new Request(BASE + query, { headers: await bearer('atproto') }),
    );
    expect(res.status).toBe(200);
    expect(browser.calls[0].args).toEqual([
      DID,
      'private',
      undefined,
      '',
      SPACE,
    ]);
  });

  it('refuses a space read to another account’s bearer token', async () => {
    const browser = fakeBrowser();
    const spaceStorage = createFakeSpaceStorage({
      spaces: [{ uri: SPACE, deletedAt: null }],
      records: {
        [SPACE]: [
          {
            collection: 'dev.pdsjs.git.repo',
            rkey: 'private',
            cid: 'bafyprivate',
            value: { name: 'private', refs: [], bundles: [] },
          },
        ],
      },
    });
    const pds = createPds({ browser, spaceStorage });
    const query = path('getTree', { repo: 'private', space: SPACE });
    const other = {
      authorization: `Bearer ${await createAccessJwt(
        'did:plc:stranger',
        SECRET,
        3600,
        'atproto',
      )}`,
    };
    const res = await pds.fetch(new Request(BASE + query, { headers: other }));
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
  });

  it('lists the spaces to a bearer token naming the account', async () => {
    const spaceStorage = createFakeSpaceStorage({
      spaces: [{ uri: SPACE, deletedAt: null }],
      records: {
        [SPACE]: [
          {
            collection: 'dev.pdsjs.git.repo',
            rkey: 'private',
            cid: 'bafyprivate',
            value: { name: 'private', refs: [], bundles: [] },
          },
        ],
      },
    });
    const pds = createPds({ spaceStorage });
    const owner = await pds.fetch(
      new Request(BASE + path('listRepos'), {
        headers: await bearer('atproto'),
      }),
    );
    const names = (await owner.json()).repos.map(
      (/** @type {{name: string, space: string|null}} */ r) => [
        r.name,
        r.space,
      ],
    );
    expect(names).toEqual([
      ['myrepo', null],
      ['private', SPACE],
    ]);
  });

  it('reports an unknown ref as NotFound', async () => {
    const pds = createPds();
    const res = await pds.fetch(
      new Request(BASE + path('getTree', { repo: 'myrepo', ref: 'gone' })),
    );
    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({
      error: 'NotFound',
      message: 'no such ref: gone',
    });
  });

  it('names a repository the server will not download whole', async () => {
    const pds = createPds();
    const res = await pds.fetch(
      new Request(BASE + path('getTree', { repo: 'myrepo', ref: 'huge' })),
    );
    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('RepositoryTooLarge');
  });

  it('lists every file at a ref', async () => {
    const browser = fakeBrowser();
    const pds = createPds({ browser });
    const res = await pds.fetch(
      new Request(BASE + path('listFiles', { repo: 'myrepo', ref: 'main' })),
    );
    expect(res.status).toBe(200);
    expect((await res.json()).files).toEqual([{ path: 'README.md', size: 3 }]);
    expect(browser.calls[0].args).toEqual([DID, 'myrepo', 'main', undefined]);
  });

  it('reads a file, and needs its path', async () => {
    const pds = createPds();
    const unnamed = await pds.fetch(
      new Request(BASE + path('getFile', { repo: 'myrepo' })),
    );
    expect(unnamed.status).toBe(400);
    expect((await unnamed.json()).message).toMatch(/path/);

    const res = await pds.fetch(
      new Request(BASE + path('getFile', { repo: 'myrepo', path: 'a.txt' })),
    );
    expect(res.status).toBe(200);
    expect((await res.json()).text).toBe('hi\n');
  });

  it('serves raw bytes as text, an octet stream or an image, never as a page', async () => {
    const pds = createPds();
    const text = await pds.fetch(
      new Request(BASE + path('getRaw', { repo: 'myrepo', path: 'x.html' })),
    );
    expect(text.status).toBe(200);
    expect(text.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
    expect(text.headers.get('X-Content-Type-Options')).toBe('nosniff');
    expect(text.headers.get('Content-Security-Policy')).toContain('sandbox');
    expect(text.headers.get('ETag')).toBe('"bafyrepo"');
    expect(await text.text()).toBe('hi\n');

    const binary = await pds.fetch(
      new Request(BASE + path('getRaw', { repo: 'myrepo', path: 'x.bin' })),
    );
    expect(binary.headers.get('Content-Type')).toBe('application/octet-stream');

    const image = await pds.fetch(
      new Request(BASE + path('getRaw', { repo: 'myrepo', path: 'x.png' })),
    );
    expect(image.headers.get('Content-Type')).toBe('image/png');
    expect(image.headers.get('Content-Security-Policy')).toContain('sandbox');
  });

  it('clamps the commit limit', async () => {
    const browser = fakeBrowser();
    const pds = createPds({ browser });
    await pds.fetch(
      new Request(
        BASE + path('listCommits', { repo: 'myrepo', limit: '9999' }),
      ),
    );
    await pds.fetch(
      new Request(BASE + path('listCommits', { repo: 'myrepo' })),
    );
    expect(browser.calls.map((c) => c.args[3])).toEqual([500, 50]);
  });

  it('answers a commit without the image bytes of its diff', async () => {
    const pds = createPds();
    const res = await pds.fetch(
      new Request(BASE + path('getCommit', { repo: 'myrepo', sha: SHA })),
    );
    expect(res.status).toBe(200);
    const { files } = await res.json();
    expect(files[0]).not.toHaveProperty('oldImage');
    expect(files[0]).not.toHaveProperty('newImage');
    expect(files[0].mediaType).toBe('image/png');
  });

  it('answers the last commit of a path', async () => {
    const browser = fakeBrowser();
    const pds = createPds({ browser });
    const res = await pds.fetch(
      new Request(
        BASE + path('getLastCommit', { repo: 'myrepo', path: 'README.md' }),
      ),
    );
    expect((await res.json()).commit.sha).toBe(SHA);
    expect(browser.calls[0].args).toEqual([
      DID,
      'myrepo',
      undefined,
      'README.md',
      undefined,
      undefined,
    ]);
  });

  it('answers the checks without a validator, since they live elsewhere', async () => {
    const pds = createPds();
    const res = await pds.fetch(
      new Request(BASE + path('listChecks', { repo: 'myrepo' })),
    );
    expect(res.status).toBe(200);
    expect(res.headers.get('ETag')).toBeNull();
    expect(res.headers.get('Cache-Control')).toBe('no-store');
    expect(await res.json()).toEqual({ runner: null, checks: [] });
  });

  it('registers the bundle-chain reads only with a browser', async () => {
    const pds = createPds({ browser: null });
    const res = await pds.fetch(
      new Request(BASE + path('getTree', { repo: 'myrepo' })),
    );
    expect(res.status).toBe(501);
    const { methods } = await (
      await pds.fetch(
        new Request(`${BASE}/xrpc/community.lexicon.service.describe`),
      )
    ).json();
    const names = methods.map((/** @type {{value: string}} */ m) => m.value);
    expect(names).toContain('dev.pdsjs.git.listRepos');
    expect(names).toContain('dev.pdsjs.git.createRepo');
    expect(names).not.toContain('dev.pdsjs.git.getTree');
  });
});

describe('git XRPC procedures', () => {
  it('needs the account session or a bearer token', async () => {
    const pds = createPds();
    const res = await call(pds, 'createRepo', { name: 'x' });
    expect(res.status).toBe(401);
    expect((await res.json()).error).toBe('AuthenticationRequired');
  });

  it('refuses a cross-origin request on the session', async () => {
    const pds = createPds();
    const res = await call(
      pds,
      'createRepo',
      { name: 'x' },
      { cookie: await sessionCookie(), origin: 'https://evil.example' },
    );
    expect(res.status).toBe(403);
    expect((await res.json()).error).toBe('Forbidden');
  });

  it('creates a private repository on the session', async () => {
    /** @type {string[]} */
    const created = [];
    const pds = createPds({
      spaceAdmin: {
        async createSpace(/** @type {{type: string, skey?: string}} */ opts) {
          created.push(`space ${opts.type}/${opts.skey ?? ''}`);
          return { uri: `at://${DID}/space/${opts.type}/${opts.skey}` };
        },
        async createRecord(
          /** @type {string} */ space,
          /** @type {string} */ collection,
          /** @type {string} */ rkey,
        ) {
          created.push(`${space} ${collection}/${rkey}`);
        },
      },
    });
    const res = await call(
      pds,
      'createRepo',
      { name: 'proj', private: true },
      { cookie: await sessionCookie() },
    );
    expect(res.status).toBe(200);
    expect(res.headers.get('Cache-Control')).toBe('no-store');
    expect(await res.json()).toEqual({ name: 'proj', space: ownSpace('proj') });
    expect(created).toEqual([
      'space dev.pdsjs.git.space/proj',
      `${ownSpace('proj')} dev.pdsjs.git.repo/proj`,
    ]);
  });

  it('creates a private repository with the account’s bearer token', async () => {
    /** @type {string[]} */
    const writes = [];
    const pds = createPds({
      spaceAdmin: {
        async createSpace(/** @type {{type: string, skey?: string}} */ opts) {
          return { uri: `at://${DID}/space/${opts.type}/${opts.skey}` };
        },
        async createRecord(
          /** @type {string} */ space,
          /** @type {string} */ collection,
          /** @type {string} */ rkey,
        ) {
          writes.push(`${space} ${collection}/${rkey}`);
        },
      },
    });
    const res = await call(
      pds,
      'createRepo',
      { name: 'proj', private: true },
      await bearer('atproto'),
    );
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ name: 'proj', space: ownSpace('proj') });
    expect(writes).toEqual([`${ownSpace('proj')} dev.pdsjs.git.repo/proj`]);
  });

  it('ignores a space the body names: the repository keeps its own', async () => {
    /** @type {string[]} */
    const created = [];
    const pds = createPds({
      spaceAdmin: {
        async createSpace(/** @type {{type: string, skey?: string}} */ opts) {
          created.push(`${opts.type}/${opts.skey ?? ''}`);
          return { uri: `at://${DID}/space/${opts.type}/${opts.skey}` };
        },
        async createRecord() {},
      },
    });
    const res = await call(
      pds,
      'createRepo',
      { name: 'proj', private: true, space: SPACE, type: 'com.example.other' },
      await bearer('atproto'),
    );
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ name: 'proj', space: ownSpace('proj') });
    expect(created).toEqual(['dev.pdsjs.git.space/proj']);
  });

  it('checks a bearer token against the repo scopes it writes', async () => {
    const pds = createPds();
    const denied = await call(
      pds,
      'createRepo',
      { name: 'myrepo' },
      await bearer('repo:com.example.other'),
    );
    expect(denied.status).toBe(403);
    expect(await denied.json()).toEqual({
      error: 'InvalidScope',
      message: 'Missing required scope to create dev.pdsjs.git.repo',
    });

    const allowed = await call(
      pds,
      'createRepo',
      { name: 'myrepo' },
      await bearer('repo:dev.pdsjs.git.repo'),
    );
    expect(allowed.status).toBe(400);
    expect((await allowed.json()).error).toBe('RepoExists');
  });

  it('refuses every write on a read-only server', async () => {
    const pds = createPds({ readOnly: true });
    const res = await call(
      pds,
      'requestCheck',
      { name: 'myrepo', ref: 'refs/heads/main' },
      { cookie: await sessionCookie() },
    );
    expect(res.status).toBe(401);
    expect((await res.json()).message).toBe('This PDS is read-only');
  });

  it('names the refusals the lexicons list', async () => {
    const pds = createPds();
    const noRunner = await call(
      pds,
      'requestCheck',
      { name: 'myrepo', ref: 'refs/heads/main' },
      { cookie: await sessionCookie() },
    );
    expect(noRunner.status).toBe(400);
    expect((await noRunner.json()).error).toBe('NoRunner');

    const missing = await call(
      pds,
      'updateSettings',
      { name: 'nope', description: 'x' },
      await bearer('com.atproto.access'),
    );
    expect(missing.status).toBe(404);
    expect((await missing.json()).error).toBe('RepoNotFound');
  });

  it('refuses a body that is not a JSON object', async () => {
    const pds = createPds();
    const cookie = await sessionCookie();
    const broken = await call(pds, 'createRepo', '{not json', { cookie });
    expect(broken.status).toBe(400);
    const list = await call(pds, 'createRepo', ['x'], { cookie });
    expect(list.status).toBe(400);
    expect((await list.json()).message).toMatch(/JSON object/);
  });
});

describe('git method lexicons', () => {
  it('describe the methods the extension registers, and no others', async () => {
    const pds = createPds();
    const { methods } = await (
      await pds.fetch(
        new Request(`${BASE}/xrpc/community.lexicon.service.describe`),
      )
    ).json();
    const served = methods
      .map((/** @type {{value: string}} */ m) => m.value)
      .filter((/** @type {string} */ nsid) =>
        nsid.startsWith('dev.pdsjs.git.'),
      );
    expect(served.sort()).toEqual([...GIT_METHODS].sort());
  });

  it('resolve every local ref within the defs', () => {
    /** @param {unknown} node @param {string[]} into */
    const refsOf = (node, into) => {
      if (Array.isArray(node)) {
        for (const item of node) refsOf(item, into);
      } else if (node && typeof node === 'object') {
        const record = /** @type {Record<string, unknown>} */ (node);
        if (typeof record.ref === 'string') into.push(record.ref);
        for (const value of Object.values(record)) refsOf(value, into);
      }
      return into;
    };
    const defs = Object.keys(gitDefsLexicon.defs);
    for (const lexicon of [gitDefsLexicon, ...gitMethodLexicons]) {
      for (const ref of refsOf(lexicon.defs, [])) {
        const [nsid, def] = ref.split('#');
        if (nsid === '' || nsid === GIT_DEFS) {
          expect(defs, `${lexicon.id} refers to ${ref}`).toContain(def);
        } else {
          expect(nsid).toMatch(/^dev\.pdsjs\.git\./);
        }
      }
      expect(lexicon.lexicon).toBe(1);
    }
    for (const lexicon of gitMethodLexicons) {
      expect(['query', 'procedure']).toContain(lexicon.defs.main.type);
    }
  });
});

describe("a read of another account's copy", () => {
  const BOB = 'did:plc:bob';

  /**
   * The world beyond this server: bob has a document and a copy, nobody else
   * does. The copy is listed rather than fetched by key, because a copy is
   * its holder's own record and carries their own key.
   */
  function directory() {
    const real = globalThis.fetch;
    globalThis.fetch = async (/** @type {any} */ input) => {
      const url = new URL(String(input));
      if (decodeURIComponent(url.pathname) === `/${BOB}`) {
        return Response.json({
          alsoKnownAs: ['at://bob.test'],
          service: [
            { id: '#atproto_pds', serviceEndpoint: 'https://bob.test' },
          ],
        });
      }
      if (
        url.host === 'bob.test' &&
        url.pathname === '/xrpc/com.atproto.repo.listRecords' &&
        url.searchParams.get('collection') === 'dev.pdsjs.git.repo'
      ) {
        return Response.json({
          records: [
            {
              uri: `at://${BOB}/dev.pdsjs.git.repo/3lbobskey`,
              cid: 'bafybobcopy',
              value: { name: 'myrepo', refs: [], bundles: [] },
            },
          ],
        });
      }
      return new Response('no', { status: 404 });
    };
    return () => (globalThis.fetch = real);
  }

  /** A browser over bob's copy, noting what it is asked. */
  function copyBrowser() {
    /** @type {Array<{method: string, args: unknown[]}>} */
    const calls = [];
    const browser = {
      readCommit: async (/** @type {unknown[]} */ ...args) => {
        calls.push({ method: 'readCommit', args });
        return { sha: SHA, parents: [], message: 'in the copy', files: [] };
      },
      readRange: async (/** @type {unknown[]} */ ...args) => {
        calls.push({ method: 'readRange', args });
        return {
          base: SHA,
          sha: SHA,
          parents: [],
          message: 'across',
          files: [],
        };
      },
      listRange: async (/** @type {unknown[]} */ ...args) => {
        calls.push({ method: 'listRange', args });
        return { commits: [], truncated: false };
      },
      listTree: async (/** @type {unknown[]} */ ...args) => {
        calls.push({ method: 'listTree', args });
        return { ref: 'main', sha: SHA, path: '', entries: [] };
      },
    };
    return { calls, browser };
  }

  it('reads the copy through the server, and says whose copy answered', async () => {
    const restore = directory();
    const copy = copyBrowser();
    try {
      const pds = createPds({ openCopy: () => copy.browser });
      const res = await pds.fetch(
        new Request(
          BASE + path('getCommit', { repo: 'myrepo', sha: SHA, from: BOB }),
        ),
      );
      expect(res.status).toBe(200);
      const body = await res.json();
      expect(body.message).toBe('in the copy');
      expect(body.copy).toEqual({
        did: BOB,
        handle: 'bob.test',
        pds: 'https://bob.test',
      });
      // The copy's account is what the browser reads, not this one, and it
      // is read at the key bob's own record carries.
      expect(copy.calls[0].method).toBe('readCommit');
      expect(copy.calls[0].args.slice(0, 3)).toEqual([BOB, '3lbobskey', SHA]);
      // A copy's version is theirs, so nothing here validates it.
      expect(res.headers.get('etag')).toBeNull();
      expect(res.headers.get('cache-control')).toBe('no-store');
    } finally {
      restore();
    }
  });

  it('reads a range as one diff, and lists the commits across it', async () => {
    const restore = directory();
    const copy = copyBrowser();
    try {
      const pds = createPds({ openCopy: () => copy.browser });
      const base = 'b'.repeat(40);
      const diff = await pds.fetch(
        new Request(
          BASE +
            path('getCommit', {
              repo: 'myrepo',
              sha: `${base}..${SHA}`,
              from: BOB,
            }),
        ),
      );
      expect(diff.status).toBe(200);
      expect((await diff.json()).message).toBe('across');
      const log = await pds.fetch(
        new Request(
          BASE +
            path('listCommits', {
              repo: 'myrepo',
              range: `${base}..${SHA}`,
              from: BOB,
            }),
        ),
      );
      expect(log.status).toBe(200);
      expect(copy.calls.map((one) => one.method)).toEqual([
        'readRange',
        'listRange',
      ]);
      expect(copy.calls[0].args.slice(0, 4)).toEqual([
        BOB,
        '3lbobskey',
        base,
        SHA,
      ]);
    } finally {
      restore();
    }
  });

  it('answers NotFound for an account with no server, and refuses a copy in a space', async () => {
    const restore = directory();
    try {
      const pds = createPds({ openCopy: () => copyBrowser().browser });
      const nobody = await pds.fetch(
        new Request(
          BASE + path('getTree', { repo: 'myrepo', from: 'did:plc:nobody' }),
        ),
      );
      expect(nobody.status).toBe(404);
      expect((await nobody.json()).error).toBe('NotFound');
      const spaced = await pds.fetch(
        new Request(
          BASE +
            path('getTree', {
              repo: 'myrepo',
              from: BOB,
              space: 'at://did:plc:x/com.example.t/s',
            }),
        ),
      );
      expect(spaced.status).toBe(400);
    } finally {
      restore();
    }
  });

  it("reads this account's own copy where from names it", async () => {
    const restore = directory();
    const copy = copyBrowser();
    try {
      const pds = createPds({ openCopy: () => copy.browser });
      const res = await pds.fetch(
        new Request(BASE + path('getTree', { repo: 'myrepo', from: DID })),
      );
      expect(res.status).toBe(200);
      expect(copy.calls).toEqual([]);
      expect(res.headers.get('etag')).not.toBeNull();
    } finally {
      restore();
    }
  });
});