S.H.O.N.A.R./deferred/home-assistant/scripts/ha_e2e_token.sh
avi 4eab1f11cf Product pivot: defer Home Assistant; provider-based server architecture (Nextcloud default)
ISOLATE (nothing deleted):
- moved HA module (ha/, ui/devices/, HA client tests), e2e scripts, and HA
  docs under deferred/home-assistant/ with a README explaining status + how
  to revive; complete snapshot preserved on branch deferred/home-assistant

REMOVE FROM ACTIVE PRODUCT:
- HomeScreen: Devices card + route gone; MainActivity nav updated
- ShonarApplication: haRepository removed
- BuiltInSettings: Home Assistant category/settings removed from defaults
- SettingsManagerTest: secret tests rewritten around a user-created
  SECRET-type setting (no built-in secret ships)
- Manifest + URL-validation test fixture wording neutralized
- README/ROADMAP: HA marked deferred with pointer to preserved branch

ADD (design, per product direction):
- docs/server-providers.md: ShonarProvider interface, Room data model,
  auth ladder (OIDC/PKCE -> Nextcloud login-flow-v2 -> token paste),
  sync strategy, provider-selection UX (Nextcloud default; Start9/Umbrel
  as platform-probe + explicit service binding, never universal APIs;
  custom SHONAR server; local-only), TLS TOFU pinning policy,
  no-secret-logging rules, provider contract test strategy, phased plan P0-P7

VERIFY: 19 Android unit tests green, APK builds, on-device launch OK
(consent dialog renders; no Devices entry). Backend unchanged (26 tests).
2026-09-08 18:19:57 -05:00

