570 lines
23 KiB
Bash
Executable File
570 lines
23 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"
|
|
|
|
# Generated code that nobody verifies is worse than none: it looks like it
|
|
# describes the schema while quietly describing an older one. This regenerates
|
|
# from the migrations and the query files and fails if the result differs from
|
|
# what is committed.
|
|
sqlc_sync() { ./scripts/sqlc.sh diff; }
|
|
step "generated queries are current" sqlc_sync
|
|
|
|
go_build() { ./scripts/go.sh build ./...; }
|
|
go_vet() { ./scripts/go.sh vet ./...; }
|
|
# gofumpt when it can be fetched, go fmt otherwise. Both report unformatted
|
|
# files on stdout and say nothing when there is nothing to say -- so empty
|
|
# output means "clean" ONLY once one of them has actually run. Check that
|
|
# first: a formatter that could not start (no daemon, no network, a package
|
|
# that does not compile) also produces no stdout, and reading that as a pass
|
|
# would turn the silence into a tick.
|
|
go_format() {
|
|
local out
|
|
if ! out=$(./scripts/go.sh run mvdan.cc/gofumpt@latest -l . 2>&1) \
|
|
&& ! out=$(./scripts/go.sh fmt ./... 2>&1); then
|
|
echo "the formatter could not run, so nothing was checked:"
|
|
printf '%s\n' "$out"
|
|
return 1
|
|
fi
|
|
[ -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 database publishes on 127.0.0.1 only, so a container cannot reach it by
|
|
# going out to the host and back the way this used to. Join the network the
|
|
# database is already on and address it there: one hop shorter, and it does not
|
|
# care what the host publishes. Its IP rather than its name, because the plain
|
|
# `docker run` path in db.sh lands on the default bridge, which has no DNS.
|
|
db_net() { docker inspect -f '{{range $n, $_ := .NetworkSettings.Networks}}{{$n}}{{end}}' "$DB_CONTAINER"; }
|
|
db_addr() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$DB_CONTAINER"; }
|
|
|
|
# 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 --network "$(db_net)" \
|
|
-e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@$(db_addr):5432/${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 "127.0.0.1:${APP_PORT}:3000" \
|
|
--network "$(db_net)" \
|
|
--health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=3 \
|
|
-e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@$(db_addr):5432/${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 "127.0.0.1:${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
|
|
|
|
# A save whose slug belongs to another campaign must come back 409. The path
|
|
# that produces it matches the unique index BY NAME from Go
|
|
# (store/admin.go: isUniqueViolation(err, "campaigns_slug_key")), so renaming
|
|
# that index without updating the string degrades this silently into a 500 —
|
|
# no compile error, nothing else here would notice.
|
|
check_slug_conflict() {
|
|
local other body code
|
|
other=$(curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns" \
|
|
| python3 -c '
|
|
import json, sys
|
|
mine = sys.argv[1]
|
|
for r in json.load(sys.stdin):
|
|
if r["slug"] != mine:
|
|
print(r["slug"]); break
|
|
' "$SLUG")
|
|
[ -n "$other" ] || { echo "fixture has no second campaign to collide with"; return 1; }
|
|
|
|
# Load, change the slug, save it back — exactly what the editor does.
|
|
body=$(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)
|
|
c["slug"] = sys.argv[1]
|
|
print(json.dumps(c))
|
|
' "$other")
|
|
|
|
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 "$body" "http://localhost:${APP_PORT}/api/admin/campaigns")
|
|
[ "$code" = "409" ] || { echo "a colliding slug returned $code, expected 409"; return 1; }
|
|
}
|
|
step "a duplicate slug is refused" check_slug_conflict
|
|
|
|
# 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","ownerId":"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
|
|
|
|
# Every campaign has an owner, and the owner may have a photo. The column is
|
|
# nullable and the client falls back to the initials chip, so both states have
|
|
# to survive the trip: set, and cleared again.
|
|
check_owner_photo() {
|
|
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
|
|
-c "UPDATE owners SET avatar_url = 'https://example.invalid/o.jpg'" >/dev/null
|
|
|
|
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
|
|
import json, sys
|
|
campaigns = json.load(sys.stdin)
|
|
assert campaigns, "no campaigns to check"
|
|
for c in campaigns:
|
|
assert c["creator"]["avatarUrl"] == "https://example.invalid/o.jpg", c["creator"]
|
|
' || return 1
|
|
|
|
# A future owner editor loads the photo from here.
|
|
curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/owners" | python3 -c '
|
|
import json, sys
|
|
owners = json.load(sys.stdin)
|
|
assert owners, "no owners"
|
|
for o in owners:
|
|
assert o.get("avatarUrl"), o
|
|
' || return 1
|
|
|
|
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
|
|
-c "UPDATE owners SET avatar_url = NULL" >/dev/null
|
|
|
|
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
|
|
import json, sys
|
|
for c in json.load(sys.stdin):
|
|
assert "avatarUrl" not in c["creator"], "a missing photo must be omitted, not sent as an empty string"
|
|
'
|
|
}
|
|
step "owner photo round-trips, and is omitted when unset" check_owner_photo
|
|
|
|
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
|
|
|
|
# The admin's way in is the landing page with a sign-in link. Nothing links to
|
|
# it, so the only thing holding it up is the server case that answers it: every
|
|
# unrecognised path renders a 404 document, and it would fall into that.
|
|
check_admin_entry() {
|
|
local out
|
|
out=$(curl -s -o /dev/null -D - -w '%{http_code}' "http://localhost:${APP_PORT}/admin-belepes")
|
|
case "$out" in
|
|
*200) ;;
|
|
*) echo "the admin entry page returned something other than 200"; return 1 ;;
|
|
esac
|
|
printf '%s' "$out" | grep -qi 'X-Robots-Tag: *noindex' \
|
|
|| { echo "the admin entry page is missing its noindex header"; return 1; }
|
|
# And it must not be advertised on the public landing page.
|
|
curl -s -D - -o /dev/null "http://localhost:${APP_PORT}/" | grep -qi 'X-Robots-Tag' \
|
|
&& { echo "the landing page should not be noindex"; return 1; }
|
|
return 0
|
|
}
|
|
step "admin entry page is served and unindexed" check_admin_entry
|
|
|
|
# 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
|