wake-alarm/scripts/vmbox/guest_cases.sh
Krzysztof kuhy Rudnicki 97d32c803d Phase 6: vmbox becomes the gate for the system scripts (item 10)
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.

Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.

Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).

**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**

1. The inhibitor claimed a block it did not have. Popen succeeding only means
   the binary launched; logind can still refuse -- the guest's polkit denies a
   non-interactive session outright ("Failed to inhibit: Access denied", exit
   1) while the alarm cheerfully logged the power key as blocked.
   take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
   the child survived, returning None and logging the refusal otherwise.

2. An unsigned session record was written and then rejected on read. With no
   HMAC key on the machine (/etc/gatelock absent, as in the guest),
   verify_entry_hmac returns False for a *missing* key exactly as it does for
   a forgery -- so every session record was unreadable and item 2's power-off
   defence silently did not exist. An entry with no "hmac" field is now
   honoured with a loud warning; a present-but-wrong signature is still
   rejected, since that one really is tampering.

Two harness traps worth recording, both hit while building it:

- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
  guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
  guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
  harness greps for it; verified by breaking a case on purpose and watching
  the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
  and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
  into /home and fail with permissions errors that look like a guest fault.

Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.

Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.

Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.

455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 20:49:16 +02:00

195 lines
6.6 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# wake_alarm vmbox harness — the cases that run INSIDE the guest.
#
# Kept as its own file rather than inlined into `vm run` for two reasons: the
# vmbox runner wraps its argument in `sh -c` and chokes on a multi-line
# command, and a real file gets shellcheck like every other script here.
#
# Each case prints "PASS <name>" or "FAIL <name>: <why>" and the script exits
# non-zero if any failed, so the host adjudicates on an exit code rather than
# by reading prose.
# ============================================================================
set -uo pipefail
readonly PY=/usr/bin/python3
# The helper .py files ship beside this script, so each gets real linting
# and type checking instead of hiding inside a heredoc.
CASE_DIR="$(dirname "$(readlink -f "$0")")"
readonly CASE_DIR
FAILURES=0
pass() {
echo "PASS $1"
}
fail() {
echo "FAIL $1: $2"
FAILURES=$((FAILURES + 1))
}
# --- Case: the package installs and imports from SYSTEM python ---------------
# The units run /usr/bin/python directly, never a venv, so this is the check
# that actually matters (CLAUDE.md).
case_imports() {
local name="imports-from-system-python"
if "$PY" -c "import wake_alarm, gatelock" 2>/dev/null; then
pass "$name"
else
fail "$name" "wake_alarm or gatelock missing from /usr/bin/python3"
fi
}
# --- Case: units land in the right enablement state --------------------------
# wake-alarm.service must stay `static`. An [Install] section there fires the
# alarm at *login* time, because _should_run_alarm() has no time gate.
case_units() {
local name state
name="trigger-timer-enabled"
state="$(systemctl --user is-enabled wake-alarm-trigger.timer 2>&1)"
if [[ "$state" == "enabled" ]]; then
pass "$name"
else
fail "$name" "expected enabled, got '$state'"
fi
name="alarm-service-never-auto-starts"
state="$(systemctl --user is-enabled wake-alarm.service 2>&1)"
if [[ "$state" == "static" || "$state" == "disabled" ]]; then
pass "$name"
else
fail "$name" "wake-alarm.service is '$state'; it must never auto-start"
fi
}
# --- Case: the shutdown wrapper shadows /usr/bin/shutdown --------------------
case_shutdown_wrapper() {
local name="shutdown-wrapper-shadows-real-shutdown"
local resolved
resolved="$(command -v shutdown)"
if [[ "$resolved" == "/usr/local/bin/shutdown" ]]; then
pass "$name"
else
fail "$name" "shutdown resolves to '$resolved', not the wrapper"
fi
}
# --- Case: the wrapper reads the alarm cache, not the network ----------------
# It parses ~/.config/wake_alarm/alarm_cache.json with jq. The shape is the
# only coupling left between the wrapper and _sync.py's _write_cache().
case_wrapper_reads_cache() {
local name="wrapper-parses-the-alarm-cache"
local cache="$HOME/.config/wake_alarm/alarm_cache.json"
mkdir -p "$(dirname "$cache")"
printf '{"hour": 6, "minute": 45, "fetched_at": %d}\n' "$(date +%s)" > "$cache"
if jq -e '.hour == 6 and .minute == 45' "$cache" >/dev/null 2>&1; then
pass "$name"
else
fail "$name" "jq could not read the cache shape the wrapper expects"
fi
rm -f "$cache"
}
# --- Case: the trigger decides without crashing ------------------------------
case_trigger_dry_run() {
local name="trigger-dry-run"
if "$PY" -m wake_alarm._trigger --dry-run 2>/dev/null | grep -q "firing"; then
pass "$name"
else
fail "$name" "--dry-run produced no decision line"
fi
}
# --- Case: the inhibitor reports honestly ------------------------------------
# The guest's polkit denies an inhibit from a non-interactive ssh session
# ("Failed to inhibit: Access denied", exit 1), so what is asserted here is
# NOT that the power key gets blocked -- it is that the code never *claims*
# a block it did not get. That distinction is the bug this case found: Popen
# succeeded, the alarm logged "power key inhibited", and nothing was.
#
# The two accepted outcomes are therefore "took it and released it" and
# "logind refused and we said so". A silent third state is the failure.
# See DOCS-vmbox.md for why the block itself cannot be proven in a guest.
case_inhibitor() {
local name="inhibitor-never-claims-a-block-it-lacks"
"$PY" "$CASE_DIR/check_inhibitor.py" > /tmp/inhibit_result 2>/dev/null
local result
result="$(cat /tmp/inhibit_result 2>/dev/null)"
case "$result" in
"HELD FREED"|"REFUSED")
pass "$name ($result)"
;;
*)
fail "$name" "expected 'HELD FREED' or 'REFUSED', got '$result'"
;;
esac
}
# --- Case: an unfinished session re-fires --------------------------------
# The one case a VM tests better than the host: a real poweroff mid-alarm.
# Here we assert the decision the next boot's trigger tick would make.
case_refire() {
local name="cut-short-alarm-refires"
"$PY" "$CASE_DIR/check_refire.py" > /tmp/refire_result 2>/dev/null
local result
result="$(cat /tmp/refire_result 2>/dev/null)"
if [[ "$result" == "False-True-False" ]]; then
pass "$name"
else
fail "$name" "expected False-True-False, got '$result'"
fi
rm -f "$HOME/.config/wake_alarm/alarm_session.json"
}
# --- Case: the alarm window really opens on a real X server ------------------
# Verified by the HOST via `vm screenshot`; this half only launches it.
case_alarm_window() {
local name="alarm-window-opens"
"$PY" -m wake_alarm._alarm --demo --trigger-now >/tmp/alarm.log 2>&1 &
local pid=$!
sleep 12
if kill -0 "$pid" 2>/dev/null; then
pass "$name"
echo "$pid" > /tmp/alarm.pid
else
fail "$name" "the alarm exited early: $(tail -3 /tmp/alarm.log)"
fi
}
main() {
case "${1:-all}" in
window)
# Run alone: it leaves a fullscreen window up for the host to
# screenshot, which would hide every later case's output.
case_alarm_window
;;
all)
case_imports
case_units
case_shutdown_wrapper
case_wrapper_reads_cache
case_trigger_dry_run
case_inhibitor
case_refire
;;
*)
echo "usage: $0 [all|window]" >&2
exit 2
;;
esac
echo "---"
if [[ $FAILURES -gt 0 ]]; then
echo "$FAILURES case(s) failed"
exit 1
fi
# The host greps for this exact string. `vm run` reports the VM's verdict
# rather than this script's exit code, so an exit status alone would let
# the gate fail open.
echo "ALL-CASES-PASSED"
}
main "$@"