Make credential setup keyboard-safe and verifiable
This commit is contained in:
@@ -211,16 +211,28 @@ SettingsPage {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
|
id: clearTokenButton
|
||||||
text: "Clear token"
|
text: "Clear token"
|
||||||
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
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()
|
onClicked: HomeAssistantConfig.clearToken()
|
||||||
|
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||||
|
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
SettingsButton {
|
||||||
|
id: saveHomeConfigButton
|
||||||
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
||||||
tone: "accent"
|
tone: "accent"
|
||||||
enabled: !HomeAssistantConfig.busy
|
enabled: !HomeAssistantConfig.busy
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: activeFocus ? Theme.fg : "transparent"
|
||||||
onClicked: root.saveHomeAssistantConfig()
|
onClicked: root.saveHomeAssistantConfig()
|
||||||
|
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
|
||||||
|
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Rectangle {
|
|||||||
anchors.rightMargin: 8
|
anchors.rightMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
activeFocusOnTab: true
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||||
selectedTextColor: Theme.fg
|
selectedTextColor: Theme.fg
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Singleton {
|
|||||||
property bool configured: false
|
property bool configured: false
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property string pendingPayload: ""
|
property string pendingPayload: ""
|
||||||
|
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
|
||||||
readonly property bool busy: statusProc.running || writeProc.running
|
readonly property bool busy: statusProc.running || writeProc.running
|
||||||
|
|
||||||
signal configurationSaved
|
signal configurationSaved
|
||||||
@@ -52,7 +53,7 @@ Singleton {
|
|||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
if (saved) {
|
if (saved) {
|
||||||
root.configurationSaved();
|
root.configurationSaved();
|
||||||
HomeAssistant.refresh();
|
root.refreshHomeAssistant();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,6 @@ Singleton {
|
|||||||
command: [root.helperPath, "write"]
|
command: [root.helperPath, "write"]
|
||||||
stdinEnabled: true
|
stdinEnabled: true
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
id: writeOutput
|
|
||||||
onStreamFinished: root.applyResult(this.text, true)
|
onStreamFinished: root.applyResult(this.text, true)
|
||||||
}
|
}
|
||||||
onStarted: {
|
onStarted: {
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ import QtQuick
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
ShellRoot {
|
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 {
|
IpcHandler {
|
||||||
target: "home-assistant-config-test"
|
target: "home-assistant-config-test"
|
||||||
@@ -14,6 +28,10 @@ ShellRoot {
|
|||||||
return HomeAssistantConfig.save(url, entities, "");
|
return HomeAssistantConfig.save(url, entities, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveWithToken(url: string, entities: string): bool {
|
||||||
|
return HomeAssistantConfig.save(url, entities, tokenFile.text());
|
||||||
|
}
|
||||||
|
|
||||||
function clearToken(): bool {
|
function clearToken(): bool {
|
||||||
return HomeAssistantConfig.clearToken();
|
return HomeAssistantConfig.clearToken();
|
||||||
}
|
}
|
||||||
@@ -30,7 +48,8 @@ ShellRoot {
|
|||||||
configured: HomeAssistantConfig.configured,
|
configured: HomeAssistantConfig.configured,
|
||||||
busy: HomeAssistantConfig.busy,
|
busy: HomeAssistantConfig.busy,
|
||||||
lastError: HomeAssistantConfig.lastError,
|
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"
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||||
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||||
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.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"
|
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||||
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||||
env_file="$work/env"
|
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'
|
fail 'credential data can reach a process command line'
|
||||||
fi
|
fi
|
||||||
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
|
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.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 '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'
|
cat >"$env_file" <<'EOF'
|
||||||
# Existing private shell settings must survive byte-for-byte.
|
# 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"
|
cp "$harness_fixture" "$harness"
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
|
"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"
|
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
|
||||||
chmod 0600 "$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() {
|
qs_for_test() {
|
||||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
||||||
|
PANAMA_TEST_TOKEN_FILE="$qml_token_file" \
|
||||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
||||||
qs -p "$harness" "$@"
|
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
|
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
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"
|
<<<"$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 \
|
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'
|
|| fail 'QML service refused a non-secret update'
|
||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||||
jq -e '.busy == false and .url == "https://qml-new.example.test"
|
jq -e '.busy == false and .url == "https://qml-final.example.test"
|
||||||
and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
jq -e '.configured == true and .tokenConfigured == true
|
jq -e '.configured == true and .tokenConfigured == true
|
||||||
|
|||||||
Reference in New Issue
Block a user