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