Files
cadprox/tests/e2e.sh
2026-10-18 19:01:06 -07:00

179 lines
9.1 KiB
Bash
Executable File

#!/bin/bash
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
# End-to-end test for cadprox against a throwaway Caddy. Production is never touched.
set -u
B=http://127.0.0.1:8091
PROXY_PORT=8099
J=/tmp/opencode/e2e/jar
PASS=0; FAIL=0
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); }
check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
ups(){ curl -s -m 20 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; }
del(){ curl -s -m 20 -b $J -X POST $B/api/delete -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; }
# Issue a request through the throwaway Caddy, using SNI so the right site block
# is selected. -k accepts Caddy's internal-CA certificate. A brand new hostname
# needs Caddy a moment to provision its certificate, so retry briefly.
req(){ local h="$1"; shift
for _ in $(seq 1 20); do
c=$(curl -sk -o /dev/null -w '%{http_code}' -m 20 \
--resolve "$h:$PROXY_PORT:127.0.0.1" "$@" "https://$h:$PROXY_PORT/")
# 000 means the TLS handshake failed, which is what an unprovisioned
# certificate looks like. Any real HTTP status is a real answer.
[ "$c" != "000" ] && { echo "$c"; return; }
sleep 1
done
echo "000"; }
err(){ ups "$1" | python3 -c "import json,sys;print(json.load(sys.stdin).get('error',''))"; }
login(){ rm -f $J
curl -s -c $J -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"e2etestpassword"}' >/dev/null; }
# Block until the throwaway Caddy admin API answers, so a restart is never
# raced by a reload request.
wait_admin(){ for _ in $(seq 1 30); do
[ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 http://127.0.0.1:2020/config/)" = "200" ] && return
sleep 1
done
echo " WARNING: caddy admin API never became ready" >&2; }
echo "=== reset ==="
docker rm -f caddy-e2e backend-e2e cadprox-e2e >/dev/null 2>&1
# cadprox runs as root and owns the files it writes, so the directory has to be
# cleared from a container too. A plain rm leaves root-owned files behind and the
# next run silently inherits stale state.
docker run --rm -v /tmp/opencode/e2e:/x caddy:2.11.4 sh -c 'rm -rf /x/*' >/dev/null 2>&1
rm -rf /tmp/opencode/e2e; mkdir -p /tmp/opencode/e2e/datadocker network rm cadprox-e2e-net >/dev/null 2>&1
docker network create cadprox-e2e-net >/dev/null
docker run -d --name backend-e2e --network cadprox-e2e-net \
python:3.12-alpine python3 -m http.server 9001 --directory /tmp >/dev/null
# local_certs keeps auto-HTTPS on but issues from Caddy's internal CA, so the
# site blocks behave exactly as they do in production but need no public DNS.
cat > /tmp/opencode/e2e/Caddyfile <<'EOF'
{
admin 0.0.0.0:2019
local_certs
}
one.test {
encode gzip
reverse_proxy backend-e2e:9001
}
EOF
docker run -d --name caddy-e2e --network cadprox-e2e-net \
-p 127.0.0.1:2020:2019 -p 127.0.0.1:$PROXY_PORT:443 \
-v /tmp/opencode/e2e/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2.11.4 >/dev/null
docker run -d --name cadprox-e2e --network host \
-v /tmp/opencode/e2e:/caddy -v /tmp/opencode/e2e/data:/data \
-e CADPROX_CADDY_ADMIN=http://127.0.0.1:2020 -e CADPROX_PORT=8091 \
-e CADPROX_ADMIN_PASSWORD=e2etestpassword cadprox:1.0 >/dev/null
wait_admin
sleep 1
login
[ -s $J ] && ok "login issues a session cookie" || bad "no session cookie"
echo "=== 1. baseline traffic through the proxy (no auth) ==="
check "open proxy serves the backend" "$(req one.test)" "200"
echo "=== 2. add a proxy, traffic follows immediately ==="
ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null
check "new proxy serves traffic" "$(req two.test)" "200"
echo "=== 3. edit the target of an existing proxy ==="
ups '{"url":"two.test","target":"127.0.0.1:9"}' >/dev/null
check "bad target now returns 502" "$(req two.test)" "502"
ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null
check "restored target serves again" "$(req two.test)" "200"
echo "=== 4. basic auth is enforced end to end ==="
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"firstpassword"}}' >/dev/null
check "no credentials -> 401" "$(req secure.test)" "401"
check "wrong password -> 401" "$(req secure.test -u alice:wrong)" "401"
check "correct credentials -> 200" "$(req secure.test -u alice:firstpassword)" "200"
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"secondpassword"}}' >/dev/null
check "old password now -> 401" "$(req secure.test -u alice:firstpassword)" "401"
check "new password -> 200" "$(req secure.test -u alice:secondpassword)" "200"
echo "=== 5. editing the target keeps the credential ==="
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":null}}' >/dev/null
check "auth still required after target edit" "$(req secure.test)" "401"
check "credential still valid after target edit" "$(req secure.test -u alice:secondpassword)" "200"
echo "=== 6. remove auth from a proxy ==="
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":null}' >/dev/null
check "auth removed -> 200 without credentials" "$(req secure.test)" "200"
echo "=== 7. delete a proxy ==="
del '{"url":"two.test"}' >/dev/null
# A deleted hostname has no route and no certificate, so Caddy answers the
# handshake with a non-200 outcome rather than proxying to the backend.
two_now=$(req two.test)
if [ "$two_now" != "200" ]; then ok "deleted proxy no longer serves the backend (got $two_now)"
else bad "deleted proxy still serving traffic"; fi
check "untouched proxy still routes" "$(req one.test)" "200"
echo "=== 8. a hand-written block survives a save ==="
# -i is required: without it docker does not forward the heredoc on stdin.
docker exec -i cadprox-e2e sh -c 'cat >> /caddy/Caddyfile' <<'EOF'
handwritten.test {
header X-Special yes
reverse_proxy backend-e2e:9001
}
EOF
docker restart caddy-e2e >/dev/null; wait_admin
docker restart cadprox-e2e >/dev/null; sleep 3
login
managed=$(curl -s -b $J $B/api/entries | python3 -c "
import json,sys
e=[x for x in json.load(sys.stdin)['entries'] if x['url']=='handwritten.test']
print(e[0]['managed'] if e else 'absent')")
check "hand-written block is read-only in the UI" "$managed" "False"
refusal=$(err '{"url":"handwritten.test","target":"127.0.0.1:9"}')
case "$refusal" in
*"preserved verbatim"*) ok "editing a preserved block is refused";;
*) bad "expected a refusal, got: '$refusal'";;
esac
check "preserved block serves traffic before the save" "$(req handwritten.test)" "200"
ups '{"url":"one.test","target":"backend-e2e:9001"}' >/dev/null
check "preserved block still in the file after a save" \
"$(docker exec cadprox-e2e sh -c 'grep -c "header X-Special yes" /caddy/Caddyfile')" "1"
check "preserved block still serves traffic after a save" "$(req handwritten.test)" "200"
check "preserved block's custom directive survived the save" \
"$(docker exec cadprox-e2e sh -c 'grep -c "X-Special" /caddy/Caddyfile')" "1"
check "the cadprox header did not accumulate across saves" \
"$(docker exec cadprox-e2e sh -c 'grep -c "Managed by cadprox" /caddy/Caddyfile')" "1"
echo "=== 9. security ==="
check "unauthenticated API is refused" "$(curl -s -o /dev/null -w '%{http_code}' -m 10 $B/api/entries)" "401"
check "POST without the CSRF header is refused" \
"$(curl -s -o /dev/null -w '%{http_code}' -m 10 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -d '{"url":"x.test","target":"1.2.3.4:80"}')" "403"
check "wrong password is refused" \
"$(curl -s -o /dev/null -w '%{http_code}' -m 10 -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"nope"}')" "401"
check "no password hash is exposed by the API" \
"$(curl -s -b $J $B/api/entries | grep -c '\$2[aby]\$')" "0"
check "invalid target is rejected" \
"$(err '{"url":"bad.test","target":"1.2.3.4:99999"}' | grep -c 'not a valid port')" "1"
check "URL with a scheme is rejected" \
"$(err '{"url":"http://bad.test","target":"1.2.3.4:80"}' | grep -c 'no scheme')" "1"
# curl's cookie jar records HttpOnly but silently drops SameSite, so the raw
# Set-Cookie header is inspected instead.
sc=$(curl -s -i -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"e2etestpassword"}' | grep -i '^set-cookie:')
case "$sc" in *HttpOnly*) ok "session cookie is HttpOnly";; *) bad "cookie not HttpOnly: $sc";; esac
case "$sc" in *SameSite=Strict*) ok "session cookie is SameSite=Strict";; *) bad "cookie lacks SameSite=Strict: $sc";; esac
case "$sc" in *Secure*) bad "cookie marked Secure but cadprox serves plain HTTP";;
*) ok "cookie not falsely marked Secure over HTTP";; esac
echo "=== 10. the Caddyfile on disk still validates ==="
docker exec cadprox-e2e caddy validate --adapter caddyfile --config /caddy/Caddyfile >/dev/null 2>&1 \
&& ok "caddy validate accepts the final file" || bad "caddy validate rejected the final file"
echo
echo "================================"
echo " passed: $PASS failed: $FAIL"
echo "================================"
[ "$FAIL" -eq 0 ]