feat: deploy scripts (m3u96a, ngrok, pm2, systemd, backups) + test suite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | +[](https://nodejs.org) | |
| 19 | +[](LICENSE) | |
| 20 | +[](https://fastify.dev) | |
| 21 | +[](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\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(' ', 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-->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('<a href="x">&'</a>'); | |
| 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 | ||