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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
253 lines · 7.8 KB Raw History
/**
 * The git appview end to end: a real PDS holding the data with its git
 * extension switched off, the appview in front of it, and real git
 * subprocesses. A push goes helper-to-PDS direct; a clone goes through the
 * appview with stock git, no helper.
 */

import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
  chmodSync,
  mkdirSync,
  mkdtempSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { createAppViewStore } from '@pdsjs/appview/store';
import { GIT_REPO_COLLECTION } from '@pdsjs/git';
import { createServer } from '@pdsjs/node';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createRemoteAccount } from '../src/remote.js';
import { createGitHostServer } from '../src/server.js';

const execFileAsync = promisify(execFile);

const PDS_PORT = 2520;
const PDS_BASE = `http://127.0.0.1:${PDS_PORT}`;
const HOST_PORT = 2521;
const HOST_BASE = `http://127.0.0.1:${HOST_PORT}`;
const PASSWORD = 'test-password';

/** @param {number} length */
function randomBase32(length) {
  const chars = 'abcdefghijklmnopqrstuvwxyz234567';
  return Array.from(randomBytes(length))
    .map((b) => chars[b % 32])
    .join('');
}

const DID = `did:plc:${randomBase32(24)}`;
const HANDLE = 'git.test';

/** @type {Awaited<ReturnType<typeof createServer>>|null} */
let pds = null;
/** @type {Awaited<ReturnType<typeof createGitHostServer>>|null} */
let host = null;
/** @type {string} */
let workDir = '';
/** @type {string} */
let srcDir = '';
/** @type {Record<string, string>} */
let gitEnv = {};

/**
 * @param {string} cwd
 * @param {string[]} args
 * @param {Record<string, string>} [env]
 * @returns {Promise<string>} stdout
 */
async function runGit(cwd, args, env = {}) {
  const { stdout } = await execFileAsync('git', args, {
    cwd,
    env: { ...gitEnv, ...env },
    encoding: 'utf8',
  });
  return stdout;
}

beforeAll(async () => {
  workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-host-e2e-'));

  // The data store: a stock PDS with its own git hosting switched off, so
  // everything the appview serves is proven to come over the wire.
  pds = await createServer({
    port: PDS_PORT,
    dbPath: join(workDir, 'pds.db'),
    blobsDir: join(workDir, 'blobs'),
    jwtSecret: 'test-secret-for-git-host-e2e',
    hostname: `127.0.0.1:${PDS_PORT}`,
    password: PASSWORD,
    experimental: { git: { external: true } },
  });
  await pds.listen();

  const init = await fetch(`${PDS_BASE}/init?did=${DID}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      did: DID,
      privateKey: randomBytes(32).toString('hex'),
      handle: HANDLE,
      password: PASSWORD,
    }),
  });
  expect(init.ok).toBe(true);

  // PATH shim so the push's atproto:// remote resolves to the helper CLI.
  const binDir = join(workDir, 'bin');
  mkdirSync(binDir);
  const cliPath = join(
    dirname(fileURLToPath(import.meta.url)),
    '..',
    '..',
    'git',
    'src',
    'cli.js',
  );
  const shim = join(binDir, 'git-remote-atproto');
  writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`);
  chmodSync(shim, 0o755);

  gitEnv = {
    .../** @type {Record<string, string>} */ (process.env),
    PATH: `${binDir}:${process.env.PATH}`,
    ATPROTO_GIT_SERVICE: PDS_BASE,
    ATPROTO_GIT_IDENTIFIER: DID,
    ATPROTO_GIT_PASSWORD: PASSWORD,
    ATPROTO_GIT_CHUNK_SIZE: '8192',
    GIT_TERMINAL_PROMPT: '0',
    GIT_AUTHOR_NAME: 'Test',
    GIT_AUTHOR_EMAIL: 'test@example.com',
    GIT_COMMITTER_NAME: 'Test',
    GIT_COMMITTER_EMAIL: 'test@example.com',
  };

  srcDir = join(workDir, 'src-repo');
  mkdirSync(srcDir);
  await runGit(srcDir, ['init', '-b', 'main']);
  writeFileSync(join(srcDir, 'README.md'), '# served by the appview\n');
  writeFileSync(join(srcDir, 'data.bin'), randomBytes(8192));
  await runGit(srcDir, ['add', '.']);
  await runGit(srcDir, ['commit', '-m', 'initial commit']);
  await runGit(srcDir, [
    'remote',
    'add',
    'origin',
    `atproto://${DID}/testrepo`,
  ]);

  // The forge: index in memory, the account reached over the wire.
  const store = createAppViewStore(new Database(':memory:'));
  const account = createRemoteAccount({
    service: PDS_BASE,
    account: DID,
  });
  host = createGitHostServer({
    account,
    store,
    hostname: `127.0.0.1:${HOST_PORT}`,
    onNotice: (message) => console.log(`[git-host] ${message}`),
  });
  await host.listen(HOST_PORT);
}, 60_000);

