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).
219 lines
11 KiB
Bash
Executable file
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".
|