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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
400 lines · 12.9 KB Raw History
/**
 * End-to-end test of the npm registry with the stock npm client: packages
 * are published with `npm publish`, read back with `npm view`, installed
 * with `npm install`, and the tarball integrity survives the round trip.
 */

import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { createServer } from '@pdsjs/node';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const execFileAsync = promisify(execFile);

const PORT = 2486;
const BASE = `http://127.0.0.1:${PORT}`;
const REGISTRY = `${BASE}/npm/`;
const PASSWORD = 'npm-test-password';
const HANDLE = 'npm.test';

/** @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)}`;

/** @type {Awaited<ReturnType<typeof createServer>>|null} */
let server = null;
/** @type {string} */
let workDir = '';
/** @type {string} */
let npmrc = '';

// npm reads `npm_config_*` environment variables as a layer above the file
// `--userconfig` names. `pnpm run` exports npm_config_registry. Without this
// filter every publish goes to registry.npmjs.org and fails with ENEEDAUTH.
const NPM_ENV = Object.fromEntries(
  Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key)),
);

/**
 * @param {string} cwd
 * @param {string[]} args
 */
async function runNpm(cwd, args) {
  const { stdout } = await execFileAsync(
    'npm',
    [...args, '--userconfig', npmrc, '--no-audit', '--no-fund'],
    { cwd, encoding: 'utf8', env: NPM_ENV },
  );
  return stdout;
}

/**
 * @param {string} dir
 * @param {{name: string, version: string, main?: string}} manifest
 * @param {string} body - module source
 */
function writePackage(dir, manifest, body) {
  mkdirSync(dir, { recursive: true });
  writeFileSync(
    join(dir, 'package.json'),
    JSON.stringify({ main: 'index.js', ...manifest }, null, 2),
  );
  writeFileSync(join(dir, 'index.js'), body);
}

beforeAll(async () => {
  workDir = mkdtempSync(join(tmpdir(), 'pdsjs-npm-'));
  server = await createServer({
    port: PORT,
    dbPath: join(workDir, 'pds.db'),
    blobsDir: join(workDir, 'blobs'),
    jwtSecret: 'test-secret-for-npm',
    hostname: `127.0.0.1:${PORT}`,
    password: PASSWORD,
    experimental: { npm: true },
  });
  await server.listen();

  const init = await fetch(`${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);

  // What `npm login` would store: registry plus the token, which for this
  // registry is the password itself.
  npmrc = join(workDir, 'npmrc');
  writeFileSync(
    npmrc,
    [
      `registry=${REGISTRY}`,
      `//127.0.0.1:${PORT}/npm/:_authToken=${PASSWORD}`,
      '',
    ].join('\n'),
  );
}, 60_000);

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

