/** * 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>|null} */ let pds = null; /** @type {Awaited>|null} */ let host = null; /** @type {string} */ let workDir = ''; /** @type {string} */ let srcDir = ''; /** @type {Record} */ let gitEnv = {}; /** * @param {string} cwd * @param {string[]} args * @param {Record} [env] * @returns {Promise} 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} */ (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); }); });