spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
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\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