Files
Panama/tests/quickshell/home-assistant-config-contract
T
Gabriel Brown 7578348db1 Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action
tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the
findings card — updates fold in, the reclaim-space prompt is gone on
purpose — and adds glance cards, the next calendar event, and weather.

My Home groups every light by Home Assistant area: the helper gained an
`areas` command (one REST template render, no websocket), and the rooms
degrade to a flat list on setups without areas. The favorites editor and
connection card moved intact. Phone gains a vitals strip — battery and
cell signal read from KDE Connect's plugin D-Bus objects, where absence
is data, not an error — beside ring, clipboard, send-a-file, and the
BlueBubbles handoff.

The retired home-phone id resolves to my-home forever via a new alias
map in SettingsRoutes (with a hasOwnProperty guard so prototype names
cannot leak into settingsPage). Storage no longer claims 0 B free — the
old page read a field the disks helper never emitted.

Contracts updated alongside; per the new workflow, the full suite runs
once at the end of the redesign (see the test backlog note).

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 22:06:18 -04:00

218 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/MyHomePage.qml"
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
env_file="$work/env"
fail() {
printf 'Home Assistant config contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
if declare -F qs_for_test >/dev/null; then
qs_for_test kill >/dev/null 2>&1 || true
fi
rm -rf "$work"
}
trap cleanup EXIT
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
[[ -f "$service" ]] || fail 'credential service is missing'
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
fail 'credential data can reach a process command line'
fi
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable'
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity'
rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity'
rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order'
rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation'
cat >"$env_file" <<'EOF'
# Existing private shell settings must survive byte-for-byte.
export KEEP_ME='untouched value'
export JIRA_CREDENTIALS='unrelated-secret'
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
EOF
chmod 0644 "$env_file"
run_helper() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
}
status="$(run_helper status)" || fail 'status failed for a valid private env file'
jq -e '.configured == true and .tokenConfigured == true
and .url == "https://old.example.test"
and .entities == ["light.old"] and (has("token") | not)' \
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
fail 'status output leaked a secret'
fi
secret='ha-secret-must-never-appear-in-ps-or-output'
payload="$work/payload.json"
jq -cn --arg token "$secret" '{
url: "https://home.example.test/",
token: $token,
entities: ["light.kitchen", "light.desk", "light.kitchen"]
}' >"$payload"
# Keep stdin open long enough to prove the token is absent from the helper's
# process arguments. The secret lives only in the private payload file/stdin.
fifo="$work/input.fifo"
mkfifo "$fifo"
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
helper_pid=$!
for _ in $(seq 1 30); do
kill -0 "$helper_pid" 2>/dev/null && break
sleep 0.05
done
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
fail 'token appeared in the credential helper process arguments'
fi
cp "$payload" "$fifo"
wait "$helper_pid" || fail 'stdin credential write failed'
write_result="$(cat "$work/write.out")"
jq -e '.ok == true and .configured == true and .tokenConfigured == true
and .url == "https://home.example.test"
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
fail 'credential helper output leaked a secret'
fi
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
# Omitting token preserves it; an explicit empty token clears it.
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
| run_helper write >/dev/null || fail 'non-secret update failed'
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
cleared="$(run_helper status)"
jq -e '.configured == false and .tokenConfigured == false
and .url == "https://new.example.test"
and .entities == ["light.office", "light.hall"]' \
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
&& fail 'invalid URL was accepted'
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
# Exercise the actual QML Process.write() boundary with a pre-existing token.
# The IPC carries only non-secret fields; the helper must preserve the token.
config_path="$work/quickshell"
harness="$config_path/home-assistant-config-harness.qml"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cp "$harness_fixture" "$harness"
printf '%s\n' \
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
"export PANAMA_HOME_ASSISTANT_TOKEN=''" \
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
chmod 0600 "$env_file"
qml_token_file="$work/qml-token"
printf '%s' 'qml-private-token' >"$qml_token_file"
chmod 0600 "$qml_token_file"
qs_for_test() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
PANAMA_TEST_TOKEN_FILE="$qml_token_file" \
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
qs -p "$harness" "$@"
}
stop_harness() {
qs_for_test kill >/dev/null 2>&1 || true
}
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 60); do
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|| fail 'credential QML harness did not start'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
qs_for_test ipc call home-assistant-config-test saveWithToken \
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|| fail 'QML service refused a private token-file update'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .configured == true and .tokenConfigured == true
and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true
and .url == "https://qml-new.example.test"
and .entities == ["light.office", "light.hall"]
and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \
<<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status"
rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token'
if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then
fail 'QML token appeared in the shell process arguments'
fi
qs_for_test ipc call home-assistant-config-test save \
https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \
|| fail 'QML service refused a non-secret update'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-final.example.test"
and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|| fail 'QML service refused token clear'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == false and .tokenConfigured == false
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
if rg -Fq 'qml-private-token' "$env_file"; then
fail 'QML token clear left the old token in the private env file'
fi
stop_harness
trap - EXIT
cleanup
printf 'Home Assistant config contract: PASS\n'