afterAll(async () => {
  if (host) await host.close();
  if (pds) await pds.close();
  rmSync(workDir, { recursive: true, force: true });
});

describe('git appview e2e', () => {
  it('pushes to the PDS direct and finds nothing served by the PDS itself', async () => {
    await runGit(srcDir, ['push', 'origin', 'main']);

    // The key is minted, so the pushed record is found by the name it
    // carries.
    const listed = await fetch(
      `${PDS_BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=${GIT_REPO_COLLECTION}&limit=100`,
    );
    expect(listed.ok).toBe(true);
    const { records } =
      /** @type {{records: Array<{value: {name: string}}>}} */ (
        await listed.json()
      );
    expect(records.some((row) => row.value.name === 'testrepo')).toBe(true);

    // The PDS runs with git.external: its own methods and /git surface
    // are gone.
    const pdsRoutes = await fetch(`${PDS_BASE}/xrpc/dev.pdsjs.git.listRepos`);
    expect(pdsRoutes.status).toBe(501);
  });

  it('serves the forge routes from the appview', async () => {
    const listing = await fetch(`${HOST_BASE}/xrpc/dev.pdsjs.git.listRepos`);
    expect(listing.ok).toBe(true);
    const body = /** @type {{repos: Array<{name: string}>}} */ (
      await listing.json()
    );
    expect(body.repos.map((repo) => repo.name)).toContain('testrepo');

    const identity = await fetch(`${HOST_BASE}/.well-known/atproto-did`);
    expect((await identity.text()).trim()).toBe(DID);
  });

  it('clones over plain git through the appview', async () => {
    const cloneDir = join(workDir, 'clone');
    await runGit(workDir, [
      'clone',
      `${HOST_BASE}/git/${DID}/testrepo`,
      cloneDir,
    ]);
    const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
    expect(readme).toContain('served by the appview');
    expect((await runGit(cloneDir, ['branch', '--show-current'])).trim()).toBe(
      'main',
    );
  });

  it('sees a second push through the appview fetch', async () => {
    writeFileSync(join(srcDir, 'second.txt'), 'second commit\n');
    await runGit(srcDir, ['add', '.']);
    await runGit(srcDir, ['commit', '-m', 'second']);
    await runGit(srcDir, ['push', 'origin', 'main']);

    const cloneDir = join(workDir, 'clone2');
    await runGit(workDir, [
      'clone',
      `${HOST_BASE}/git/${DID}/testrepo`,
      cloneDir,
    ]);
    expect(
      (await runGit(cloneDir, ['rev-list', '--count', 'HEAD'])).trim(),
    ).toBe('2');
  });

  it('serves raw files with the record cid as a strong validator', async () => {
    const refs = await fetch(
      `${HOST_BASE}/git/${DID}/testrepo/info/refs?service=git-upload-pack`,
    );
    expect(refs.ok).toBe(true);
    const etag = refs.headers.get('etag');
    expect(etag).toBeTruthy();

    const raw = await fetch(
      `${HOST_BASE}/git/${DID}/testrepo/raw/main/README.md`,
    );
    expect(raw.ok).toBe(true);
    expect(await raw.text()).toContain('served by the appview');

    const again = await fetch(
      `${HOST_BASE}/git/${DID}/testrepo/raw/main/README.md`,
      { headers: { 'if-none-match': etag ?? '' } },
    );
    // The raw etag is the record's, so an advertisement's etag does not
    // short it; what matters is the route answers.
    expect([200, 304]).toContain(again.status);
  });
});