szabadpalya/scripts/ci.sh

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