/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : test/e2e/roundtrip.test.mjs * Purpose : End-to-end — API create → real git push → web + API reflect it * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import net from 'node:net'; // The server runs inside this very process — git must be spawned async or // the event loop blocks and the push deadlocks against our own server. const execFileAsync = promisify(execFile); import { loadConfig } from '../../src/config.mjs'; import { buildServer } from '../../src/server.mjs'; import { TokenStore } from '../../src/auth/token.mjs'; /** Grab a free TCP port (the hook script needs the real port baked in). */ function freePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); srv.on('error', reject); }); } const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 'Simon-Pierre Boucher', GIT_AUTHOR_EMAIL: 'contact@spboucher.ai', GIT_COMMITTER_NAME: 'Simon-Pierre Boucher', GIT_COMMITTER_EMAIL: 'contact@spboucher.ai', }; let root; let app; let base; let token; beforeAll(async () => { root = mkdtempSync(join(tmpdir(), 'spbgit-e2e-')); const port = await freePort(); const config = loadConfig({ SPBGIT_PORT: String(port), SPBGIT_HOST: '127.0.0.1', SPBGIT_PUBLIC_URL: `http://127.0.0.1:${port}`, SPBGIT_GIT_ROOT: join(root, 'git'), SPBGIT_DATA_DIR: join(root, 'data'), SPBGIT_CACHE_DIR: join(root, 'cache'), SPBGIT_LOG_LEVEL: 'error', SPBGIT_ENV: 'test', }); ({ app } = await buildServer(config)); await app.listen({ port, host: '127.0.0.1' }); base = `http://127.0.0.1:${port}`; ({ token } = await new TokenStore(config.dataDir).create('e2e')); }, 120000); afterAll(async () => { await app?.close(); rmSync(root, { recursive: true, force: true }); }); describe('full round trip', () => { it('healthz responds', async () => { const res = await fetch(`${base}/healthz`); expect(res.status).toBe(200); expect((await res.json()).status).toBe('ok'); }); it('rejects unauthenticated repo creation', async () => { const res = await fetch(`${base}/api/v1/repos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'nope' }), }); expect(res.status).toBe(401); expect((await res.json()).error.code).toBe('unauthorized'); }); it('creates a repo via the API', async () => { const res = await fetch(`${base}/api/v1/repos`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: 'e2e-demo', description: 'E2E test repo', topics: ['e2e'], pinned: true }), }); expect(res.status).toBe(201); const body = await res.json(); expect(body.name).toBe('e2e-demo'); expect(body.empty).toBe(true); }); it('rejects invalid repo names hard', async () => { for (const name of ['../evil', 'UPPER', 'a b']) { const res = await fetch(`${base}/api/v1/repos`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name }), }); expect(res.status, name).toBe(400); } }); it('rejects a push without credentials (401 advertisement)', async () => { const res = await fetch(`${base}/e2e-demo.git/info/refs?service=git-receive-pack`); expect(res.status).toBe(401); expect(res.headers.get('www-authenticate')).toContain('Basic'); }); it('pushes with a real git client and PAT', async () => { const work = join(root, 'work'); mkdirSync(work, { recursive: true }); await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: work, env: GIT_ENV }); writeFileSync(join(work, 'README.md'), '# E2E\n\n![badge](https://img.shields.io/badge/e2e-pass-green)\n\n```js\nconst ok = true;\n```\n'); writeFileSync(join(work, 'main.go'), 'package main\n\nfunc main() {}\n'); await execFileAsync('git', ['add', '-A'], { cwd: work, env: GIT_ENV }); await execFileAsync('git', ['commit', '-qm', 'feat: e2e commit'], { cwd: work, env: GIT_ENV }); const url = new URL(`${base}/e2e-demo.git`); url.username = 'spb'; url.password = token; await execFileAsync('git', ['push', '-q', url.href, 'main'], { cwd: work, env: GIT_ENV }); }, 60000); it('anonymous clone works', async () => { const dest = join(root, 'clone'); await execFileAsync('git', ['clone', '-q', `${base}/e2e-demo.git`, dest], { env: GIT_ENV }); await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: dest, env: GIT_ENV }); }, 60000); it('API reflects the push (commits, language, stats)', async () => { const repo = await (await fetch(`${base}/api/v1/repos/e2e-demo`)).json(); expect(repo.empty).toBe(false); expect(repo.commitCount).toBe(1); expect(repo.topLanguage).toBe('Go'); const commits = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json(); expect(commits.commits[0].subject).toBe('feat: e2e commit'); const stats = await (await fetch(`${base}/api/v1/stats`)).json(); expect(stats.repos).toBeGreaterThanOrEqual(1); expect(stats.commits).toBeGreaterThanOrEqual(1); }); it('web UI renders the repo with README and badges', async () => { const html = await (await fetch(`${base}/e2e-demo`)).text(); expect(html).toContain('e2e-demo'); expect(html).toContain('img.shields.io/badge/e2e-pass-green'); expect(html).toContain('markdown-body'); expect(html).toContain('lang-bar'); }); it('blob view highlights with line anchors; raw serves the bytes', async () => { const blob = await (await fetch(`${base}/e2e-demo/blob/main/main.go`)).text(); expect(blob).toContain('id="L1"'); const raw = await fetch(`${base}/raw/e2e-demo/main/main.go`); expect(raw.headers.get('x-content-type-options')).toBe('nosniff'); expect(await raw.text()).toContain('package main'); }); it('archives download', async () => { const res = await fetch(`${base}/archive/e2e-demo/main.zip`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('application/zip'); expect(Number(res.headers.get('content-length'))).toBeGreaterThan(100); }); it('single commit page shows the diff', async () => { const { commits } = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json(); const html = await (await fetch(`${base}/e2e-demo/commit/${commits[0].sha}`)).text(); expect(html).toContain('diff-file'); expect(html).toContain('main.go'); }); it('activity feed recorded the push (hook fired)', async () => { // The post-receive hook posts via curl; give it a beat on slow CI. let ok = false; for (let i = 0; i < 20 && !ok; i += 1) { const atom = await (await fetch(`${base}/feed.atom`)).text(); ok = atom.includes('e2e-demo'); if (!ok) await new Promise((r) => setTimeout(r, 250)); } expect(ok).toBe(true); }, 30000); it('path traversal is rejected on raw + smart-http', async () => { expect((await fetch(`${base}/raw/e2e-demo/main/../../../etc/passwd`)).status).toBe(404); expect((await fetch(`${base}/..%2f..%2fetc.git/info/refs?service=git-upload-pack`)).status).toBe(404); }); it('releases: upload a dmg asset on a tag, download it, checksum matches', async () => { const work = join(root, 'work'); await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: work, env: GIT_ENV }); const url = new URL(`${base}/e2e-demo.git`); url.username = 'spb'; url.password = token; await execFileAsync('git', ['push', '-q', url.href, '--tags'], { cwd: work, env: GIT_ENV }); const payload = Buffer.from('fake-dmg-content-'.repeat(1000)); const upload = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: payload, }); expect(upload.status).toBe(201); const meta = await upload.json(); expect(meta.size).toBe(payload.length); expect(meta.sha256).toMatch(/^[0-9a-f]{64}$/); // Unauthenticated upload is rejected. const anon = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/evil.dmg`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: 'x', }); expect(anon.status).toBe(401); // Upload on a missing tag is rejected. const badTag = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v9.9.9/assets/x.dmg`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: 'x', }); expect(badTag.status).toBe(400); // Public download streams the exact bytes. const download = await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`); expect(download.status).toBe(200); expect(download.headers.get('content-type')).toBe('application/x-apple-diskimage'); expect(download.headers.get('x-checksum-sha256')).toBe(meta.sha256); expect(Buffer.from(await download.arrayBuffer()).equals(payload)).toBe(true); // Listed via API and shown on the releases page. const { releases } = await (await fetch(`${base}/api/v1/repos/e2e-demo/releases`)).json(); expect(releases[0].tag).toBe('v1.0.0'); expect(releases[0].assets[0].name).toBe('MyApp.dmg'); const page = await (await fetch(`${base}/e2e-demo/releases`)).text(); expect(page).toContain('MyApp.dmg'); // Delete works and is auth-gated. const del = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); expect(del.status).toBe(200); expect((await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`)).status).toBe(404); }, 60000); it('PATCH updates metadata and DELETE soft-deletes', async () => { const patch = await fetch(`${base}/api/v1/repos/e2e-demo`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ description: 'updated', topics: ['x', 'y'] }), }); expect(patch.status).toBe(200); expect((await patch.json()).description).toBe('updated'); const del = await fetch(`${base}/api/v1/repos/e2e-demo`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); expect(del.status).toBe(200); expect((await fetch(`${base}/api/v1/repos/e2e-demo`)).status).toBe(404); expect((await fetch(`${base}/e2e-demo`)).status).toBe(404); }); });