Make credential setup keyboard-safe and verifiable

This commit is contained in:
Gabriel Brown
2026-08-18 06:14:07 -04:00
parent de1b8a7673
commit 3a2295ff53
5 changed files with 70 additions and 9 deletions
@@ -211,16 +211,28 @@ SettingsPage {
spacing: 8
SettingsButton {
id: clearTokenButton
text: "Clear token"
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistantConfig.clearToken()
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
}
SettingsButton {
id: saveHomeConfigButton
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
tone: "accent"
enabled: !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
onClicked: root.saveHomeAssistantConfig()
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
}
}
}
@@ -48,6 +48,7 @@ Rectangle {
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
@@ -18,6 +18,7 @@ Singleton {
property bool configured: false
property string lastError: ""
property string pendingPayload: ""
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
readonly property bool busy: statusProc.running || writeProc.running
signal configurationSaved
@@ -52,7 +53,7 @@ Singleton {
root.lastError = "";
if (saved) {
root.configurationSaved();
HomeAssistant.refresh();
root.refreshHomeAssistant();
}
}
@@ -102,7 +103,6 @@ Singleton {
command: [root.helperPath, "write"]
stdinEnabled: true
stdout: StdioCollector {
id: writeOutput
onStreamFinished: root.applyResult(this.text, true)
}
onStarted: {
@@ -5,7 +5,21 @@ import QtQuick
import qs.services
ShellRoot {
Component.onCompleted: HomeAssistant.fixtureMode = true
id: root
property int refreshCalls: 0
FileView {
id: tokenFile
path: Quickshell.env("PANAMA_TEST_TOKEN_FILE")
blockLoading: true
printErrors: false
}
Component.onCompleted: {
HomeAssistant.fixtureMode = true;
HomeAssistantConfig.refreshHomeAssistant = function() { root.refreshCalls++; };
}
IpcHandler {
target: "home-assistant-config-test"
@@ -14,6 +28,10 @@ ShellRoot {
return HomeAssistantConfig.save(url, entities, "");
}
function saveWithToken(url: string, entities: string): bool {
return HomeAssistantConfig.save(url, entities, tokenFile.text());
}
function clearToken(): bool {
return HomeAssistantConfig.clearToken();
}
@@ -30,7 +48,8 @@ ShellRoot {
configured: HomeAssistantConfig.configured,
busy: HomeAssistantConfig.busy,
lastError: HomeAssistantConfig.lastError,
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === ""
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "",
refreshCalls: root.refreshCalls
});
}
}
@@ -6,6 +6,7 @@ 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/HomePhonePage.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"
@@ -34,8 +35,13 @@ 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.
@@ -124,12 +130,16 @@ 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='qml-private-token'" \
"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" "$@"
}
@@ -151,16 +161,35 @@ for _ in $(seq 1 60); do
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true' \
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 save \
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-new.example.test"
and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break
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