describe('npm registry e2e', () => {
  it('logs in the way npm adduser does', async () => {
    const res = await fetch(`${REGISTRY}-/user/org.couchdb.user:${HANDLE}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: HANDLE, password: PASSWORD }),
    });
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.token).toBe(PASSWORD);

    const bad = await fetch(`${REGISTRY}-/user/org.couchdb.user:${HANDLE}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: HANDLE, password: 'wrong' }),
    });
    expect(bad.status).toBe(401);
  });

  it('answers whoami for the token', async () => {
    const res = await fetch(`${REGISTRY}-/whoami`, {
      headers: { Authorization: `Bearer ${PASSWORD}` },
    });
    expect(res.ok).toBe(true);
    expect((await res.json()).username).toBe(HANDLE);
  });

  it('publishes a package with the npm CLI', async () => {
    const dir = join(workDir, 'demo-pkg');
    writePackage(
      dir,
      { name: 'demo-pkg', version: '1.0.0' },
      "module.exports = 'served from an atproto repo';\n",
    );
    const out = await runNpm(dir, ['publish']);
    expect(out).toContain('demo-pkg@1.0.0');
  });

  it('refuses a republish of the same version', async () => {
    const dir = join(workDir, 'demo-pkg');
    await expect(runNpm(dir, ['publish'])).rejects.toThrow(
      /cannot publish over/i,
    );
  });

  it('answers npm view from the record', async () => {
    const version = await runNpm(workDir, ['view', 'demo-pkg', 'version']);
    expect(version.trim()).toBe('1.0.0');
  });

  it('installs the package with the npm CLI and runs it', async () => {
    const consumer = join(workDir, 'consumer');
    writePackage(
      consumer,
      { name: 'consumer', version: '0.0.0' },
      "console.log(require('demo-pkg'));\n",
    );
    await runNpm(consumer, ['install', 'demo-pkg']);
    const { stdout } = await execFileAsync('node', ['index.js'], {
      cwd: consumer,
      encoding: 'utf8',
    });
    expect(stdout.trim()).toBe('served from an atproto repo');
  });

  it('publishes and installs a scoped package', async () => {
    const dir = join(workDir, 'scoped-pkg');
    writePackage(
      dir,
      { name: '@chad/util', version: '2.0.0' },
      'module.exports = { scoped: true };\n',
    );
    await runNpm(dir, ['publish', '--access', 'public']);

    const consumer = join(workDir, 'scoped-consumer');
    writePackage(
      consumer,
      { name: 'scoped-consumer', version: '0.0.0' },
      "console.log(JSON.stringify(require('@chad/util')));\n",
    );
    await runNpm(consumer, ['install', '@chad/util']);
    const { stdout } = await execFileAsync('node', ['index.js'], {
      cwd: consumer,
      encoding: 'utf8',
    });
    expect(JSON.parse(stdout)).toEqual({ scoped: true });
  });

  it('serves dist-tags and moves them with the npm CLI', async () => {
    const dir = join(workDir, 'demo-pkg');
    writePackage(
      dir,
      { name: 'demo-pkg', version: '1.1.0' },
      "module.exports = 'next';\n",
    );
    await runNpm(dir, ['publish', '--tag', 'next']);

    const tags = await runNpm(workDir, ['dist-tag', 'ls', 'demo-pkg']);
    expect(tags).toContain('latest: 1.0.0');
    expect(tags).toContain('next: 1.1.0');

    await runNpm(workDir, ['dist-tag', 'add', 'demo-pkg@1.1.0', 'stable']);
    const after = await runNpm(workDir, ['dist-tag', 'ls', 'demo-pkg']);
    expect(after).toContain('stable: 1.1.0');
  });

  it('holds every version in the package record', async () => {
    const params = new URLSearchParams({
      repo: DID,
      collection: 'dev.pdsjs.npm.package',
      rkey: 'demo-pkg',
    });
    const res = await fetch(
      `${BASE}/xrpc/com.atproto.repo.getRecord?${params}`,
    );
    expect(res.ok).toBe(true);
    const body = await res.json();
    expect(body.value.name).toBe('demo-pkg');
    // Two versions, each holding a tarball and a manifest blob against
    // orphan cleanup.
    expect(body.value.versions).toHaveLength(2);
    for (const version of body.value.versions) {
      expect(version.integrity).toMatch(/^sha512-/);
      expect(version.tarball.ref.$link).toMatch(/^b/);
      expect(version.manifest.ref.$link).toMatch(/^b/);
    }

    const scoped = new URLSearchParams({
      repo: DID,
      collection: 'dev.pdsjs.npm.package',
      rkey: 'chad:util',
    });
    const scopedRes = await fetch(
      `${BASE}/xrpc/com.atproto.repo.getRecord?${scoped}`,
    );
    expect(scopedRes.ok).toBe(true);
    expect((await scopedRes.json()).value.name).toBe('@chad/util');
  });

  it('serves the packument with a strong ETag', async () => {
    const first = await fetch(`${REGISTRY}demo-pkg`);
    expect(first.ok).toBe(true);
    const etag = first.headers.get('etag');
    expect(etag).toBeTruthy();
    const packument = await first.json();
    expect(Object.keys(packument.versions).sort()).toEqual(['1.0.0', '1.1.0']);
    expect(packument.versions['1.0.0'].dist.tarball).toBe(
      `${BASE}/npm/demo-pkg/-/demo-pkg-1.0.0.tgz`,
    );

    const cached = await fetch(`${REGISTRY}demo-pkg`, {
      headers: { 'If-None-Match': /** @type {string} */ (etag) },
    });
    expect(cached.status).toBe(304);
  });

  it('refuses an unauthenticated publish', async () => {
    const res = await fetch(`${REGISTRY}sneaky`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'sneaky', versions: {} }),
    });
    expect(res.status).toBe(401);
  });

  it('answers 404 for an unknown package', async () => {
    const res = await fetch(`${REGISTRY}absent`);
    expect(res.status).toBe(404);
  });

  it('serves a scoped packument at the encoded URL npm sends', async () => {
    const res = await fetch(`${REGISTRY}@chad%2futil`);
    expect(res.ok).toBe(true);
    const packument = await res.json();
    expect(packument.name).toBe('@chad/util');
    expect(packument.versions['2.0.0'].dist.tarball).toBe(
      `${BASE}/npm/@chad/util/-/util-2.0.0.tgz`,
    );
  });

  it('answers ping and refuses what it does not serve', async () => {
    expect((await fetch(`${REGISTRY}-/ping`)).status).toBe(200);
    expect((await fetch(`${REGISTRY}UPPER`)).status).toBe(400);
    expect((await fetch(`${REGISTRY}-/v1/search?text=x`)).status).toBe(404);
    expect((await fetch(`${REGISTRY}demo-pkg/-/other-1.0.0.tgz`)).status).toBe(
      404,
    );
    expect(
      (await fetch(`${REGISTRY}demo-pkg/-/demo-pkg-9.9.9.tgz`)).status,
    ).toBe(404);
    const unpublish = await fetch(`${REGISTRY}demo-pkg`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${PASSWORD}` },
    });
    expect(unpublish.status).toBe(403);
  });

  it('removes a dist-tag over the wire', async () => {
    const res = await fetch(`${REGISTRY}-/package/demo-pkg/dist-tags/stable`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${PASSWORD}` },
    });
    expect(res.ok).toBe(true);
    const tags = await fetch(`${REGISTRY}-/package/demo-pkg/dist-tags`);
    const body = await tags.json();
    expect(body.stable).toBeUndefined();
    expect(body.latest).toBe('1.0.0');

    const missing = await fetch(
      `${REGISTRY}-/package/demo-pkg/dist-tags/gone`,
      { method: 'DELETE', headers: { Authorization: `Bearer ${PASSWORD}` } },
    );
    expect(missing.status).toBe(404);
  });

  it('summarizes hosted packages for the account page', async () => {
    const signIn = await fetch(`${BASE}/account/sign-in`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ username: DID, password: PASSWORD }),
      redirect: 'manual',
    });
    const cookie = (signIn.headers.get('set-cookie') || '').split(';')[0];
    expect(cookie).toContain('pdsjs_account=');

    const overview = await fetch(`${BASE}/account/api/overview`, {
      headers: { Cookie: cookie },
    });
    const account = await overview.json();
    expect(account.features.npm).toBe(true);
    expect(account.stats.npmPackages).toBeGreaterThanOrEqual(2);

    const res = await fetch(`${BASE}/account/api/npm/packages`, {
      headers: { Cookie: cookie },
    });
    expect(res.ok).toBe(true);
    const body = await res.json();
    expect(body.enabled).toBe(true);
    expect(body.registryUrl).toBe(`${BASE}/npm/`);
    const demo = body.packages.find(
      (/** @type {{name: string}} */ p) => p.name === 'demo-pkg',
    );
    expect(
      demo.versions.map((/** @type {{version: string}} */ v) => v.version),
    ).toEqual(['1.0.0', '1.1.0']);
    expect(demo.distTags).toContainEqual({ tag: 'latest', version: '1.0.0' });
    expect(demo.size).toBeGreaterThan(0);
    const scoped = body.packages.find(
      (/** @type {{name: string}} */ p) => p.name === '@chad/util',
    );
    expect(scoped.versions).toHaveLength(1);

    const anonymous = await fetch(`${BASE}/account/api/npm/packages`);
    expect(anonymous.status).toBe(401);
  });

  it('refuses a publish whose body lies about the tarball', async () => {
    const manifest = {
      name: 'liar',
      version: '1.0.0',
      dist: { integrity: `sha512-${'A'.repeat(86)}==` },
    };
    const res = await fetch(`${REGISTRY}liar`, {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${PASSWORD}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: 'liar',
        versions: { '1.0.0': manifest },
        'dist-tags': { latest: '1.0.0' },
        _attachments: {
          'liar-1.0.0.tgz': {
            data: Buffer.from('not the declared bytes').toString('base64'),
          },
        },
      }),
    });
    expect(res.status).toBe(400);
    expect((await res.json()).error).toMatch(/hashes to/);
  });
});