taler-monitoring v1.13.12

Multi-phase global SUMMARY, warn-filter dimmed context, SUMMARY chrome
as meta; FP stage host-agent via francpaysan-stage-user (stagepaysan);
monpages GOA + FP stage.
This commit is contained in:
Hernâni Marques 2026-07-19 04:21:16 +02:00
commit 5a5431bfa9
No known key found for this signature in database
GPG key ID: CB5738652768F7E9
88 changed files with 21996 additions and 0 deletions

704
check_auth401.sh Executable file
View file

@ -0,0 +1,704 @@
#!/usr/bin/env bash
# auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows)
#
# Exercises the same APIs the merchant SPA uses for:
# • self-provision signup (POST /instances) — mixed-case id
# • login / createAccessToken (POST …/private/token) — Basic user case
# • password change (POST …/private/auth)
# • admin-create (POST /management/instances) when admin bearer available
# • access-token + private/ with Bearer
#
# Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is
# usually not. SPA must always lowercase the username (createAccessToken, signup
# id, admin-create, pw change re-login). These tests assert the *raw API*
# behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted
# only after lowercasing.
#
# ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401
# ./taler-monitoring.sh -d hacktivism.ch auth401
#
# Continue through all groups / collect every ERROR (optional):
# AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401
# CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401 # same
#
# Env: see secrets.env.example (AUTH401_*).
#
# NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under
# set -e would abort with no ERROR summary — the opposite of what monitoring
# needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit.
set -uo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
load_monitoring_secrets_env 2>/dev/null || true
tmp=$(mktemp -d)
AUTH401_FINISHED=0
_auth401_finish() {
[ "${AUTH401_FINISHED}" = "1" ] && return 0
AUTH401_FINISHED=1
# summary() returns non-zero when FAIL_N>0 — never let that skip the print
summary || true
rm -rf "${tmp:-}"
if [ "${FAIL_N:-0}" -gt 0 ]; then
exit 1
fi
exit 0
}
trap '_auth401_finish' EXIT
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
: "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}"
: "${AUTH401_CREATE:=1}"
: "${AUTH401_INSTANCE:=}"
: "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}"
: "${AUTH401_ID_PREFIX:=mon401}"
: "${AUTH401_SKIP_IF_MFA:=1}"
: "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}"
: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
: "${AUTH401_DURABLE_ID:=mon401}"
# Prefer durable alone (skip throwaway) — default 0 so full matrix always runs
: "${AUTH401_PREFER_DURABLE:=0}"
# Run expanded groups (signup/login/pwchange/admin/webui). Default 1.
: "${AUTH401_FULL:=1}"
# Admin bearer for POST /management/instances (optional)
: "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}"
: "${AUTH401_ADMIN_PASSWORD:=}"
: "${AUTH401_ADMIN_USER:=admin}"
# 1 = do not abort on hard prereq fail; run remaining groups (default 0)
# Alias: CONTINUE_ON_ERROR=1
: "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}"
# Never rotate password on durable account (default 1 — keep mon401.password valid)
: "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}"
TIMEOUT="$AUTH401_TIMEOUT"
export TIMEOUT
_auth401_abort_or_continue() {
local why="${1:-prereq failed}"
if [ "${AUTH401_CONTINUE}" = "1" ]; then
warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)"
return 0
fi
# hard stop — EXIT trap prints summary + all ERRORS collected so far
exit 1
}
BASE="${MERCHANT_PUBLIC%/}"
if [ -z "$BASE" ]; then
echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2
exit 2
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
expect_http() {
local label="$1" want="$2" got="$3" detail="${4:-}"
case ",$want," in
*",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;;
*) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;;
esac
}
to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }
# First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0)
synth_mix() {
python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)"
}
# First 3 upper (partial mix)
synth_partmix() {
python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())"
}
# Normalize curl status: "200", "000", never "200000" / "000000"
_http_norm() {
local raw="$1" dig
dig=$(printf '%s' "$raw" | tr -cd '0-9')
if [ -z "$dig" ]; then
printf '000'
else
# first 3 digits = real code (curl 200 then || echo 000 → 200000)
printf '%s' "$dig" | head -c 3
fi
}
token_http() {
local path_id="$1" user="$2" password="$3"
local b64 raw
b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \
|| printf '%s' "${user}:${password}" | base64)
raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/last_token.json" -w '%{http_code}' \
-X POST "${BASE}/instances/${path_id}/private/token" \
-H "Authorization: Basic ${b64}" \
-H 'Content-Type: application/json' \
-d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
2>/dev/null || true)
_http_norm "${raw:-}"
}
json_code_hint() {
python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
import json, sys
try:
d = json.load(open(sys.argv[1]))
except Exception:
print("")
raise SystemExit
c = d.get("code", "")
h = (d.get("hint") or d.get("detail") or "")[:70]
print(f"code={c} {h}".strip())
PY
}
extract_token() {
python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true
import json, sys
try:
d = json.load(open(sys.argv[1]))
except Exception:
print("")
raise SystemExit(0)
print(d.get("access_token") or d.get("token") or "")
PY
}
instance_body() {
local id="$1" pass="$2" name="${3:-auth401 probe}"
python3 - "$id" "$pass" "$name" <<'PY'
import json, sys
print(json.dumps({
"address": {},
"auth": {"method": "token", "password": sys.argv[2]},
"default_pay_delay": {"d_us": 60000000},
"default_wire_transfer_delay": {"d_us": 30000000},
"default_refund_delay": {"d_us": 30000000},
"id": sys.argv[1],
"jurisdiction": {},
"name": sys.argv[3],
"use_stefan": True,
}))
PY
}
auth_change_http() {
local path_id="$1" bearer="$2" new_pass="$3"
local tok="$bearer" body
case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))')
curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/auth_change.json" -w '%{http_code}' \
-X POST "${BASE}/instances/${path_id}/private/auth" \
-H "Authorization: Bearer ${tok}" \
-H 'Content-Type: application/json' \
-d "$body" \
2>/dev/null || echo 000
}
# ---------------------------------------------------------------------------
# Stage secrets (quiet)
# ---------------------------------------------------------------------------
_auth401_stage_secret_roots() {
printf '%s\n' \
"${FRANCPAYSAN_SECRETS:-}" \
"${HOME}/francpaysan-secrets" \
"${HOME}/src/francpaysan-secrets" \
"${HOME}/taler/src/francpaysan-secrets"
}
_auth401_resolve_secrets_quiet() {
local root inst_id pass
for root in $(_auth401_stage_secret_roots); do
[ -n "$root" ] && [ -d "$root" ] || continue
# durable ids: prefer mon401fix when mon401 was rotated by old tests
if [ -z "${DURABLE_PASS:-}" ]; then
for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do
[ -n "$inst_id" ] || continue
pass=""
if [ -f "$root/stage/instances/${inst_id}.password" ]; then
pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password")
fi
# skip empty password files
if [ -n "$pass" ]; then
# verify file is for this id (mon401.password may be stale copy of mon401fix)
DURABLE_PASS="$pass"
DURABLE_ID=$(to_lower "$inst_id")
AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/"
# only set AUTH401_PASSWORD if caller did not pin an instance
if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then
AUTH401_PASSWORD="$pass"
fi
break
fi
done
fi
if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then
AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt")
AUTH401_BEARER_NOTE="default-instance-token.txt"
fi
if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then
AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt")
AUTH401_ADMIN_NOTE="admin-instance-token.txt"
fi
[ -n "${DURABLE_PASS:-}" ] && break
done
# also keep AUTH401_INSTANCE for durable-only mode
if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}"
AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}"
AUTH401_CREATE=0
AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}"
fi
}
_auth401_resolve_secrets_quiet
: "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}"
export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN
# ---------------------------------------------------------------------------
set_area auth401
section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}"
if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi
if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi
if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi
if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi
info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}"
if [ "${AUTH401_CONTINUE}" = "1" ]; then
info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap"
fi
# ===========================================================================
set_group config
# ===========================================================================
cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json")
expect_http "merchant /config" 200 "$cfg_code" "$BASE/config"
HAVE_SP=0
MANDATORY_TAN=0
if [ "$cfg_code" = "200" ]; then
eval "$(python3 - "$tmp/mer-config.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
sp = 1 if d.get("have_self_provisioning") else 0
tans = d.get("mandatory_tan_channels") or []
print(f"HAVE_SP={sp}")
print(f"MANDATORY_TAN={1 if tans else 0}")
print(f"TAN_CH={','.join(tans) if tans else ''}")
print(f"API_VER={d.get('version','')}")
PY
)"
info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}"
else
fail "merchant config required for auth401" "HTTP $cfg_code"
_auth401_abort_or_continue "no /config"
fi
# ===========================================================================
set_group webui
# SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle
# ===========================================================================
if [ "${AUTH401_FULL}" = "1" ]; then
wcode=$(http_code "$BASE/webui/")
case "$wcode" in
200|301|302) ok "webui /" "HTTP $wcode" ;;
*) warn "webui /" "HTTP $wcode" ;;
esac
vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000)
if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then
ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")"
else
warn "webui version.txt" "HTTP ${vcode:-000}"
fi
# Download a slice of index.js — look for lowercasing mitigation (#11340)
jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000)
jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3)
if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then
ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes"
if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then
ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)"
else
warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing"
fi
else
warn "webui index.js" "HTTP ${jcode:-000}"
fi
fi
# ===========================================================================
set_group signup
# WebUI self-provision: POST /instances with mixed-case id (as typed)
# ===========================================================================
CREATED=0
USE_ID=""
USE_PASS=""
ID_MIX=""
ID_LOW=""
CREATE_PASS="$AUTH401_CREATE_PASSWORD"
stamp=$(date +%H%M%S)
# Mixed-case id like the SPA would send before lowercasing (or if it forgot)
ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}"
ID_LOW=$(to_lower "$ID_MIX")
if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then
if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then
warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD"
else
body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX")
ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/create.json" -w '%{http_code}' \
-X POST "${BASE}/instances" \
-H 'Content-Type: application/json' \
-d "$body" 2>/dev/null || true)")
case "$ccode" in
200|204)
ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)"
CREATED=1
USE_ID="$ID_MIX"
USE_PASS="$CREATE_PASS"
# Immediate login as SPA after signup: must use *lowercase* Basic user
code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS")
expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)"
# Bug path: SPA forgot toLowerCase on login after signup with MIX id typed
code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS")
expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)"
;;
202)
warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE"
;;
*)
fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')"
;;
esac
fi
elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then
USE_ID=$(to_lower "$AUTH401_INSTANCE")
USE_PASS="$AUTH401_PASSWORD"
ID_LOW=$(to_lower "$USE_ID")
ID_MIX=$(synth_mix "$ID_LOW")
info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)"
ok "using existing instance" "$USE_ID"
else
warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)"
fi
# Fallback durable
if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
USE_ID="$DURABLE_ID"
USE_PASS="$DURABLE_PASS"
ID_LOW=$(to_lower "$USE_ID")
ID_MIX=$(synth_mix "$ID_LOW")
info "fallback durable" "$USE_ID"
ok "using durable instance" "$USE_ID"
fi
if [ -z "$USE_ID" ]; then
fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password"
_auth401_abort_or_continue "no instance under test"
# CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401)
ID_LOW_USE="auth401-missing-instance"
ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
ID_PATH_MIX="$ID_USER_MIX"
ID_UPPER=$(to_upper "$ID_LOW_USE")
ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
PASS_OK="invalid"
PASS_BAD='auth401-wrong-password-NOT-VALID'
USE_ID="$ID_LOW_USE"
USE_PASS="$PASS_OK"
fi
ID_LOW_USE=$(to_lower "$USE_ID")
# Always synthetic case variants (even for mon401) so MIX ≠ low
ID_USER_MIX=$(synth_mix "$ID_LOW_USE")
ID_PATH_MIX="$ID_USER_MIX"
ID_UPPER=$(to_upper "$ID_LOW_USE")
ID_PARTMIX=$(synth_partmix "$ID_LOW_USE")
PASS_OK="$USE_PASS"
PASS_BAD='auth401-wrong-password-NOT-VALID'
info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}"
# ===========================================================================
set_group login
# WebUI login = POST …/private/token with Basic (username, password)
# ===========================================================================
if [ "${AUTH401_FULL}" = "1" ]; then
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
fi
# ===========================================================================
set_group case
# Full Basic case matrix (always hard expects — synthetic MIX)
# ===========================================================================
code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK")
expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK")
expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK")
expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK")
expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK")
expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK")
expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)"
# ===========================================================================
set_group password
# ===========================================================================
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD")
expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "")
expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD")
expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)"
# ===========================================================================
set_group pwchange
# WebUI password change: POST private/auth then re-login
# Only on throwaway signup instances — never rotate durable mon401.password
# ===========================================================================
if [ "${AUTH401_FULL}" = "1" ]; then
if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then
info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password"
else
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
if [ "$code" != "200" ]; then
fail "pwchange: login for token" "HTTP $code — skip pwchange"
else
TOK=$(extract_token)
if [ -z "$TOK" ]; then
fail "pwchange: parse token" "empty access_token"
else
ok "pwchange: got bearer" "len=${#TOK}"
NEW_PASS="Auth401-New-${stamp}!"
ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")")
case "$ac" in
200|204)
ok "pwchange POST private/auth" "HTTP $ac (SPA password form)"
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS")
expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS")
expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)"
PASS_OK="$NEW_PASS"
USE_PASS="$NEW_PASS"
;;
202)
warn "pwchange needs MFA" "HTTP 202"
;;
*)
fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')"
;;
esac
fi
fi
fi
fi
# ===========================================================================
set_group admin
# Admin UI: POST /management/instances (needs admin bearer)
# ===========================================================================
if [ "${AUTH401_FULL}" = "1" ]; then
ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}"
# Optional: login as admin user with password
if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then
code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD")
if [ "$code" = "200" ]; then
ADMIN_TOK=$(extract_token)
ok "admin login user=${AUTH401_ADMIN_USER}" "got token"
else
# try lower only
au=$(to_lower "$AUTH401_ADMIN_USER")
code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD")
if [ "$code" = "200" ]; then
ADMIN_TOK=$(extract_token)
ok "admin login user=${au}" "got token"
else
warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN"
fi
fi
fi
if [ -z "$ADMIN_TOK" ]; then
info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)"
else
case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac
AID_MIX="Adm401X${stamp}"
AID_LOW=$(to_lower "$AID_MIX")
APASS="Adm401-Pass-${stamp}!"
body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX")
acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/admin_create.json" -w '%{http_code}' \
-X POST "${BASE}/management/instances" \
-H "Authorization: Bearer ${ADMIN_TOK}" \
-H 'Content-Type: application/json' \
-d "$body" 2>/dev/null || echo 000)
case "$acode" in
200|204)
ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX"
code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS")
expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)"
# admin-set auth on new instance
code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS")
if [ "$code" = "200" ]; then
AT=$(extract_token)
NEW_A="Adm401-New-${stamp}!"
ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A")
case "$ch" in
200|204)
ok "admin-created instance pwchange" "HTTP $ch"
code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A")
expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)"
;;
*) warn "admin-created pwchange" "HTTP $ch" ;;
esac
fi
;;
401|403)
warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)"
;;
202)
warn "admin-create MFA" "HTTP 202"
;;
*)
fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')"
;;
esac
fi
fi
# ===========================================================================
set_group missing
# ===========================================================================
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/last_token.json" -w '%{http_code}' \
-X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \
-H 'Content-Type: application/json' \
-d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \
2>/dev/null || echo 000)
expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK")
expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)"
code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK")
# Must not look like auth failure (401). Prefer 404; 502 from proxy is soft.
case "$code" in
404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;;
401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;;
502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;;
*) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;;
esac
# ===========================================================================
set_group bearer
# ===========================================================================
code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK")
if [ "$code" = "200" ]; then
TOKEN=$(extract_token)
if [ -n "$TOKEN" ]; then
ok "got access_token" "len=${#TOKEN}"
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/priv.json" -w '%{http_code}' \
"${BASE}/instances/${ID_LOW_USE}/private/" \
-H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000)
expect_http "GET private/ Bearer OK → 200" 200 "$code"
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/priv.json" -w '%{http_code}' \
"${BASE}/instances/${ID_LOW_USE}/private/" \
-H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000)
expect_http "GET private/ Bearer BAD → 401" 401 "$code"
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/priv.json" -w '%{http_code}' \
"${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000)
expect_http "GET private/ NO_AUTH → 401" 401 "$code"
else
fail "parse access_token" "HTTP $code but no token field"
fi
else
fail "token for bearer tests" "HTTP $code"
fi
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/mgmt.json" -w '%{http_code}' \
"${BASE}/management/instances" 2>/dev/null || echo 000)
expect_http "GET /management/instances NO_AUTH → 401" 401 "$code"
if [ -n "${AUTH401_BEARER:-}" ]; then
if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then
case "${AUTH401_BEARER_NOTE:-}" in
*default-instance-token*) AUTH401_BEARER_INSTANCE=default ;;
*) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;;
esac
fi
inst=$(to_lower "$AUTH401_BEARER_INSTANCE")
tok="$AUTH401_BEARER"
case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac
code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \
-o "$tmp/priv2.json" -w '%{http_code}' \
"${BASE}/instances/${inst}/private/" \
-H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000)
case "$code" in
200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;;
401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;;
*) warn "secrets Bearer" "HTTP $code on $inst" ;;
esac
fi
# ===========================================================================
set_group durable
# Extra: durable mon401 still works after throwaway tests (if present)
# ===========================================================================
if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then
dlow=$(to_lower "$DURABLE_ID")
dmix=$(synth_mix "$dlow")
code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS")
expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)"
code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS")
expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)"
else
info "durable" "no mon401.password — skipped"
fi
# ===========================================================================
set_group report
# ===========================================================================
info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin"
if [ "$CREATED" = "1" ]; then
info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)"
fi
# fall through → EXIT trap runs _auth401_finish (summary + full ERROR list)
: