#!/usr/bin/env bash set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" image="homeassistant-stelloauth-addon:test" run_id="$(date +%s)-$$" first_container="stelloauth-runtime-${run_id}-first" second_container="stelloauth-runtime-${run_id}-second" tmp_dir="$(mktemp -d)" artifacts_dir="${repo_root}/artifacts" options_file="${tmp_dir}/options.json" sentinels=( "sentinel-email@example.invalid" "SENTINEL_PASSWORD_9a34" "SENTINEL_COOKIE_7b21" "SENTINEL_OAUTH_CODE_5c88" "SENTINEL_ACCESS_TOKEN_1d62" "SENTINEL_REFRESH_TOKEN_4e73" ) cleanup() { set +e for container in "$first_container" "$second_container"; do if docker container inspect "$container" >/dev/null 2>&1; then if [ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null)" = "true" ]; then docker stop --time 10 "$container" >/dev/null 2>&1 fi docker rm "$container" >/dev/null 2>&1 fi done rm -r "$tmp_dir" } trap cleanup EXIT fail() { printf 'runtime test failed: %s\n' "$*" >&2 exit 1 } write_options() { python3 - "$options_file" <<'PY' import json import pathlib import sys options = { "queue_timeout": "60s", "rate_limit_count": 5, "rate_limit_duration": "1h", } pathlib.Path(sys.argv[1]).write_text( json.dumps(options, separators=(",", ":")) + "\n", encoding="utf-8", ) PY } start_container() { local container="$1" docker create --name "$container" \ --platform linux/amd64 \ --publish 127.0.0.1::8080 \ "$image" >/dev/null docker cp "$options_file" "$container:/data/options.json" docker start "$container" >/dev/null } host_port() { local container="$1" local mapping mapping="$(docker port "$container" 8080/tcp)" [ -n "$mapping" ] || fail "container 8080 has no host mapping" printf '%s\n' "${mapping##*:}" } probe_root() { local port="$1" python3 - "$port" <<'PY' import sys import urllib.request opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) with opener.open(f"http://127.0.0.1:{sys.argv[1]}/", timeout=2) as response: if response.status != 200: raise SystemExit(f"root status {response.status}") response.read() PY } wait_ready() { local container="$1" local port="$2" local deadline=$((SECONDS + 90)) while (( SECONDS < deadline )); do if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then docker logs "$container" >&2 fail "$container exited during readiness" fi if docker logs "$container" 2>&1 | grep -Fq "Stelloauth listening on 0.0.0.0:8080"; then if probe_root "$port" >/dev/null 2>&1; then return fi fi sleep 1 done docker logs "$container" >&2 fail "$container did not become ready within 90 seconds" } normalize_process_file() { local top_file="$1" python3 - "$top_file" <<'PY' import pathlib import sys commands = [] for raw_line in pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()[1:]: fields = raw_line.split(maxsplit=1) command = " ".join(fields[1].split()) if len(fields) == 2 else "" tokens = command.split() while tokens and ( tokens[0] == "/run/rosetta/rosetta" or pathlib.Path(tokens[0]).name.startswith("qemu-") ): tokens = tokens[1:] python_prefixes = ( [], ["python3"], ["/usr/local/bin/python3"], ["/usr/local/bin/python3", "python3"], ) supervisor_commands = [ [*prefix, "/usr/local/bin/addon-supervisor"] for prefix in python_prefixes ] cloak_commands = [ [ *prefix, "/usr/local/bin/cloakserve", "--headless=true", "--idle-timeout=30", "--data-dir=/tmp/cloakserve", ] for prefix in python_prefixes ] stelloauth_commands = ( ["/usr/local/bin/stelloauth"], ["/usr/local/bin/stelloauth", "/usr/local/bin/stelloauth"], ) if tokens in supervisor_commands: commands.append("addon-supervisor") elif tokens in cloak_commands: commands.append("cloakserve") elif tokens in stelloauth_commands: commands.append("stelloauth") elif command: commands.append(f"unexpected:{command}") print("\n".join(sorted(commands))) PY } normalized_process_commands() { local container="$1" local top_file="${tmp_dir}/${container}-processes.txt" docker top "$container" -eo pid,args > "$top_file" normalize_process_file "$top_file" } capture_process_baseline() { local container="$1" local expected current local deadline=$((SECONDS + 10)) expected=$'addon-supervisor\ncloakserve\nstelloauth' while (( SECONDS < deadline )); do current="$(normalized_process_commands "$container")" if [ "$current" = "$expected" ]; then printf '%s\n' "$current" return fi sleep 0.25 done printf 'expected startup process baseline:\n%s\ncurrent process commands:\n%s\n' \ "$expected" "$current" >&2 docker top "$container" >&2 fail "$container did not reach the expected startup process baseline" } assert_processes_return_to_baseline() { local container="$1" local baseline="$2" local current local deadline=$((SECONDS + 10)) while (( SECONDS < deadline )); do current="$(normalized_process_commands "$container")" if [ "$current" = "$baseline" ]; then return fi sleep 0.25 done printf 'expected process baseline after CDP close:\n%s\ncurrent process commands:\n%s\n' \ "$baseline" "$current" >&2 docker top "$container" >&2 fail "$container retained browser or profile processes after CDP close" } assert_loopback_cdp_listener() { local container="$1" local tcp_artifact="$2" local tcp6_artifact="$3" docker exec "$container" cat /proc/net/tcp > "$tcp_artifact" docker exec "$container" cat /proc/net/tcp6 > "$tcp6_artifact" docker exec -i "$container" python3 - <<'PY' expected = f"0100007F:{9222:04X}" if expected != "0100007F:2406": raise SystemExit(f"unexpected 9222 hexadecimal encoding: {expected}") listeners = {"/proc/net/tcp": set(), "/proc/net/tcp6": set()} for table in ("/proc/net/tcp", "/proc/net/tcp6"): with open(table, encoding="ascii") as handle: next(handle) for line in handle: fields = line.split() if len(fields) < 4 or fields[3] != "0A": continue local_address = fields[1].upper() if local_address.rsplit(":", 1)[-1] != "2406": continue listeners[table].add(local_address) if table == "/proc/net/tcp6": raise SystemExit(f"IPv6 CDP listener present: {local_address}") if listeners["/proc/net/tcp"] != {expected}: raise SystemExit( f"IPv4 CDP listeners = {sorted(listeners['/proc/net/tcp'])}, want [{expected}]" ) PY } probe_and_close_cdp() { local container="$1" docker exec -i "$container" python3 - <<'PY' import json import urllib.request opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) version_url = "http://127.0.0.1:9222/json/version?fingerprint=runtime-readiness" close_url = "http://127.0.0.1:9222/fingerprint/runtime-readiness/close" with opener.open(version_url, timeout=10) as response: if response.status != 200: raise SystemExit(f"CDP version status {response.status}") document = json.load(response) websocket_url = document.get("webSocketDebuggerUrl") if not isinstance(websocket_url, str) or not websocket_url: raise SystemExit("CDP response lacks webSocketDebuggerUrl") request = urllib.request.Request(close_url, data=b"", method="POST") with opener.open(request, timeout=10) as response: if response.status != 200: raise SystemExit(f"CDP close status {response.status}") response.read() PY } post_invalid_worker() { local port="$1" local response_artifact="$2" python3 - "$port" "$response_artifact" <<'PY' import json import pathlib import sys import urllib.error import urllib.request port, artifact = sys.argv[1:] body = { "url": ( "https://example.invalid/am/oauth2/authorize" "?redirect_uri=sentinel%3A%2F%2Fcallback" "&code=SENTINEL_OAUTH_CODE_5c88" "&access_token=SENTINEL_ACCESS_TOKEN_1d62" "&refresh_token=SENTINEL_REFRESH_TOKEN_4e73" "&cookie=SENTINEL_COOKIE_7b21" ), "email": "sentinel-email@example.invalid", "password": "SENTINEL_PASSWORD_9a34", "cookie": "SENTINEL_COOKIE_7b21", "oauth_code": "SENTINEL_OAUTH_CODE_5c88", "access_token": "SENTINEL_ACCESS_TOKEN_1d62", "refresh_token": "SENTINEL_REFRESH_TOKEN_4e73", } request = urllib.request.Request( f"http://127.0.0.1:{port}/worker", data=json.dumps(body, separators=(",", ":")).encode(), headers={"Content-Type": "application/json"}, method="POST", ) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) try: with opener.open(request, timeout=10) as response: status = response.status response_body = response.read() except urllib.error.HTTPError as error: status = error.code response_body = error.read() if status != 400: raise SystemExit(f"invalid worker status {status}, want 400") pathlib.Path(artifact).write_bytes(response_body) PY } assert_no_9222_mapping() { local container="$1" local mapping mapping="$(docker port "$container" 9222/tcp 2>/dev/null || true)" [ -z "$mapping" ] || fail "container 9222 is mapped: $mapping" } scan_logs() { local container="$1" local log_file="${tmp_dir}/${container}.log" docker logs "$container" > "$log_file" 2>&1 for sentinel in "${sentinels[@]}"; do if grep -Fq "$sentinel" "$log_file"; then fail "$container logs contain sentinel $sentinel" fi done if grep -Fq "worker OAuth request" "$log_file"; then fail "$container began an OAuth flow for the rejected worker body" fi } stop_and_assert() { local container="$1" local timing_artifact="$2" local started_ns ended_ns elapsed state started_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" docker stop --time 10 "$container" >/dev/null ended_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" elapsed="$(python3 - "$started_ns" "$ended_ns" <<'PY' import sys print((int(sys.argv[2]) - int(sys.argv[1])) / 1_000_000_000) PY )" printf 'seconds=%s\n' "$elapsed" > "$timing_artifact" python3 - "$elapsed" <<'PY' import sys if float(sys.argv[1]) > 10.0: raise SystemExit(f"container stop exceeded 10 seconds: {sys.argv[1]}") PY state="$(docker inspect --format '{{.State.Status}} {{.State.ExitCode}} {{.State.Pid}}' "$container")" [ "$state" = "exited 0 0" ] || fail "$container state is $state, want exited 0 with PID 0" } if [ "${1:-}" = "--normalize-processes" ]; then [ "$#" -eq 2 ] || fail "--normalize-processes requires one docker top file" normalize_process_file "$2" exit fi [ "$#" -eq 0 ] || fail "unexpected runtime test arguments" mkdir -p "$artifacts_dir" write_options if [ "${SKIP_BUILD:-0}" != "1" ]; then docker buildx build \ --platform linux/amd64 \ --build-arg BUILD_ARCH=amd64 \ --load \ --tag "$image" \ "$repo_root/stelloauth" fi start_container "$first_container" first_port="$(host_port "$first_container")" wait_ready "$first_container" "$first_port" first_baseline="$(capture_process_baseline "$first_container")" assert_no_9222_mapping "$first_container" assert_loopback_cdp_listener \ "$first_container" \ "${artifacts_dir}/runtime-proc-net-tcp.txt" \ "${artifacts_dir}/runtime-proc-net-tcp6.txt" probe_and_close_cdp "$first_container" assert_processes_return_to_baseline "$first_container" "$first_baseline" post_invalid_worker "$first_port" "${artifacts_dir}/runtime-invalid-worker-response.json" scan_logs "$first_container" stop_and_assert "$first_container" "${artifacts_dir}/runtime-first-stop.txt" scan_logs "$first_container" start_container "$second_container" second_port="$(host_port "$second_container")" wait_ready "$second_container" "$second_port" second_baseline="$(capture_process_baseline "$second_container")" assert_no_9222_mapping "$second_container" assert_loopback_cdp_listener \ "$second_container" \ "${artifacts_dir}/runtime-restart-proc-net-tcp.txt" \ "${artifacts_dir}/runtime-restart-proc-net-tcp6.txt" probe_and_close_cdp "$second_container" assert_processes_return_to_baseline "$second_container" "$second_baseline" sleep 5 docker stats --no-stream "$second_container" > "${artifacts_dir}/runtime-docker-stats.txt" docker top "$second_container" > "${artifacts_dir}/runtime-docker-top.txt" docker image inspect "$image" --format '{{.Size}}' > "${artifacts_dir}/runtime-image-size-bytes.txt" docker image inspect "$image" --format '{{json .Config.ExposedPorts}}' > "${artifacts_dir}/runtime-image-exposed-ports.json" docker inspect "$second_container" --format '{{json .HostConfig.PortBindings}}' > "${artifacts_dir}/runtime-host-port-bindings.json" stop_and_assert "$second_container" "${artifacts_dir}/runtime-second-stop.txt" scan_logs "$second_container" printf 'runtime acceptance PASS: root, CDP, loopback bind, invalid worker, redaction, stop, restart\n'