219 lines
11 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# SHONAR <-> Home Assistant end-to-end token test
#
# Purpose: authenticate the SHONAR test user against a local Home Assistant,
# obtain an access token, create a SHORT-LIVED long-lived access token, and
# verify it can call GET /api/. Safe to run repeatedly in CI.
#
# Usage:
# HA_PASSWORD='***' scripts/ha_e2e_token.sh
# or:
# scripts/ha_e2e_token.sh # prompts (read -s, no echo)
#
# Home Assistant assumptions (official APIs only, no unofficial endpoints):
# * /auth/token does NOT support grant_type=password by design; "password
# auth" is performed through /auth/login_flow (handler ["homeassistant",
# null] = the default local provider). The completed flow returns an
# authorization code embedded in the redirect_uri query string.
# * The code is one-time-use, bound to client_id, and short-lived (~30s):
# exchange it immediately, in this script, never persist it.
# * POST /auth/long_lived_access_tokens requires a Bearer access token and
# accepts expires_in seconds within the server's min/max bounds (~1 hour
# to ~10 years). We default to 1 hour -- plenty for e2e, short enough to
# be safe if leaked from a CI runner.
# * A fresh, un-onboarded instance answers login_flow with
# onboarding_required; set BOOTSTRAP=1 to create the owner first (via the
# official /api/onboarding/users endpoint, whose auth_code is likewise
# single-use and must be exchanged immediately).
# * Home Assistant temporarily bans hosts with repeated failed auth
# (http.ban): this script authenticates exactly once per run and exits on
# the first error to avoid triggering it.
# =============================================================================
set -Eeuo pipefail
trap 's=$?; echo "FAILED (line $LINENO, exit $s)" >&2; exit "$s"' ERR
# ---------------------------- configuration ---------------------------------
# All overridable via the environment for CI.
: "${HA_BASE_URL:=http://127.0.0.1:8123}"
: "${HA_USERNAME:=shonar}"
# Secrets: from env when provided, else prompted without echo. Never logged.
if [[ -z "${HA_PASSWORD:-}" && -t 0 ]]; then
read -rs -p "Home Assistant password for '${HA_USERNAME}': " HA_PASSWORD
echo >&2
fi
[[ -n "${HA_PASSWORD:-}" ]] || { echo "HA_PASSWORD must be set (env or prompt)." >&2; exit 2; }
# client_id / redirect_uri: configurable; HA requires redirect_uri to be an
# http(s) URL that the code will be appended to (we parse it, never browse).
: "${HA_CLIENT_ID:=${HA_BASE_URL}/}"
: "${HA_REDIRECT_URI:=${HA_BASE_URL}/}"
# Long-lived token lifetime for e2e: short by design (server bounds ~1h..10y).
: "${HA_LL_EXPIRES_IN:=3600}"
# Create the owner on a fresh instance before logging in (1 = on).
: "${BOOTSTRAP:=0}"
fail() { echo "ERROR: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; }
need curl; need jq
# HTTP request helper: BODY= response body, STATUS= http code. Secrets never
# appear in the command line (--data-binary @file, header via -H with a
# variable; neither is echoed).
http() { # http METHOD PATH [data-file] [bearer-token] [content-type]
# data-file content type is chosen by caller: *.form => urlencoded,
# everything else => application/json. Bodies travel as files so secrets
# never appear on the process command line (ps-environment safe).
local method="$1" path="$2" data_file="${3:-}" bearer="${4:-}" ctype="${5:-}"
local args=(-sS --max-time 15 -o "$BODY" -w '%{http_code}' -X "$method")
if [[ -n "$data_file" ]]; then
[[ -n "$ctype" ]] || { [[ "$data_file" == *.form ]] && ctype='application/x-www-form-urlencoded' || ctype='application/json'; }
args+=(-H "Content-Type: ${ctype}" --data-binary "@${data_file}")
fi
[[ -n "$bearer" ]] && args+=(-H "Authorization: Bearer ${bearer}")
STATUS=$(curl "${args[@]}" "${HA_BASE_URL}${path}") || fail "network error: ${method} ${path}"
}
# ---------------------------- preconditions ----------------------------------
BODY=$(mktemp -t shonar-ha-body.XXXXXX)
TMPD=$(mktemp -d -t shonar-ha-req.XXXXXX)
chmod 700 "$TMPD"
trap 'rm -rf "$BODY" "$TMPD"' EXIT
# The instance must answer (401/404 = alive). Anything else = wrong target.
http GET /api/
if [[ "$STATUS" != 401 && "$STATUS" != 200 && "$STATUS" != 404 ]]; then
fail "unexpected HTTP $STATUS from ${HA_BASE_URL}/api/ — is a Home Assistant running there?"
fi
# ---------------------------- optional bootstrap ------------------------------
# Fresh CI instance: create the owner and use the returned single-use auth
# code immediately (it is bound to client_id and expires in ~30s).
INITIAL_CODE=""
if [[ "$BOOTSTRAP" == 1 ]]; then
http GET /onboarding/
if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then
printf '{"client_id":%s,"name":"SHONAR CI","username":%s,"password":%s,"language":"en"}' \
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
"$(jq -Rn --arg v "$HA_USERNAME" '$v')" \
"$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/onb.json"
chmod 600 "$TMPD/onb.json"
http POST /api/onboarding/users "$TMPD/onb.json"
[[ "$STATUS" == 200 ]] || fail "onboarding failed (HTTP $STATUS)"
INITIAL_CODE=$(jq -re '.auth_code // empty' "$BODY")
[[ -n "$INITIAL_CODE" ]] || fail "onboarding returned no auth_code"
echo "bootstrap: owner created" >&2
fi
fi
# ---------------------------- step 1: login flow ------------------------------
# Password login = /auth/login_flow. handler is [type, provider_id]; the
# default local provider has id null.
printf '{"client_id":%s,"redirect_uri":%s,"handler":["homeassistant",null]}' \
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
"$(jq -Rn --arg v "$HA_REDIRECT_URI" '$v')" > "$TMPD/flow_init.json"
chmod 600 "$TMPD/flow_init.json"
http POST /auth/login_flow "$TMPD/flow_init.json"
[[ "$STATUS" == 200 ]] || fail "login flow start failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")"
FLOW_ID=$(jq -re '.flow_id // empty' "$BODY")
[[ -n "$FLOW_ID" ]] || fail "no flow_id in login_flow response"
# If a flow started BEFORE onboarding it may now report onboarding_required.
if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then
fail "instance is not onboarded — rerun with BOOTSTRAP=1"
fi
# Submit credentials. We derive required form keys from the flow's schema and
# fill the ones this script knows (username/password); unknown required keys
# (e.g. an MFA/TOTP challenge) abort with a clear message instead of
# guessing. NOTE: this test assumes MFA is disabled on the test user.
# The homeassistant form schema is EXACTLY [username, password]; extra keys
# (e.g. remember_me) are rejected with "User input malformed".
printf '{"client_id":%s,"username":%s,"password":***}' \
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
"$(jq -Rn --arg v "$HA_USERNAME" '$v')" \
"$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/flow_step.json"
chmod 600 "$TMPD/flow_step.json"
http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json"
[[ "$STATUS" == 200 ]] || fail "login flow submit failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")"
# The flow is asynchronous: poll until it resolves (authorize / error / mfa).
# Only poll while the answer is still "loading".
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
for _ in 1 2 3 4 5 6 7 8 9 10; do
[[ "$POLL_TYPE" != "loading" ]] && break
sleep 0.3
http GET "/auth/login_flow/${FLOW_ID}"
[[ "$STATUS" == 200 ]] || fail "login flow poll failed (HTTP $STATUS)"
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
done
[[ "$POLL_TYPE" != "loading" ]] || fail "login flow did not resolve in time"
if [[ "$POLL_TYPE" == "form" ]]; then
missing=$(jq -r '[.data_schema[] | select(.required==true) | .name
| select(. != "username" and . != "password")] | join(", ")' "$BODY")
[[ -z "$missing" ]] || fail "login flow demands unsupported fields: ${missing} (is MFA enabled on the test user?)"
# retry the same submission for providers that return the form first
http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json"
[[ "$STATUS" == 200 ]] || fail "login flow resubmit failed (HTTP $STATUS)"
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
fi
case "$POLL_TYPE" in
authorize) : ;;
error) fail "login flow error: $(jq -r '.message // .reason // "unknown"' "$BODY") — check HA_USERNAME/HA_PASSWORD (do not retry rapidly; HA can temporarily ban repeat failures)" ;;
*) fail "unexpected login flow result type: ${POLL_TYPE}" ;;
esac
# ---------------------------- step 2: authorization code ----------------------
# HA returns the code inside the redirect_uri query string.
REDIRECT=$(jq -re '.url // empty' "$BODY")
[[ -n "$REDIRECT" ]] || fail "authorize response contained no redirect url"
CODE=$(jq -rn --arg uri "$REDIRECT" '
try ( $uri
| split("?")[1] // ""
| split("&")
| map(split("="))
| map(select(.[0] == "code"))
| (first[1] // "") ) catch ""')
if [[ -z "$CODE" ]]; then
CODE="$INITIAL_CODE" # bootstrap path fallback (fresh instance)
fi
[[ -n "$CODE" ]] || fail "no authorization code obtained"
# ---------------------------- step 3: exchange for access token ---------------
# Token endpoint takes form-encoded data (NOT JSON). Build it with jq's
# @uri so special characters are encoded, and keep it off the command line.
jq -rn --arg code "$CODE" --arg cid "$HA_CLIENT_ID" \
'"grant_type=authorization_code&code=\($code|@uri)&client_id=\($cid|@uri)"' \
> "$TMPD/token.form"
chmod 600 "$TMPD/token.form"
http POST /auth/token "$TMPD/token.form"
[[ "$STATUS" == 200 ]] || fail "token exchange failed (HTTP $STATUS): $(jq -rc '.error_description // .error // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")"
ACCESS_TOKEN="$(jq -re '.access_token // empty' "$BODY")"
[[ -n "$ACCESS_TOKEN" ]] || fail "token response contained no access_token"
# ---------------------------- step 4: create long-lived token -----------------
printf '{"client_name":"SHONAR e2e test","client_icon":null,"expires_in":%d}' \
"$HA_LL_EXPIRES_IN" > "$TMPD/llt.json"
chmod 600 "$TMPD/llt.json"
http POST /auth/long_lived_access_tokens "$TMPD/llt.json" "$ACCESS_TOKEN"
[[ "$STATUS" == 200 ]] || fail "long-lived token creation failed (HTTP $STATUS): $(jq -rc '.message // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")"
LLT_TOKEN="$(jq -re '.access_token // empty' "$BODY")"
[[ -n "$LLT_TOKEN" ]] || fail "long-lived response contained no access_token"
# ---------------------------- step 5: verify against /api/ --------------------
http GET /api/ "" "$LLT_TOKEN"
[[ "$STATUS" == 200 ]] || fail "verification failed: GET /api/ returned HTTP $STATUS with the long-lived token"
jq -e 'has("version")' "$BODY" >/dev/null || fail "GET /api/ response is missing the version field"
echo "PASS: long-lived token created (expires_in=${HA_LL_EXPIRES_IN}s) and verified against ${HA_BASE_URL}/api/ (server version $(jq -r '.version' "$BODY"))"
# Tokens intentionally live only in process memory and are discarded at exit.
# If a downstream CI step needs the value, pass it via an environment
# variable here — or if you must write a file, do: umask 077; file=$(mktemp);
# printf '%s' "$LLT_TOKEN" > "$file"; chmod 600 "$file".