SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
11.3 KB · 280 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : test/e2e/roundtrip.test.mjs8 *  Purpose : End-to-end — API create → real git push → web + API reflect it9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { describe, it, expect, beforeAll, afterAll } from 'vitest';14import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';15import { tmpdir } from 'node:os';16import { join } from 'node:path';17import { execFile } from 'node:child_process';18import { promisify } from 'node:util';19import net from 'node:net';2021// The server runs inside this very process — git must be spawned async or22// the event loop blocks and the push deadlocks against our own server.23const execFileAsync = promisify(execFile);24import { loadConfig } from '../../src/config.mjs';25import { buildServer } from '../../src/server.mjs';26import { TokenStore } from '../../src/auth/token.mjs';2728/** Grab a free TCP port (the hook script needs the real port baked in). */29function freePort() {30  return new Promise((resolve, reject) => {31    const srv = net.createServer();32    srv.listen(0, '127.0.0.1', () => {33      const { port } = srv.address();34      srv.close(() => resolve(port));35    });36    srv.on('error', reject);37  });38}3940const GIT_ENV = {41  ...process.env,42  GIT_TERMINAL_PROMPT: '0',43  GIT_AUTHOR_NAME: 'Simon-Pierre Boucher',44  GIT_AUTHOR_EMAIL: 'contact@spboucher.ai',45  GIT_COMMITTER_NAME: 'Simon-Pierre Boucher',46  GIT_COMMITTER_EMAIL: 'contact@spboucher.ai',47};4849let root;50let app;51let base;52let token;5354beforeAll(async () => {55  root = mkdtempSync(join(tmpdir(), 'spbgit-e2e-'));56  const port = await freePort();57  const config = loadConfig({58    SPBGIT_PORT: String(port),59    SPBGIT_HOST: '127.0.0.1',60    SPBGIT_PUBLIC_URL: `http://127.0.0.1:${port}`,61    SPBGIT_GIT_ROOT: join(root, 'git'),62    SPBGIT_DATA_DIR: join(root, 'data'),63    SPBGIT_CACHE_DIR: join(root, 'cache'),64    SPBGIT_LOG_LEVEL: 'error',65    SPBGIT_ENV: 'test',66  });67  ({ app } = await buildServer(config));68  await app.listen({ port, host: '127.0.0.1' });69  base = `http://127.0.0.1:${port}`;70  ({ token } = await new TokenStore(config.dataDir).create('e2e'));71}, 120000);7273afterAll(async () => {74  await app?.close();75  rmSync(root, { recursive: true, force: true });76});7778describe('full round trip', () => {79  it('healthz responds', async () => {80    const res = await fetch(`${base}/healthz`);81    expect(res.status).toBe(200);82    expect((await res.json()).status).toBe('ok');83  });8485  it('rejects unauthenticated repo creation', async () => {86    const res = await fetch(`${base}/api/v1/repos`, {87      method: 'POST',88      headers: { 'Content-Type': 'application/json' },89      body: JSON.stringify({ name: 'nope' }),90    });91    expect(res.status).toBe(401);92    expect((await res.json()).error.code).toBe('unauthorized');93  });9495  it('creates a repo via the API', async () => {96    const res = await fetch(`${base}/api/v1/repos`, {97      method: 'POST',98      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },99      body: JSON.stringify({ name: 'e2e-demo', description: 'E2E test repo', topics: ['e2e'], pinned: true }),100    });101    expect(res.status).toBe(201);102    const body = await res.json();103    expect(body.name).toBe('e2e-demo');104    expect(body.empty).toBe(true);105  });106107  it('rejects invalid repo names hard', async () => {108    for (const name of ['../evil', 'UPPER', 'a b']) {109      const res = await fetch(`${base}/api/v1/repos`, {110        method: 'POST',111        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },112        body: JSON.stringify({ name }),113      });114      expect(res.status, name).toBe(400);115    }116  });117118  it('rejects a push without credentials (401 advertisement)', async () => {119    const res = await fetch(`${base}/e2e-demo.git/info/refs?service=git-receive-pack`);120    expect(res.status).toBe(401);121    expect(res.headers.get('www-authenticate')).toContain('Basic');122  });123124  it('pushes with a real git client and PAT', async () => {125    const work = join(root, 'work');126    mkdirSync(work, { recursive: true });127    await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: work, env: GIT_ENV });128    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');129    writeFileSync(join(work, 'main.go'), 'package main\n\nfunc main() {}\n');130    await execFileAsync('git', ['add', '-A'], { cwd: work, env: GIT_ENV });131    await execFileAsync('git', ['commit', '-qm', 'feat: e2e commit'], { cwd: work, env: GIT_ENV });132    const url = new URL(`${base}/e2e-demo.git`);133    url.username = 'spb';134    url.password = token;135    await execFileAsync('git', ['push', '-q', url.href, 'main'], { cwd: work, env: GIT_ENV });136  }, 60000);137138  it('anonymous clone works', async () => {139    const dest = join(root, 'clone');140    await execFileAsync('git', ['clone', '-q', `${base}/e2e-demo.git`, dest], { env: GIT_ENV });141    await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: dest, env: GIT_ENV });142  }, 60000);143144  it('API reflects the push (commits, language, stats)', async () => {145    const repo = await (await fetch(`${base}/api/v1/repos/e2e-demo`)).json();146    expect(repo.empty).toBe(false);147    expect(repo.commitCount).toBe(1);148    expect(repo.topLanguage).toBe('Go');149150    const commits = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json();151    expect(commits.commits[0].subject).toBe('feat: e2e commit');152153    const stats = await (await fetch(`${base}/api/v1/stats`)).json();154    expect(stats.repos).toBeGreaterThanOrEqual(1);155    expect(stats.commits).toBeGreaterThanOrEqual(1);156  });157158  it('web UI renders the repo with README and badges', async () => {159    const html = await (await fetch(`${base}/e2e-demo`)).text();160    expect(html).toContain('e2e-demo');161    expect(html).toContain('img.shields.io/badge/e2e-pass-green');162    expect(html).toContain('markdown-body');163    expect(html).toContain('lang-bar');164  });165166  it('blob view highlights with line anchors; raw serves the bytes', async () => {167    const blob = await (await fetch(`${base}/e2e-demo/blob/main/main.go`)).text();168    expect(blob).toContain('id="L1"');169    const raw = await fetch(`${base}/raw/e2e-demo/main/main.go`);170    expect(raw.headers.get('x-content-type-options')).toBe('nosniff');171    expect(await raw.text()).toContain('package main');172  });173174  it('archives download', async () => {175    const res = await fetch(`${base}/archive/e2e-demo/main.zip`);176    expect(res.status).toBe(200);177    expect(res.headers.get('content-type')).toBe('application/zip');178    expect(Number(res.headers.get('content-length'))).toBeGreaterThan(100);179  });180181  it('single commit page shows the diff', async () => {182    const { commits } = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json();183    const html = await (await fetch(`${base}/e2e-demo/commit/${commits[0].sha}`)).text();184    expect(html).toContain('diff-file');185    expect(html).toContain('main.go');186  });187188  it('activity feed recorded the push (hook fired)', async () => {189    // The post-receive hook posts via curl; give it a beat on slow CI.190    let ok = false;191    for (let i = 0; i < 20 && !ok; i += 1) {192      const atom = await (await fetch(`${base}/feed.atom`)).text();193      ok = atom.includes('e2e-demo');194      if (!ok) await new Promise((r) => setTimeout(r, 250));195    }196    expect(ok).toBe(true);197  }, 30000);198199  it('path traversal is rejected on raw + smart-http', async () => {200    expect((await fetch(`${base}/raw/e2e-demo/main/../../../etc/passwd`)).status).toBe(404);201    expect((await fetch(`${base}/..%2f..%2fetc.git/info/refs?service=git-upload-pack`)).status).toBe(404);202  });203204  it('releases: upload a dmg asset on a tag, download it, checksum matches', async () => {205    const work = join(root, 'work');206    await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: work, env: GIT_ENV });207    const url = new URL(`${base}/e2e-demo.git`);208    url.username = 'spb';209    url.password = token;210    await execFileAsync('git', ['push', '-q', url.href, '--tags'], { cwd: work, env: GIT_ENV });211212    const payload = Buffer.from('fake-dmg-content-'.repeat(1000));213    const upload = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, {214      method: 'PUT',215      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },216      body: payload,217    });218    expect(upload.status).toBe(201);219    const meta = await upload.json();220    expect(meta.size).toBe(payload.length);221    expect(meta.sha256).toMatch(/^[0-9a-f]{64}$/);222223    // Unauthenticated upload is rejected.224    const anon = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/evil.dmg`, {225      method: 'PUT',226      headers: { 'Content-Type': 'application/octet-stream' },227      body: 'x',228    });229    expect(anon.status).toBe(401);230231    // Upload on a missing tag is rejected.232    const badTag = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v9.9.9/assets/x.dmg`, {233      method: 'PUT',234      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },235      body: 'x',236    });237    expect(badTag.status).toBe(400);238239    // Public download streams the exact bytes.240    const download = await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`);241    expect(download.status).toBe(200);242    expect(download.headers.get('content-type')).toBe('application/x-apple-diskimage');243    expect(download.headers.get('x-checksum-sha256')).toBe(meta.sha256);244    expect(Buffer.from(await download.arrayBuffer()).equals(payload)).toBe(true);245246    // Listed via API and shown on the releases page.247    const { releases } = await (await fetch(`${base}/api/v1/repos/e2e-demo/releases`)).json();248    expect(releases[0].tag).toBe('v1.0.0');249    expect(releases[0].assets[0].name).toBe('MyApp.dmg');250    const page = await (await fetch(`${base}/e2e-demo/releases`)).text();251    expect(page).toContain('MyApp.dmg');252253    // Delete works and is auth-gated.254    const del = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, {255      method: 'DELETE',256      headers: { Authorization: `Bearer ${token}` },257    });258    expect(del.status).toBe(200);259    expect((await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`)).status).toBe(404);260  }, 60000);261262  it('PATCH updates metadata and DELETE soft-deletes', async () => {263    const patch = await fetch(`${base}/api/v1/repos/e2e-demo`, {264      method: 'PATCH',265      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },266      body: JSON.stringify({ description: 'updated', topics: ['x', 'y'] }),267    });268    expect(patch.status).toBe(200);269    expect((await patch.json()).description).toBe('updated');270271    const del = await fetch(`${base}/api/v1/repos/e2e-demo`, {272      method: 'DELETE',273      headers: { Authorization: `Bearer ${token}` },274    });275    expect(del.status).toBe(200);276    expect((await fetch(`${base}/api/v1/repos/e2e-demo`)).status).toBe(404);277    expect((await fetch(`${base}/e2e-demo`)).status).toBe(404);278  });279});280