szabadpalya/scripts/ci.sh

457 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# Local CI pipeline.
#
# This script is the single definition of "does it pass". A hosted CI runner
# later just calls it; nothing here assumes GitHub, Gitea or anything else.
#
# ./scripts/ci.sh full run, including the container smoke test
# ./scripts/ci.sh --fast static checks only (what the pre-push hook runs)
#
# The full run uses its own database (szabadpalya_ci) on the development
# Postgres, so it never touches the data you are working with.
set -uo pipefail
cd "$(dirname "$0")/.."
FAST=0
[ "${1:-}" = "--fast" ] && FAST=1
DB_CONTAINER=szabadpalya-db
DB_USER=szabadpalya
CI_DB=szabadpalya_ci
APP_CONTAINER=szabadpalya-ci-app
APP_PORT=3210
SICK_CONTAINER=szabadpalya-ci-sick
SICK_PORT=3211
IMAGE=szabadpalya:ci
SEED_IMAGE=szabadpalya:ci-seed
SLUG=rakoczi-teri-kozossegi-kert
FAILED=()
START=$(date +%s)
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
pass() { printf ' \033[32m✓\033[0m %s \033[2m(%ss)\033[0m\n' "$1" "$2"; }
fail() { printf ' \033[31m✗\033[0m %s \033[2m(%ss)\033[0m\n' "$1" "$2"; FAILED+=("$1"); }
# step <name> <command...>
step() {
local name="$1"; shift
local t0 t1 out
t0=$(date +%s)
if out=$("$@" 2>&1); then
t1=$(date +%s); pass "$name" "$((t1 - t0))"
else
t1=$(date +%s); fail "$name" "$((t1 - t0))"
printf '%s\n' "$out" | tail -25 | sed 's/^/ /'
fi
}
cleanup() {
docker rm -f "$APP_CONTAINER" "$SICK_CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# ---------------------------------------------------------------- static ----
bold "Static checks — backend"
go_build() { ./scripts/go.sh build ./...; }
go_vet() { ./scripts/go.sh vet ./...; }
go_format() {
local out
out=$(./scripts/go.sh run mvdan.cc/gofumpt@latest -l . 2>/dev/null || ./scripts/go.sh fmt ./...)
[ -z "$out" ] || { echo "not gofmt'd:"; printf '%s\n' "$out"; return 1; }
}
step "go build" go_build
step "go vet" go_vet
step "gofmt" go_format
bold "Static checks — frontend"
web_typecheck() { (cd web && npx tsc --noEmit); }
web_lint() { (cd web && npx eslint .); }
web_build() { (cd web && npx vite build); }
step "typecheck" web_typecheck
step "lint" web_lint
step "vite build" web_build
# Project rule: the product is Hungarian, everything else is English.
# Guard the code side of that so it does not drift back.
check_language() {
local allow='Szabadpálya|rendszerbontó|Támogatók|Beleadom|Ft|hu-HU|Europe/Budapest|Belépés|Admin'
local hits
hits=$(git ls-files '*.ts' '*.tsx' '*.go' '*.css' '*.sql' '*.sh' '*.yaml' 'Dockerfile' \
| xargs grep -nE '^[[:space:]]*(//|#|\*|/\*|///|--)' 2>/dev/null \
| grep -E '[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]' \
| grep -vE "$allow" || true)
[ -z "$hits" ] || { printf '%s\n' "$hits"; return 1; }
}
step "comments are English" check_language
# Things that must never be committed.
check_secrets() {
local bad
bad=$(git ls-files | grep -E '(^|/)\.env$|\.env\.(local|production)$|\.pem$' || true)
[ -z "$bad" ] || { echo "committed secret-ish files:"; printf '%s\n' "$bad"; return 1; }
}
step "no secrets committed" check_secrets
if [ "$FAST" = "1" ]; then
END=$(date +%s)
echo
if [ ${#FAILED[@]} -eq 0 ]; then
bold "fast checks passed in $((END - START))s"; exit 0
else
bold "FAILED: ${FAILED[*]}"; exit 1
fi
fi
# ------------------------------------------------------------- container ----
echo
bold "Container smoke test"
ensure_db() {
docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" >/dev/null 2>&1 || ./scripts/db.sh up >/dev/null
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='${CI_DB}'" | grep -q 1 \
|| docker exec "$DB_CONTAINER" createdb -U "$DB_USER" "$CI_DB"
# A rerun must start from a known schema, not from whatever the last one left.
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
}
step "database ready" ensure_db
build_image() { docker build --target runner -t "$IMAGE" .; }
step "docker image builds" build_image
# The seeder is deliberately not in the shipping image, so CI builds the target
# that carries it. Same base, same layers — only the extra binary differs.
build_seeder() { docker build --target seeder -t "$SEED_IMAGE" .; }
step "seeder image builds" build_seeder
# What must NOT be there: a binary that starts by deleting every campaign has no
# business in the image that runs in production.
check_no_seeder_in_runner() {
if docker run --rm --entrypoint sh "$IMAGE" -c 'command -v seed' >/dev/null 2>&1; then
echo "the seed binary is present in the runner image"; return 1
fi
}
step "runner image ships no seeder" check_no_seeder_in_runner
# The migrator and the seeder run from the image, so CI exercises the same
# binaries production does rather than a host toolchain that may differ.
in_image() {
local image="$1"; shift
docker run --rm --add-host=host.docker.internal:host-gateway \
-e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@host.docker.internal:5433/${CI_DB}" \
-e SESSION_SECRET="ci-only-secret" \
"$image" "$@"
}
migrate_ci() { in_image "$IMAGE" server -migrate; }
seed_ci() { in_image "$SEED_IMAGE" seed; }
step "migrations apply" migrate_ci
step "migrations are idempotent" migrate_ci
step "seed loads" seed_ci
boot_app() {
docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$APP_CONTAINER" -p "${APP_PORT}:3000" \
--add-host=host.docker.internal:host-gateway \
--health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=3 \
-e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@host.docker.internal:5433/${CI_DB}" \
-e SESSION_SECRET="ci-only-secret" \
-e BASE_URL="http://localhost:${APP_PORT}" \
-e ADMIN_EMAILS="admin@example.invalid" \
"$IMAGE" >/dev/null
for _ in $(seq 1 45); do
curl -sf -o /dev/null "http://localhost:${APP_PORT}/" && return 0
sleep 1
done
echo "container never became ready:"; docker logs "$APP_CONTAINER" 2>&1 | tail -20
return 1
}
step "container boots and serves" boot_app
http_ok() {
local path="$1" code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}${path}")
[ "$code" = "200" ] || { echo "$path returned $code"; return 1; }
}
step "GET /" http_ok /
step "GET /kampanyok" http_ok /kampanyok
step "GET campaign page" http_ok "/kampanyok/${SLUG}"
step "GET og image" http_ok "/kampanyok/${SLUG}/og.png"
step "GET /api/campaigns" http_ok /api/campaigns
# The share image must be a real PNG of the right size, not an error page that
# happens to return 200.
check_og_png() {
local head size
head=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png" | head -c 8 | od -An -tx1 | tr -d ' \n')
[ "$head" = "89504e470d0a1a0a" ] || { echo "og.png is not a PNG (magic: $head)"; return 1; }
size=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png" | wc -c)
[ "$size" -gt 10000 ] || { echo "og.png suspiciously small: ${size} bytes"; return 1; }
}
step "og image is a real PNG" check_og_png
# The numbers must come out of the pledge rows, not out of a stored column.
check_derived() {
local json
json=$(curl -s "http://localhost:${APP_PORT}/api/campaigns/${SLUG}")
grep -q '"raised":1356000' <<<"$json" || { echo "derived raised amount wrong (expected 1356000)"; return 1; }
grep -q '"backers":150' <<<"$json" || { echo "derived backer count wrong"; return 1; }
# A tier whose backers reached its inventory limit is what renders "Elfogyott".
python3 - "$json" <<'PY' || return 1
import json, sys
tiers = json.loads(sys.argv[1])["tiers"]
if not any(t.get("inventoryLimit") and t["backers"] >= t["inventoryLimit"] for t in tiers):
print("no sold-out tier in the fixture"); raise SystemExit(1)
PY
}
step "totals derived from pledges" check_derived
# Per-campaign share tags are the reason the server renders HTML at all.
check_og_tags() {
local html
html=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}")
grep -q 'og:title" content="Rákóczi téri közösségi kert' <<<"$html" \
|| { echo "campaign-specific og:title missing"; return 1; }
grep -q "og:image\" content=\"http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png\"" <<<"$html" \
|| { echo "absolute og:image missing"; return 1; }
grep -q '113%' <<<"$html" || { echo "og:description carries no derived progress"; return 1; }
}
step "per-campaign og tags" check_og_tags
# The inlined payload is what saves the first route a round-trip. It must be
# present, parseable, and about the campaign that was asked for.
check_bootstrap() {
curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}" \
| python3 -c '
import re, sys, json
html = sys.stdin.read()
m = re.search(r"<script type=\"application/json\" id=\"__DATA__\">(.*?)</script>", html, re.S)
if not m: print("no __DATA__ payload"); raise SystemExit(1)
payload = json.loads(m.group(1))
assert payload["route"] == "campaign", payload["route"]
assert payload["data"]["slug"] == "'"${SLUG}"'", payload["data"]["slug"]
'
}
step "bootstrap payload inlined" check_bootstrap
# The landing page shows the headline numbers only; the campaigns themselves
# live one click away, on /kampanyok. If the list creeps back into the home
# payload the page is loading rows it does not render.
check_home_has_no_list() {
curl -s "http://localhost:${APP_PORT}/" \
| python3 -c '
import re, sys, json
html = sys.stdin.read()
m = re.search(r"<script type=\"application/json\" id=\"__DATA__\">(.*?)</script>", html, re.S)
if not m: print("no __DATA__ payload"); raise SystemExit(1)
payload = json.loads(m.group(1))
assert payload["route"] == "home", payload["route"]
assert "stats" in payload["data"], "the home payload lost its stats"
assert "campaigns" not in payload["data"], "the home payload still carries the campaign list"
'
}
step "landing page carries no campaign list" check_home_has_no_list
check_health_ok() {
local body code
body=$(curl -s -w '\n%{http_code}' "http://localhost:${APP_PORT}/api/health")
code=$(tail -1 <<<"$body")
[ "$code" = "200" ] || { echo "/api/health returned $code"; return 1; }
grep -q '"database":"ok"' <<<"$body" || { echo "unexpected body: $body"; return 1; }
}
step "health endpoint reports ok" check_health_ok
# Docker must see the container as healthy, not merely running.
check_docker_health() {
local status
for _ in $(seq 1 30); do
status=$(docker inspect --format '{{.State.Health.Status}}' "$APP_CONTAINER" 2>/dev/null)
[ "$status" = "healthy" ] && return 0
[ "$status" = "unhealthy" ] && { echo "container reported unhealthy"; return 1; }
sleep 1
done
echo "health status stuck at: ${status:-<none>}"; return 1
}
step "docker reports container healthy" check_docker_health
# The point of the probe: an app that runs but cannot reach Postgres must not
# pass as healthy. Uses a throwaway container so the real database is untouched.
check_health_degraded() {
docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$SICK_CONTAINER" -p "${SICK_PORT}:3000" \
--health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=2 \
-e DATABASE_URL="postgresql://nobody:nobody@127.0.0.1:5999/nothing" \
-e SESSION_SECRET="ci-only-secret" \
"$IMAGE" >/dev/null
local code=""
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${SICK_PORT}/api/health" || true)
[ -n "$code" ] && [ "$code" != "000" ] && break
sleep 1
done
[ "$code" = "503" ] || { echo "expected 503 with an unreachable database, got ${code:-no response}"; return 1; }
local status
for _ in $(seq 1 30); do
status=$(docker inspect --format '{{.State.Health.Status}}' "$SICK_CONTAINER" 2>/dev/null)
[ "$status" = "unhealthy" ] && { docker rm -f "$SICK_CONTAINER" >/dev/null; return 0; }
sleep 1
done
echo "container with a dead database never went unhealthy (status: ${status:-<none>})"
docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true
return 1
}
step "health fails when database is down" check_health_degraded
# Sign in for real: request a magic link, read it out of the container log,
# follow it, and keep the session cookie.
sign_in_as() {
local email="$1" jar="$2"
rm -f "$jar"
curl -s -o /dev/null -X POST "http://localhost:${APP_PORT}/api/auth/magic-link" \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:${APP_PORT}" \
-d "{\"email\":\"${email}\",\"callback\":\"/admin\"}"
local link=""
for _ in $(seq 1 15); do
link=$(docker logs "$APP_CONTAINER" 2>&1 \
| grep -oE "http://localhost:${APP_PORT}/api/auth/verify\?[^ ]+" | tail -1)
[ -n "$link" ] && break
sleep 1
done
[ -n "$link" ] || { echo "no magic link was logged for ${email}"; return 1; }
curl -s -c "$jar" -o /dev/null "$link"
grep -q "szp_session" "$jar" || { echo "no session cookie for ${email}"; return 1; }
}
# The admin *shell* is public HTML — it carries no data. What must be refused
# is the API behind it.
check_anonymous_locked_out() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/api/admin/campaigns")
[ "$code" = "401" ] || { echo "/api/admin/campaigns returned $code while signed out"; return 1; }
curl -s "http://localhost:${APP_PORT}/api/auth/session" | grep -q '"isAdmin":false' \
|| { echo "anonymous session claims admin"; return 1; }
curl -s "http://localhost:${APP_PORT}/admin" | grep -q '"route":"admin"' \
|| { echo "admin shell did not render"; return 1; }
curl -s "http://localhost:${APP_PORT}/admin" | grep -qi 'goalAmount' \
&& { echo "admin shell leaked campaign data to an anonymous visitor"; return 1; }
return 0
}
step "anonymous cannot reach admin data" check_anonymous_locked_out
check_admin_signin() {
sign_in_as "admin@example.invalid" /tmp/ci-admin.jar || return 1
curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/auth/session" \
| grep -q '"isAdmin":true' || { echo "allowlisted admin is not admin"; return 1; }
local code
code=$(curl -s -b /tmp/ci-admin.jar -o /dev/null -w '%{http_code}' \
"http://localhost:${APP_PORT}/api/admin/campaigns")
[ "$code" = "200" ] || { echo "/api/admin/campaigns returned $code for an admin"; return 1; }
}
step "magic-link sign-in reaches admin" check_admin_signin
# The important one: signing in is not the same as being an admin.
check_non_admin_refused() {
sign_in_as "nobody@example.invalid" /tmp/ci-other.jar || return 1
curl -s -b /tmp/ci-other.jar "http://localhost:${APP_PORT}/api/auth/session" \
| grep -q '"email":"nobody@example.invalid"' \
|| { echo "the non-admin did not actually get a session"; return 1; }
curl -s -b /tmp/ci-other.jar "http://localhost:${APP_PORT}/api/auth/session" \
| grep -q '"isAdmin":false' || { echo "a signed-in non-admin was granted admin"; return 1; }
local code
code=$(curl -s -b /tmp/ci-other.jar -o /dev/null -w '%{http_code}' \
"http://localhost:${APP_PORT}/api/admin/campaigns")
[ "$code" = "401" ] || { echo "a signed-in non-admin reached admin data (got $code)"; return 1; }
}
step "signed-in non-admin is refused" check_non_admin_refused
# A magic link is single-use. Following the same one twice must not sign in.
check_link_single_use() {
local link
link=$(docker logs "$APP_CONTAINER" 2>&1 \
| grep -oE "http://localhost:${APP_PORT}/api/auth/verify\?[^ ]+" | tail -1)
rm -f /tmp/ci-replay.jar
curl -s -c /tmp/ci-replay.jar -o /dev/null "$link"
grep -q "szp_session" /tmp/ci-replay.jar \
&& { echo "a used magic link signed someone in again"; return 1; }
return 0
}
step "magic link is single-use" check_link_single_use
# Cross-origin writes must be refused even with a valid session cookie.
check_csrf_guard() {
local code
code=$(curl -s -b /tmp/ci-admin.jar -o /dev/null -w '%{http_code}' \
-X PUT "http://localhost:${APP_PORT}/api/admin/campaigns" \
-H "Content-Type: application/json" -H "Origin: https://evil.example" \
-d '{"title":"x"}')
[ "$code" = "403" ] || { echo "cross-origin write returned $code, expected 403"; return 1; }
}
step "cross-origin writes refused" check_csrf_guard
check_admin_editor_data() {
curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns/${SLUG}" \
| python3 -c '
import json, sys
c = json.load(sys.stdin)
assert c["tiers"], "no tiers in the editor payload"
assert any(t["pledgeCount"] > 0 for t in c["tiers"]), "no tier reports its pledges"
assert c["story"], "no story sections"
'
}
step "admin editor payload complete" check_admin_editor_data
# Campaigns are created by hand in the database. The editor can only change one
# that already exists, and the API has to enforce that rather than trust it:
# a save with no id used to insert a new campaign.
check_creation_refused() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
-b /tmp/ci-admin.jar \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:${APP_PORT}" \
-d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","organizationId":"x","status":"AKTIV"}' \
"http://localhost:${APP_PORT}/api/admin/campaigns")
[ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; }
# And it must not have landed anyway.
local n
n=$(curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns" \
| grep -c '"slug":"uj-kampany"' || true)
[ "$n" = "0" ] || { echo "the refused campaign was created regardless"; return 1; }
}
step "campaigns cannot be created through the API" check_creation_refused
check_404() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen")
[ "$code" = "404" ] || { echo "unknown campaign returned $code, expected 404"; return 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/nincs-ilyen-oldal")
[ "$code" = "404" ] || { echo "unknown page returned $code, expected 404"; return 1; }
}
step "unknown routes are 404" check_404
# A draft campaign must be as invisible as one that does not exist.
check_draft_hidden() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/api/campaigns/nincs-ilyen")
[ "$code" = "404" ] || { echo "unknown campaign API returned $code"; return 1; }
}
step "non-public campaigns are hidden" check_draft_hidden
END=$(date +%s)
echo
if [ ${#FAILED[@]} -eq 0 ]; then
bold "all checks passed in $((END - START))s"
exit 0
else
bold "FAILED (${#FAILED[@]}): ${FAILED[*]}"
exit 1
fi