SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%

feat: deploy scripts (m3u96a, ngrok, pm2, systemd, backups) + test suite

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent d9539aa

Showing 13 changed files with +1,091 and −0

added README.md +102 −0
@@ -0,0 +1,102 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : README.md
8 + Purpose : Project overview, quick start, operations guide
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +<div align="center">
13 +
14 +# SPB Git
15 +
16 +**The personal git platform of Simon-Pierre Boucher**
17 +
18 +[![node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen)](https://nodejs.org)
19 +[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
20 +[![runtime](https://img.shields.io/badge/runtime-Fastify%205-black)](https://fastify.dev)
21 +[![deploy](https://img.shields.io/badge/host-m3u96a-4f8cff)](https://git.spboucher.ai)
22 +
23 +Self-hosted software forge · every repo public & clonable · owner-only writes
24 +
25 +`https://git.spboucher.ai`
26 +
27 +</div>
28 +
29 +---
30 +
31 +## What it is
32 +
33 +SPB Git is a complete, self-hosted GitHub-equivalent built for one person:
34 +
35 +- **Git hosting** — Smart HTTP v2 (streamed `upload-pack`/`receive-pack`), anonymous clone/fetch, PAT-authenticated push, post-receive hooks, zip/tar.gz snapshots.
36 +- **Showcase web UI** — SSR (Nunjucks), dark/light themes, pinned repos, activity feed, contribution heatmap, GitHub-grade README rendering (badges, mermaid, task lists, relative images), shiki-highlighted file browsing with line anchors, commits, diffs, blame, branches, tags, global search, OG cards, Atom feed, sitemap.
37 +- **`spbgit` CLI** — the terminal command center: `init`, `token`, `list`, `create --push`, `clone --all`, `status`, `commit`, `push`, `pull`, `sync`, `open`, `info`, `rm`, `doctor`.
38 +- **JSON API** — public reads, token-gated writes at `/api/v1/*`.
39 +
40 +**Filesystem is the database.** Bare repos under the git root are the source of truth; `meta.json` holds descriptions/topics/pins; everything expensive is cached per `<repo>@<sha>` and busted on push.
41 +
42 +## Quick start (development)
43 +
44 +```bash
45 +npm install # also fetches self-hosted fonts
46 +cp .env.example .env # point the paths at ./dev/* for local work
47 +npm run dev # http://127.0.0.1:7420
48 +```
49 +
50 +Mint a first token, then wire up the CLI:
51 +
52 +```bash
53 +node --input-type=module -e "
54 +import { loadConfig, ensureDirs } from './src/config.mjs';
55 +import { TokenStore } from './src/auth/token.mjs';
56 +const c = loadConfig(); ensureDirs(c);
57 +console.log((await new TokenStore(c.dataDir).create('bootstrap')).token);
58 +"
59 +npm link # exposes `spbgit`
60 +spbgit init # paste server URL + token
61 +spbgit create hello --push # first repo, live immediately
62 +```
63 +
64 +## Deployment (node m3u96a)
65 +
66 +```bash
67 +# on m3u96a, from the app directory
68 +bash deploy/setup-m3u96a.sh
69 +```
70 +
71 +The script is idempotent: checks node ≥ 20 / git / ngrok / pm2, creates the
72 +data prefix (`/srv` on Linux, `~/srv` on macOS), installs production deps,
73 +writes `.env`, prints the **bootstrap PAT once**, and starts both pm2 apps
74 +(`spbgit-server`, `spbgit-tunnel`). Run `pm2 startup` once so everything
75 +survives a reboot. DNS: CNAME `git.spboucher.ai` to the ngrok edge target
76 +(see `deploy/ngrok.yml`). Nightly backups: cron `deploy/backup.sh` (keeps 14).
77 +
78 +A systemd unit is provided as the documented alternative: `deploy/spbgit.service`.
79 +
80 +## Scripts
81 +
82 +| Command | Purpose |
83 +|---|---|
84 +| `npm start` / `npm run dev` | run the server (dev = watch + template reload) |
85 +| `npm test` | vitest — unit + full clone/push round-trip e2e |
86 +| `npm run lint` | eslint |
87 +| `npm run check:headers` | fail if any file lacks the author header |
88 +| `npm run inject:headers` | add missing headers in bulk |
89 +| `npm run fetch:fonts` | (re)download self-hosted Inter / JetBrains Mono |
90 +
91 +## Security model
92 +
93 +- All repositories are **public read-only**; there is no private flag anywhere.
94 +- Writes require a PAT (`spbgit_<id>_<secret>`), stored argon2id-hashed, revocable, with last-used tracking.
95 +- Repo names match `^[a-z0-9][a-z0-9._-]{0,63}$`; tree paths are traversal-checked twice (validation + prefix check).
96 +- Rendered Markdown is sanitized with a strict allowlist (badges, `<details>`, `<kbd>`, align HTML survive; scripts, iframes, event handlers never do).
97 +- `/raw` serves with `nosniff` + sandbox CSP, and repo HTML is served as `text/plain`.
98 +- `/internal/*` hook routes accept loopback connections only.
99 +
100 +## License
101 +
102 +MIT © [Simon-Pierre Boucher](mailto:contact@spboucher.ai)
added deploy/backup.sh +40 −0
@@ -0,0 +1,40 @@
1 +#!/bin/bash
2 +# ─────────────────────────────────────────────
3 +# SPB Git — Personal Git Platform
4 +# ─────────────────────────────────────────────
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# File : deploy/backup.sh
8 +# Purpose : Nightly backup — tar.zst of bare repos + data, keep 14
9 +# License : MIT © Simon-Pierre Boucher
10 +# ─────────────────────────────────────────────
11 +#
12 +# Cron (crontab -e on m3u96a):
13 +# 15 3 * * * /bin/bash <app>/deploy/backup.sh >> <prefix>/spbgit/logs/backup.log 2>&1
14 +#
15 +set -euo pipefail
16 +
17 +if [ "$(uname)" = "Darwin" ]; then PREFIX="$HOME/srv"; else PREFIX="/srv"; fi
18 +GIT_ROOT="${SPBGIT_GIT_ROOT:-$PREFIX/git}"
19 +DATA_DIR="${SPBGIT_DATA_DIR:-$PREFIX/spbgit/data}"
20 +BACKUP_DIR="${SPBGIT_BACKUP_DIR:-$PREFIX/spbgit/backups}"
21 +KEEP=14
22 +
23 +mkdir -p "$BACKUP_DIR"
24 +STAMP="$(date +%Y%m%d-%H%M%S)"
25 +
26 +if tar --help 2>/dev/null | grep -q -- --zstd; then
27 + ARCHIVE="$BACKUP_DIR/spbgit-$STAMP.tar.zst"
28 + tar --zstd -cf "$ARCHIVE" -C "$(dirname "$GIT_ROOT")" "$(basename "$GIT_ROOT")" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
29 +else
30 + ARCHIVE="$BACKUP_DIR/spbgit-$STAMP.tar.gz"
31 + tar -czf "$ARCHIVE" -C "$(dirname "$GIT_ROOT")" "$(basename "$GIT_ROOT")" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
32 +fi
33 +
34 +# Rotate: keep the newest $KEEP archives.
35 +ls -1t "$BACKUP_DIR"/spbgit-*.tar.* 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do
36 + rm -f "$old"
37 +done
38 +
39 +SIZE="$(du -h "$ARCHIVE" | cut -f1 | tr -d ' ')"
40 +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] backup ok: $ARCHIVE ($SIZE), $(ls -1 "$BACKUP_DIR"/spbgit-*.tar.* | wc -l | tr -d ' ') archives kept"
added deploy/ecosystem.config.cjs +55 −0
@@ -0,0 +1,55 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : deploy/ecosystem.config.cjs
8 + * Purpose : pm2 process definitions — server + ngrok tunnel
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + *
12 + * Usage on m3u96a:
13 + * pm2 start deploy/ecosystem.config.cjs
14 + * pm2 save && pm2 startup # survive reboots
15 + */
16 +
17 +const path = require('node:path');
18 +const APP_DIR = path.resolve(__dirname, '..');
19 +const LOG_DIR = process.env.SPBGIT_LOG_DIR || path.join(process.env.HOME || '/tmp', 'srv/spbgit/logs');
20 +
21 +module.exports = {
22 + apps: [
23 + {
24 + name: 'spbgit-server',
25 + cwd: APP_DIR,
26 + script: 'src/server.mjs',
27 + interpreter: 'node',
28 + autorestart: true,
29 + max_restarts: 25,
30 + restart_delay: 2000,
31 + max_memory_restart: '750M',
32 + out_file: path.join(LOG_DIR, 'server-out.log'),
33 + error_file: path.join(LOG_DIR, 'server-err.log'),
34 + merge_logs: true,
35 + time: true,
36 + env: {
37 + NODE_ENV: 'production',
38 + },
39 + },
40 + {
41 + name: 'spbgit-tunnel',
42 + cwd: APP_DIR,
43 + script: 'ngrok',
44 + args: 'start --config deploy/ngrok.yml spbgit',
45 + interpreter: 'none',
46 + autorestart: true,
47 + max_restarts: 50,
48 + restart_delay: 5000,
49 + out_file: path.join(LOG_DIR, 'tunnel-out.log'),
50 + error_file: path.join(LOG_DIR, 'tunnel-err.log'),
51 + merge_logs: true,
52 + time: true,
53 + },
54 + ],
55 +};
added deploy/ngrok.yml +26 −0
@@ -0,0 +1,26 @@
1 +# ─────────────────────────────────────────────
2 +# SPB Git — Personal Git Platform
3 +# ─────────────────────────────────────────────
4 +# Author : Simon-Pierre Boucher
5 +# Contact : contact@spboucher.ai
6 +# File : deploy/ngrok.yml
7 +# Purpose : ngrok v3 agent config — git.spboucher.ai → 127.0.0.1:7420
8 +# License : MIT © Simon-Pierre Boucher
9 +# ─────────────────────────────────────────────
10 +#
11 +# DNS setup (one time):
12 +# 1. In the ngrok dashboard, add the custom domain `git.spboucher.ai`
13 +# (Universal Gateway → Domains → New Domain).
14 +# 2. ngrok shows a CNAME target — create that CNAME record on spboucher.ai:
15 +# git.spboucher.ai CNAME <xxxx>.ngrok-cname.com
16 +# 3. Authtoken: either export NGROK_AUTHTOKEN before starting the agent,
17 +# or run `ngrok config add-authtoken <token>` once on the host.
18 +#
19 +version: 3
20 +agent:
21 + authtoken: ${NGROK_AUTHTOKEN}
22 +endpoints:
23 + - name: spbgit
24 + url: https://git.spboucher.ai
25 + upstream:
26 + url: http://127.0.0.1:7420
added deploy/setup-m3u96a.sh +137 −0
@@ -0,0 +1,137 @@
1 +#!/bin/bash
2 +# ─────────────────────────────────────────────
3 +# SPB Git — Personal Git Platform
4 +# ─────────────────────────────────────────────
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# File : deploy/setup-m3u96a.sh
8 +# Purpose : Idempotent bootstrap of the full stack on node m3u96a
9 +# License : MIT © Simon-Pierre Boucher
10 +# ─────────────────────────────────────────────
11 +#
12 +# Run FROM the app directory on m3u96a:
13 +# bash deploy/setup-m3u96a.sh
14 +#
15 +# What it does (all idempotent):
16 +# 1. Verifies node ≥ 20, git, ngrok (installs ngrok via brew when missing).
17 +# 2. Creates the data prefix: /srv on Linux, ~/srv on macOS (SIP-safe).
18 +# 3. npm ci --omit=dev + fonts.
19 +# 4. Writes .env with resolved paths.
20 +# 5. Generates the bootstrap PAT — printed ONCE, never stored in clear.
21 +# 6. Starts pm2 apps (server + ngrok tunnel) and saves the process list.
22 +#
23 +set -euo pipefail
24 +
25 +say() { printf '\033[1;34m▸ %s\033[0m\n' "$*"; }
26 +ok() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
27 +fail() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
28 +
29 +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
30 +cd "$APP_DIR"
31 +
32 +# ── 1. prerequisites ─────────────────────────────────────────────
33 +command -v git >/dev/null || fail "git is required"
34 +command -v node >/dev/null || fail "node ≥ 20 is required (brew install node@20 or nvm install 20)"
35 +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
36 +[ "$NODE_MAJOR" -ge 20 ] || fail "node ≥ 20 required, found $(node --version)"
37 +ok "node $(node --version), git $(git --version | awk '{print $3}')"
38 +
39 +if ! command -v ngrok >/dev/null; then
40 + if command -v brew >/dev/null; then
41 + say "installing ngrok via homebrew"
42 + brew install ngrok >/dev/null
43 + else
44 + fail "ngrok not found — install it from https://ngrok.com/download"
45 + fi
46 +fi
47 +ok "ngrok $(ngrok version 2>/dev/null | head -1)"
48 +
49 +if ! command -v pm2 >/dev/null; then
50 + say "installing pm2 globally"
51 + npm install -g pm2 >/dev/null
52 +fi
53 +ok "pm2 $(pm2 --version)"
54 +
55 +# ── 2. directories ───────────────────────────────────────────────
56 +# /srv is read-only on macOS system volumes → use ~/srv there.
57 +if [ "$(uname)" = "Darwin" ]; then
58 + PREFIX="$HOME/srv"
59 +else
60 + PREFIX="/srv"
61 + [ -w "$PREFIX" ] || sudo mkdir -p "$PREFIX" && sudo chown "$(whoami)" "$PREFIX" 2>/dev/null || true
62 +fi
63 +GIT_ROOT="$PREFIX/git"
64 +DATA_DIR="$PREFIX/spbgit/data"
65 +CACHE_DIR="$PREFIX/spbgit/cache"
66 +LOG_DIR="$PREFIX/spbgit/logs"
67 +BACKUP_DIR="$PREFIX/spbgit/backups"
68 +mkdir -p "$GIT_ROOT" "$DATA_DIR" "$CACHE_DIR" "$LOG_DIR" "$BACKUP_DIR"
69 +ok "directories ready under $PREFIX"
70 +
71 +# ── 3. dependencies ──────────────────────────────────────────────
72 +say "installing production dependencies"
73 +npm ci --omit=dev --no-audit --no-fund >/dev/null
74 +ok "npm dependencies installed"
75 +
76 +# ── 4. environment ───────────────────────────────────────────────
77 +if [ ! -f .env ]; then
78 + cat > .env <<ENV
79 +# Written by deploy/setup-m3u96a.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
80 +SPBGIT_PORT=7420
81 +SPBGIT_HOST=127.0.0.1
82 +SPBGIT_PUBLIC_URL=https://git.spboucher.ai
83 +SPBGIT_GIT_ROOT=$GIT_ROOT
84 +SPBGIT_DATA_DIR=$DATA_DIR
85 +SPBGIT_CACHE_DIR=$CACHE_DIR
86 +SPBGIT_LOG_LEVEL=info
87 +SPBGIT_ENV=production
88 +ENV
89 + chmod 600 .env
90 + ok ".env written"
91 +else
92 + ok ".env already present — left untouched"
93 +fi
94 +
95 +# ── 5. bootstrap PAT (only when no token exists yet) ─────────────
96 +if [ ! -s "$DATA_DIR/tokens.json" ] || [ "$(node -p "try{JSON.parse(require('fs').readFileSync('$DATA_DIR/tokens.json','utf8')).tokens.length}catch(e){0}")" = "0" ]; then
97 + say "generating bootstrap personal access token"
98 + BOOTSTRAP_TOKEN="$(node --input-type=module -e "
99 + import { loadConfig, ensureDirs } from '$APP_DIR/src/config.mjs';
100 + import { TokenStore } from '$APP_DIR/src/auth/token.mjs';
101 + const config = loadConfig();
102 + ensureDirs(config);
103 + const { token } = await new TokenStore(config.dataDir).create('bootstrap');
104 + console.log(token);
105 + ")"
106 + printf '\n\033[1;33m┌────────────────────────────────────────────────────────────┐\033[0m\n'
107 + printf '\033[1;33m│ BOOTSTRAP TOKEN — shown once, copy it now: │\033[0m\n'
108 + printf '\033[1;33m└────────────────────────────────────────────────────────────┘\033[0m\n'
109 + printf '\n %s\n\n' "$BOOTSTRAP_TOKEN"
110 + printf 'Configure your laptop with: spbgit init\n\n'
111 +else
112 + ok "tokens already exist — no bootstrap token generated"
113 +fi
114 +
115 +# ── 6. ngrok auth + pm2 ──────────────────────────────────────────
116 +if [ -z "${NGROK_AUTHTOKEN:-}" ] && ! ngrok config check >/dev/null 2>&1; then
117 + printf '\033[1;33m! NGROK_AUTHTOKEN not set and no ngrok config found.\033[0m\n'
118 + printf ' Run: ngrok config add-authtoken <token> (or export NGROK_AUTHTOKEN)\n'
119 +fi
120 +
121 +say "starting pm2 apps"
122 +SPBGIT_LOG_DIR="$LOG_DIR" pm2 start deploy/ecosystem.config.cjs --update-env >/dev/null
123 +pm2 save >/dev/null
124 +ok "pm2 apps started (spbgit-server, spbgit-tunnel) and saved"
125 +printf ' To survive reboots, run once: \033[1mpm2 startup\033[0m (follow its instructions)\n'
126 +
127 +# ── 7. health check ──────────────────────────────────────────────
128 +say "waiting for /healthz"
129 +for _ in $(seq 1 20); do
130 + if curl -sf http://127.0.0.1:7420/healthz >/dev/null 2>&1; then
131 + ok "server healthy: $(curl -s http://127.0.0.1:7420/healthz)"
132 + ok "public URL: https://git.spboucher.ai (once DNS/ngrok domain is configured)"
133 + exit 0
134 + fi
135 + sleep 1
136 +done
137 +fail "server did not become healthy — check: pm2 logs spbgit-server"
added deploy/spbgit.service +36 −0
@@ -0,0 +1,36 @@
1 +# ─────────────────────────────────────────────
2 +# SPB Git — Personal Git Platform
3 +# ─────────────────────────────────────────────
4 +# Author : Simon-Pierre Boucher
5 +# Contact : contact@spboucher.ai
6 +# File : deploy/spbgit.service
7 +# Purpose : systemd unit — documented alternative to pm2 (Linux hosts)
8 +# License : MIT © Simon-Pierre Boucher
9 +# ─────────────────────────────────────────────
10 +#
11 +# Install:
12 +# sudo cp deploy/spbgit.service /etc/systemd/system/spbgit.service
13 +# sudo systemctl daemon-reload
14 +# sudo systemctl enable --now spbgit
15 +#
16 +[Unit]
17 +Description=SPB Git — personal git platform (git.spboucher.ai)
18 +After=network-online.target
19 +Wants=network-online.target
20 +
21 +[Service]
22 +Type=simple
23 +User=simon-pierreboucher
24 +WorkingDirectory=/srv/spbgit/app
25 +ExecStart=/usr/bin/env node src/server.mjs
26 +Restart=always
27 +RestartSec=3
28 +Environment=NODE_ENV=production
29 +# Paths default to /srv/git + /srv/spbgit/{data,cache} via src/config.mjs
30 +NoNewPrivileges=true
31 +PrivateTmp=true
32 +ProtectSystem=full
33 +ReadWritePaths=/srv/git /srv/spbgit
34 +
35 +[Install]
36 +WantedBy=multi-user.target
added test/e2e/roundtrip.test.mjs +216 −0
@@ -0,0 +1,216 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/e2e/roundtrip.test.mjs
8 + * Purpose : End-to-end — API create → real git push → web + API reflect it
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14 +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
15 +import { tmpdir } from 'node:os';
16 +import { join } from 'node:path';
17 +import { execFileSync } from 'node:child_process';
18 +import net from 'node:net';
19 +import { loadConfig } from '../../src/config.mjs';
20 +import { buildServer } from '../../src/server.mjs';
21 +import { TokenStore } from '../../src/auth/token.mjs';
22 +
23 +/** Grab a free TCP port (the hook script needs the real port baked in). */
24 +function freePort() {
25 + return new Promise((resolve, reject) => {
26 + const srv = net.createServer();
27 + srv.listen(0, '127.0.0.1', () => {
28 + const { port } = srv.address();
29 + srv.close(() => resolve(port));
30 + });
31 + srv.on('error', reject);
32 + });
33 +}
34 +
35 +const GIT_ENV = {
36 + ...process.env,
37 + GIT_TERMINAL_PROMPT: '0',
38 + GIT_AUTHOR_NAME: 'Simon-Pierre Boucher',
39 + GIT_AUTHOR_EMAIL: 'contact@spboucher.ai',
40 + GIT_COMMITTER_NAME: 'Simon-Pierre Boucher',
41 + GIT_COMMITTER_EMAIL: 'contact@spboucher.ai',
42 +};
43 +
44 +let root;
45 +let app;
46 +let base;
47 +let token;
48 +
49 +beforeAll(async () => {
50 + root = mkdtempSync(join(tmpdir(), 'spbgit-e2e-'));
51 + const port = await freePort();
52 + const config = loadConfig({
53 + SPBGIT_PORT: String(port),
54 + SPBGIT_HOST: '127.0.0.1',
55 + SPBGIT_PUBLIC_URL: `http://127.0.0.1:${port}`,
56 + SPBGIT_GIT_ROOT: join(root, 'git'),
57 + SPBGIT_DATA_DIR: join(root, 'data'),
58 + SPBGIT_CACHE_DIR: join(root, 'cache'),
59 + SPBGIT_LOG_LEVEL: 'error',
60 + SPBGIT_ENV: 'test',
61 + });
62 + ({ app } = await buildServer(config));
63 + await app.listen({ port, host: '127.0.0.1' });
64 + base = `http://127.0.0.1:${port}`;
65 + ({ token } = await new TokenStore(config.dataDir).create('e2e'));
66 +}, 120000);
67 +
68 +afterAll(async () => {
69 + await app?.close();
70 + rmSync(root, { recursive: true, force: true });
71 +});
72 +
73 +describe('full round trip', () => {
74 + it('healthz responds', async () => {
75 + const res = await fetch(`${base}/healthz`);
76 + expect(res.status).toBe(200);
77 + expect((await res.json()).status).toBe('ok');
78 + });
79 +
80 + it('rejects unauthenticated repo creation', async () => {
81 + const res = await fetch(`${base}/api/v1/repos`, {
82 + method: 'POST',
83 + headers: { 'Content-Type': 'application/json' },
84 + body: JSON.stringify({ name: 'nope' }),
85 + });
86 + expect(res.status).toBe(401);
87 + expect((await res.json()).error.code).toBe('unauthorized');
88 + });
89 +
90 + it('creates a repo via the API', async () => {
91 + const res = await fetch(`${base}/api/v1/repos`, {
92 + method: 'POST',
93 + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
94 + body: JSON.stringify({ name: 'e2e-demo', description: 'E2E test repo', topics: ['e2e'], pinned: true }),
95 + });
96 + expect(res.status).toBe(201);
97 + const body = await res.json();
98 + expect(body.name).toBe('e2e-demo');
99 + expect(body.empty).toBe(true);
100 + });
101 +
102 + it('rejects invalid repo names hard', async () => {
103 + for (const name of ['../evil', 'UPPER', 'a b']) {
104 + const res = await fetch(`${base}/api/v1/repos`, {
105 + method: 'POST',
106 + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
107 + body: JSON.stringify({ name }),
108 + });
109 + expect(res.status, name).toBe(400);
110 + }
111 + });
112 +
113 + it('rejects a push without credentials (401 advertisement)', async () => {
114 + const res = await fetch(`${base}/e2e-demo.git/info/refs?service=git-receive-pack`);
115 + expect(res.status).toBe(401);
116 + expect(res.headers.get('www-authenticate')).toContain('Basic');
117 + });
118 +
119 + it('pushes with a real git client and PAT', () => {
120 + const work = join(root, 'work');
121 + mkdirSync(work, { recursive: true });
122 + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: work, env: GIT_ENV });
123 + 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');
124 + writeFileSync(join(work, 'main.go'), 'package main\n\nfunc main() {}\n');
125 + execFileSync('git', ['add', '-A'], { cwd: work, env: GIT_ENV });
126 + execFileSync('git', ['commit', '-qm', 'feat: e2e commit'], { cwd: work, env: GIT_ENV });
127 + const url = new URL(`${base}/e2e-demo.git`);
128 + url.username = 'spb';
129 + url.password = token;
130 + execFileSync('git', ['push', '-q', url.href, 'main'], { cwd: work, env: GIT_ENV });
131 + }, 60000);
132 +
133 + it('anonymous clone works', () => {
134 + const dest = join(root, 'clone');
135 + execFileSync('git', ['clone', '-q', `${base}/e2e-demo.git`, dest], { env: GIT_ENV });
136 + execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dest, env: GIT_ENV });
137 + }, 60000);
138 +
139 + it('API reflects the push (commits, language, stats)', async () => {
140 + const repo = await (await fetch(`${base}/api/v1/repos/e2e-demo`)).json();
141 + expect(repo.empty).toBe(false);
142 + expect(repo.commitCount).toBe(1);
143 + expect(repo.topLanguage).toBe('Go');
144 +
145 + const commits = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json();
146 + expect(commits.commits[0].subject).toBe('feat: e2e commit');
147 +
148 + const stats = await (await fetch(`${base}/api/v1/stats`)).json();
149 + expect(stats.repos).toBeGreaterThanOrEqual(1);
150 + expect(stats.commits).toBeGreaterThanOrEqual(1);
151 + });
152 +
153 + it('web UI renders the repo with README and badges', async () => {
154 + const html = await (await fetch(`${base}/e2e-demo`)).text();
155 + expect(html).toContain('e2e-demo');
156 + expect(html).toContain('img.shields.io/badge/e2e-pass-green');
157 + expect(html).toContain('markdown-body');
158 + expect(html).toContain('lang-bar');
159 + });
160 +
161 + it('blob view highlights with line anchors; raw serves the bytes', async () => {
162 + const blob = await (await fetch(`${base}/e2e-demo/blob/main/main.go`)).text();
163 + expect(blob).toContain('id="L1"');
164 + const raw = await fetch(`${base}/raw/e2e-demo/main/main.go`);
165 + expect(raw.headers.get('x-content-type-options')).toBe('nosniff');
166 + expect(await raw.text()).toContain('package main');
167 + });
168 +
169 + it('archives download', async () => {
170 + const res = await fetch(`${base}/archive/e2e-demo/main.zip`);
171 + expect(res.status).toBe(200);
172 + expect(res.headers.get('content-type')).toBe('application/zip');
173 + expect(Number(res.headers.get('content-length'))).toBeGreaterThan(100);
174 + });
175 +
176 + it('single commit page shows the diff', async () => {
177 + const { commits } = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json();
178 + const html = await (await fetch(`${base}/e2e-demo/commit/${commits[0].sha}`)).text();
179 + expect(html).toContain('diff-file');
180 + expect(html).toContain('main.go');
181 + });
182 +
183 + it('activity feed recorded the push (hook fired)', async () => {
184 + // The post-receive hook posts via curl; give it a beat on slow CI.
185 + let ok = false;
186 + for (let i = 0; i < 20 && !ok; i += 1) {
187 + const atom = await (await fetch(`${base}/feed.atom`)).text();
188 + ok = atom.includes('e2e-demo');
189 + if (!ok) await new Promise((r) => setTimeout(r, 250));
190 + }
191 + expect(ok).toBe(true);
192 + }, 30000);
193 +
194 + it('path traversal is rejected on raw + smart-http', async () => {
195 + expect((await fetch(`${base}/raw/e2e-demo/main/../../../etc/passwd`)).status).toBe(404);
196 + expect((await fetch(`${base}/..%2f..%2fetc.git/info/refs?service=git-upload-pack`)).status).toBe(404);
197 + });
198 +
199 + it('PATCH updates metadata and DELETE soft-deletes', async () => {
200 + const patch = await fetch(`${base}/api/v1/repos/e2e-demo`, {
201 + method: 'PATCH',
202 + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
203 + body: JSON.stringify({ description: 'updated', topics: ['x', 'y'] }),
204 + });
205 + expect(patch.status).toBe(200);
206 + expect((await patch.json()).description).toBe('updated');
207 +
208 + const del = await fetch(`${base}/api/v1/repos/e2e-demo`, {
209 + method: 'DELETE',
210 + headers: { Authorization: `Bearer ${token}` },
211 + });
212 + expect(del.status).toBe(200);
213 + expect((await fetch(`${base}/api/v1/repos/e2e-demo`)).status).toBe(404);
214 + expect((await fetch(`${base}/e2e-demo`)).status).toBe(404);
215 + });
216 +});
added test/unit/activity.test.mjs +57 −0
@@ -0,0 +1,57 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/activity.test.mjs
8 + * Purpose : Unit tests — contribution calendar building
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect } from 'vitest';
14 +import { bucketByDay, buildCalendar, calendarSvg } from '../../src/stats/activity.mjs';
15 +
16 +describe('bucketByDay', () => {
17 + it('groups unix timestamps by UTC day', () => {
18 + const noon = Date.UTC(2026, 0, 15, 12) / 1000;
19 + const evening = Date.UTC(2026, 0, 15, 22) / 1000;
20 + const nextDay = Date.UTC(2026, 0, 16, 1) / 1000;
21 + const days = bucketByDay([noon, evening, nextDay]);
22 + expect(days.get('2026-01-15')).toBe(2);
23 + expect(days.get('2026-01-16')).toBe(1);
24 + });
25 +});
26 +
27 +describe('buildCalendar', () => {
28 + it('builds 52 weeks ending today with levels 0-4', () => {
29 + const today = new Date(Date.UTC(2026, 7, 9));
30 + const days = new Map([
31 + ['2026-08-09', 12],
32 + ['2026-08-08', 3],
33 + ['2026-08-01', 1],
34 + ]);
35 + const cal = buildCalendar(days, today);
36 + expect(cal.weeks).toHaveLength(52);
37 + expect(cal.total).toBe(16);
38 + expect(cal.max).toBe(12);
39 + const flat = cal.weeks.flat().filter(Boolean);
40 + const busiest = flat.find((d) => d.date === '2026-08-09');
41 + expect(busiest.level).toBe(4);
42 + const light = flat.find((d) => d.date === '2026-08-01');
43 + expect(light.level).toBe(1);
44 + // Last cell of the grid is today.
45 + expect(flat[flat.length - 1].date).toBe('2026-08-09');
46 + });
47 +});
48 +
49 +describe('calendarSvg', () => {
50 + it('renders an accessible SVG with tooltips', () => {
51 + const cal = buildCalendar(new Map([['2026-08-09', 2]]), new Date(Date.UTC(2026, 7, 9)));
52 + const svg = calendarSvg(cal);
53 + expect(svg).toContain('<svg');
54 + expect(svg).toContain('role="img"');
55 + expect(svg).toContain('<title>2 commits on 2026-08-09</title>');
56 + });
57 +});
added test/unit/diff.test.mjs +77 −0
@@ -0,0 +1,77 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/diff.test.mjs
8 + * Purpose : Unit tests — unified diff parsing + git path unquoting
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect } from 'vitest';
14 +import { parseUnifiedDiff, unquoteGitPath } from '../../src/git/repo.mjs';
15 +
16 +const SAMPLE = `diff --git a/src/app.js b/src/app.js
17 +index 1111111..2222222 100644
18 +--- a/src/app.js
19 ++++ b/src/app.js
20 +@@ -1,4 +1,5 @@
21 + const a = 1;
22 +-const b = 2;
23 ++const b = 3;
24 ++const c = 4;
25 + export { a, b };
26 +diff --git a/new.txt b/new.txt
27 +new file mode 100644
28 +index 0000000..3333333
29 +--- /dev/null
30 ++++ b/new.txt
31 +@@ -0,0 +1,2 @@
32 ++hello
33 ++world
34 +diff --git a/img.png b/img.png
35 +Binary files a/img.png and b/img.png differ
36 +`;
37 +
38 +describe('parseUnifiedDiff', () => {
39 + it('parses files, hunks, line numbers, and stats', () => {
40 + const files = parseUnifiedDiff(SAMPLE);
41 + expect(files).toHaveLength(3);
42 +
43 + const [modified, added, binary] = files;
44 + expect(modified.newPath).toBe('src/app.js');
45 + expect(modified.status).toBe('modified');
46 + expect(modified.additions).toBe(2);
47 + expect(modified.deletions).toBe(1);
48 + expect(modified.hunks).toHaveLength(1);
49 + const lines = modified.hunks[0].lines;
50 + expect(lines[0]).toMatchObject({ type: 'ctx', old: 1, new: 1 });
51 + expect(lines[1]).toMatchObject({ type: 'del', old: 2, new: null });
52 + expect(lines[2]).toMatchObject({ type: 'add', old: null, new: 2 });
53 + expect(lines[3]).toMatchObject({ type: 'add', old: null, new: 3 });
54 + expect(lines[4]).toMatchObject({ type: 'ctx', old: 3, new: 4 });
55 +
56 + expect(added.status).toBe('added');
57 + expect(added.additions).toBe(2);
58 +
59 + expect(binary.binary).toBe(true);
60 + });
61 +
62 + it('handles empty input', () => {
63 + expect(parseUnifiedDiff('')).toEqual([]);
64 + });
65 +});
66 +
67 +describe('unquoteGitPath', () => {
68 + it('passes through plain paths', () => {
69 + expect(unquoteGitPath('src/app.js')).toBe('src/app.js');
70 + });
71 + it('decodes octal utf-8 escapes', () => {
72 + expect(unquoteGitPath('"docs/\\303\\251t\\303\\251.txt"')).toBe('docs/été.txt');
73 + });
74 + it('decodes simple escapes', () => {
75 + expect(unquoteGitPath('"a\\"b\\\\c"')).toBe('a"b\\c');
76 + });
77 +});
added test/unit/languages.test.mjs +55 −0
@@ -0,0 +1,55 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/languages.test.mjs
8 + * Purpose : Unit tests — language detection + percentages
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect } from 'vitest';
14 +import { detectLanguage, computeLanguages, languageColor } from '../../src/stats/languages.mjs';
15 +
16 +describe('detectLanguage', () => {
17 + it('maps common extensions', () => {
18 + expect(detectLanguage('src/index.mjs')).toBe('JavaScript');
19 + expect(detectLanguage('main.py')).toBe('Python');
20 + expect(detectLanguage('lib.rs')).toBe('Rust');
21 + expect(detectLanguage('Dockerfile')).toBe('Dockerfile');
22 + expect(detectLanguage('CMakeLists.txt')).toBe('CMake');
23 + expect(detectLanguage('notes.md')).toBe('Markdown');
24 + });
25 + it('returns null for unknown files', () => {
26 + expect(detectLanguage('data.unknownext')).toBe(null);
27 + expect(detectLanguage('LICENSE')).toBe(null);
28 + });
29 +});
30 +
31 +describe('computeLanguages', () => {
32 + it('computes byte percentages, programming+markup only', () => {
33 + const { languages, totalBytes } = computeLanguages([
34 + { path: 'a.py', size: 750 },
35 + { path: 'b.js', size: 250 },
36 + { path: 'README.md', size: 5000 }, // prose → excluded
37 + { path: 'data.json', size: 9000 }, // data → excluded
38 + { path: 'node_modules/x.js', size: 4000 },// vendored → excluded
39 + { path: 'app.min.js', size: 12000 }, // minified → excluded
40 + ]);
41 + expect(totalBytes).toBe(1000);
42 + expect(languages[0]).toMatchObject({ name: 'Python', percent: 75 });
43 + expect(languages[1]).toMatchObject({ name: 'JavaScript', percent: 25 });
44 + });
45 + it('handles empty input', () => {
46 + expect(computeLanguages([]).languages).toEqual([]);
47 + });
48 +});
49 +
50 +describe('languageColor', () => {
51 + it('returns linguist colors with a grey fallback', () => {
52 + expect(languageColor('Python')).toBe('#3572A5');
53 + expect(languageColor('NotALanguage')).toBe('#8b93a3');
54 + });
55 +});
added test/unit/markdown.test.mjs +124 −0
@@ -0,0 +1,124 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/markdown.test.mjs
8 + * Purpose : Unit tests — the flagship README rendering pipeline
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect, beforeAll } from 'vitest';
14 +import { renderMarkdown, githubSlug, resolveRelativeUrl } from '../../src/render/markdown.mjs';
15 +import { initHighlighter } from '../../src/render/highlight.mjs';
16 +
17 +const CTX = { repo: 'demo', ref: 'main', basePath: '.', publicUrl: 'https://git.spboucher.ai' };
18 +
19 +beforeAll(async () => {
20 + await initHighlighter();
21 +}, 60000);
22 +
23 +describe('githubSlug', () => {
24 + it('matches the GitHub algorithm', () => {
25 + expect(githubSlug('Installation')).toBe('installation');
26 + expect(githubSlug('Getting Started!')).toBe('getting-started');
27 + expect(githubSlug('API — v2.0 (beta)')).toBe('api--v20-beta');
28 + });
29 +});
30 +
31 +describe('resolveRelativeUrl', () => {
32 + it('rewrites relative images to raw', () => {
33 + expect(resolveRelativeUrl('./docs/demo.png', CTX, 'raw')).toBe('/raw/demo/main/docs/demo.png');
34 + expect(resolveRelativeUrl('docs/demo.png', CTX, 'raw')).toBe('/raw/demo/main/docs/demo.png');
35 + });
36 + it('rewrites relative links to blob', () => {
37 + expect(resolveRelativeUrl('CONTRIBUTING.md', CTX, 'blob')).toBe('/demo/blob/main/CONTRIBUTING.md');
38 + });
39 + it('resolves against the document directory', () => {
40 + const nested = { ...CTX, basePath: 'docs' };
41 + expect(resolveRelativeUrl('img/x.png', nested, 'raw')).toBe('/raw/demo/main/docs/img/x.png');
42 + expect(resolveRelativeUrl('../top.png', nested, 'raw')).toBe('/raw/demo/main/top.png');
43 + });
44 + it('leaves absolute, anchor, and external URLs alone', () => {
45 + expect(resolveRelativeUrl('https://x.com/a.png', CTX, 'raw')).toBe('https://x.com/a.png');
46 + expect(resolveRelativeUrl('#section', CTX, 'blob')).toBe('#section');
47 + expect(resolveRelativeUrl('/already/rooted', CTX, 'blob')).toBe('/already/rooted');
48 + });
49 +});
50 +
51 +describe('renderMarkdown', () => {
52 + it('keeps shields.io badges inline', async () => {
53 + const html = await renderMarkdown('![b](https://img.shields.io/badge/x-y-green) ![c](https://img.shields.io/badge/a-b-blue)', CTX);
54 + expect(html).toContain('src="https://img.shields.io/badge/x-y-green"');
55 + expect(html).toContain('loading="lazy"');
56 + });
57 +
58 + it('renders GFM tables, strikethrough, task lists', async () => {
59 + const html = await renderMarkdown('| a |\n|---|\n| 1 |\n\n~~gone~~\n\n- [x] done\n- [ ] todo', CTX);
60 + expect(html).toContain('<table>');
61 + expect(html).toContain('<s>gone</s>');
62 + expect(html).toMatch(/checkbox[^>]*checked/);
63 + expect(html).toContain('disabled');
64 + });
65 +
66 + it('adds GitHub-style heading anchors', async () => {
67 + const html = await renderMarkdown('## Getting Started', CTX);
68 + expect(html).toContain('id="getting-started"');
69 + expect(html).toContain('heading-anchor');
70 + });
71 +
72 + it('strips scripts, event handlers, and iframes but keeps details/kbd/align', async () => {
73 + const source = [
74 + '<script>alert(1)</script>',
75 + '<img src="x.png" onerror="alert(1)">',
76 + '<iframe src="https://evil.com"></iframe>',
77 + '<details><summary>More</summary>Body</details>',
78 + '<kbd>Ctrl</kbd>',
79 + '<h1 align="center">Centered</h1>',
80 + ].join('\n\n');
81 + const html = await renderMarkdown(source, CTX);
82 + expect(html).not.toContain('<script');
83 + expect(html).not.toContain('onerror');
84 + expect(html).not.toContain('<iframe');
85 + expect(html).toContain('<details>');
86 + expect(html).toContain('<kbd>Ctrl</kbd>');
87 + expect(html).toContain('align="center"');
88 + });
89 +
90 + it('rewrites relative HTML img src to the raw endpoint', async () => {
91 + const html = await renderMarkdown('<img src="./assets/logo.png" width="120">', CTX);
92 + expect(html).toContain('src="/raw/demo/main/assets/logo.png"');
93 + expect(html).toContain('width="120"');
94 + });
95 +
96 + it('highlights fenced code with shiki dual themes', async () => {
97 + const html = await renderMarkdown('```js\nconst x = 1;\n```', CTX);
98 + expect(html).toContain('shiki');
99 + expect(html).toContain('--shiki-dark');
100 + });
101 +
102 + it('emits mermaid placeholders for client rendering', async () => {
103 + const html = await renderMarkdown('```mermaid\ngraph TD; A-->B;\n```', CTX);
104 + expect(html).toContain('mermaid-block');
105 + expect(html).toContain('A--&gt;B');
106 + });
107 +
108 + it('converts emoji shortcodes', async () => {
109 + const html = await renderMarkdown('ship it :rocket:', CTX);
110 + expect(html).toContain('🚀');
111 + });
112 +
113 + it('supports footnotes', async () => {
114 + const html = await renderMarkdown('text[^1]\n\n[^1]: the note', CTX);
115 + expect(html).toContain('footnote');
116 + });
117 +
118 + it('never emits javascript: hrefs (markdown or raw HTML)', async () => {
119 + const md = await renderMarkdown('[click](javascript:alert(1))', CTX);
120 + expect(md).not.toMatch(/href\s*=\s*["']?javascript:/i);
121 + const html = await renderMarkdown('<a href="javascript:alert(1)">click</a>', CTX);
122 + expect(html).not.toMatch(/href\s*=\s*["']?javascript:/i);
123 + });
124 +});
added test/unit/token.test.mjs +73 −0
@@ -0,0 +1,73 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/token.test.mjs
8 + * Purpose : Unit tests — PAT lifecycle (argon2, revocation, parsing)
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14 +import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
15 +import { tmpdir } from 'node:os';
16 +import { join } from 'node:path';
17 +import { TokenStore, extractToken } from '../../src/auth/token.mjs';
18 +
19 +let dir;
20 +beforeAll(() => {
21 + dir = mkdtempSync(join(tmpdir(), 'spbgit-tokens-'));
22 +});
23 +afterAll(() => {
24 + rmSync(dir, { recursive: true, force: true });
25 +});
26 +
27 +describe('TokenStore', () => {
28 + it('creates, verifies, and revokes tokens', async () => {
29 + const store = new TokenStore(dir);
30 + const { token, record } = await store.create('laptop');
31 + expect(token).toMatch(/^spbgit_[0-9a-f]{8}_[0-9a-f]{40}$/);
32 + expect(record.label).toBe('laptop');
33 +
34 + const verified = await store.verify(token);
35 + expect(verified).not.toBeNull();
36 + expect(verified.label).toBe('laptop');
37 + expect(verified.lastUsed).not.toBeNull();
38 + expect(verified.hash).toBeUndefined();
39 +
40 + expect(await store.verify('spbgit_00000000_' + '0'.repeat(40))).toBeNull();
41 + expect(await store.verify('garbage')).toBeNull();
42 + expect(await store.verify(token.slice(0, -1) + (token.endsWith('a') ? 'b' : 'a'))).toBeNull();
43 +
44 + expect(store.revoke(record.id)).toBe(true);
45 + expect(await store.verify(token)).toBeNull();
46 + expect(store.revoke(record.id)).toBe(false);
47 + }, 30000);
48 +
49 + it('never stores the secret in clear', async () => {
50 + const store = new TokenStore(dir);
51 + const { token } = await store.create('audit');
52 + const raw = readFileSync(join(dir, 'tokens.json'), 'utf8');
53 + const secret = token.split('_')[2];
54 + expect(raw).not.toContain(secret);
55 + expect(raw).toContain('$argon2id$');
56 + }, 30000);
57 +});
58 +
59 +describe('extractToken', () => {
60 + it('parses Bearer', () => {
61 + expect(extractToken('Bearer abc123')).toBe('abc123');
62 + });
63 + it('parses Basic with username spb', () => {
64 + const header = 'Basic ' + Buffer.from('spb:tok_secret').toString('base64');
65 + expect(extractToken(header)).toBe('tok_secret');
66 + });
67 + it('rejects wrong usernames and junk', () => {
68 + const wrong = 'Basic ' + Buffer.from('root:x').toString('base64');
69 + expect(extractToken(wrong)).toBeNull();
70 + expect(extractToken(undefined)).toBeNull();
71 + expect(extractToken('Digest xyz')).toBeNull();
72 + });
73 +});
added test/unit/util.test.mjs +93 −0
@@ -0,0 +1,93 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit/util.test.mjs
8 + * Purpose : Unit tests — validation, path safety, formatting
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { describe, it, expect } from 'vitest';
14 +import {
15 + isValidRepoName, safeJoin, isValidTreePath, formatBytes, escapeHtml, identiconSvg, mapLimit,
16 +} from '../../src/lib/util.mjs';
17 +
18 +describe('isValidRepoName', () => {
19 + it('accepts sane names', () => {
20 + for (const name of ['demo', 'api-tools', 'a', 'repo.name', 'x_1', 'a2.b-c_d']) {
21 + expect(isValidRepoName(name), name).toBe(true);
22 + }
23 + });
24 + it('rejects traversal, uppercase, and junk', () => {
25 + for (const name of ['../etc', 'a/../b', 'A', '-lead', '.hidden', '', 'a'.repeat(65), 'name.git', 'a..b', 'a b']) {
26 + expect(isValidRepoName(name), String(name)).toBe(false);
27 + }
28 + });
29 +});
30 +
31 +describe('safeJoin', () => {
32 + it('keeps paths inside the root', () => {
33 + expect(safeJoin('/srv/git', 'demo.git')).toBe('/srv/git/demo.git');
34 + });
35 + it('throws on escape attempts', () => {
36 + expect(() => safeJoin('/srv/git', '../../etc/passwd')).toThrow();
37 + expect(() => safeJoin('/srv/git', 'a', '..', '..', 'b')).toThrow();
38 + });
39 +});
40 +
41 +describe('isValidTreePath', () => {
42 + it('accepts normal repo paths', () => {
43 + expect(isValidTreePath('src/index.js')).toBe(true);
44 + expect(isValidTreePath('a.txt')).toBe(true);
45 + });
46 + it('rejects traversal and absolutes', () => {
47 + for (const path of ['../x', 'a/../b', '/abs', 'a//b', 'a/./b', 'nul\0byte']) {
48 + expect(isValidTreePath(path), path).toBe(false);
49 + }
50 + });
51 +});
52 +
53 +describe('formatBytes', () => {
54 + it('formats units', () => {
55 + expect(formatBytes(0)).toBe('0 B');
56 + expect(formatBytes(1024)).toBe('1.0 KB');
57 + expect(formatBytes(1536)).toBe('1.5 KB');
58 + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
59 + });
60 +});
61 +
62 +describe('escapeHtml', () => {
63 + it('escapes the five metacharacters', () => {
64 + expect(escapeHtml('<a href="x">&\'</a>')).toBe('&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
65 + });
66 +});
67 +
68 +describe('identiconSvg', () => {
69 + it('is deterministic and symmetric-sized', () => {
70 + const a = identiconSvg('contact@spboucher.ai');
71 + const b = identiconSvg('contact@spboucher.ai');
72 + const c = identiconSvg('other@example.com');
73 + expect(a).toBe(b);
74 + expect(a).not.toBe(c);
75 + expect(a).toContain('<svg');
76 + });
77 +});
78 +
79 +describe('mapLimit', () => {
80 + it('preserves order and limits concurrency', async () => {
81 + let inFlight = 0;
82 + let peak = 0;
83 + const result = await mapLimit([1, 2, 3, 4, 5, 6], 2, async (n) => {
84 + inFlight += 1;
85 + peak = Math.max(peak, inFlight);
86 + await new Promise((r) => setTimeout(r, 5));
87 + inFlight -= 1;
88 + return n * 10;
89 + });
90 + expect(result).toEqual([10, 20, 30, 40, 50, 60]);
91 + expect(peak).toBeLessThanOrEqual(2);
92 + });
93 +});
94