mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
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
177 lines
7.0 KiB
Python
177 lines
7.0 KiB
Python
"""Tests for the power-key inhibitor held while the alarm rings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from wake_alarm import _inhibit
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_stale_scan(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Stub the stale scan, so taking an inhibitor never scans the real host.
|
|
|
|
Not autouse: TestStaleCleanup exercises the real function, and an autouse
|
|
stub would silently make every one of those tests assert nothing.
|
|
"""
|
|
monkeypatch.setattr(_inhibit, "kill_stale_inhibitors", lambda: 0)
|
|
|
|
|
|
def test_resolves_systemd_inhibit_from_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The binary is looked up on PATH, never hardcoded."""
|
|
monkeypatch.setattr(_inhibit.shutil, "which", lambda name: f"/usr/bin/{name}")
|
|
assert _inhibit._systemd_inhibit_path() == "/usr/bin/systemd-inhibit"
|
|
|
|
|
|
@pytest.mark.usefixtures("_no_stale_scan")
|
|
class TestTakeInhibitor:
|
|
"""Taking the inhibitor that blocks the power button."""
|
|
|
|
def test_spawns_a_blocking_inhibitor(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The child blocks handle-power-key and self-terminates."""
|
|
monkeypatch.setattr(
|
|
_inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit"
|
|
)
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_popen(cmd: list[str], **_kwargs: object) -> MagicMock:
|
|
captured.append(cmd)
|
|
live = MagicMock(pid=4242)
|
|
# Still running after the confirm wait: logind accepted it.
|
|
live.wait.side_effect = subprocess.TimeoutExpired("systemd-inhibit", 0.5)
|
|
return live
|
|
|
|
monkeypatch.setattr(_inhibit.subprocess, "Popen", fake_popen)
|
|
|
|
assert _inhibit.take_power_key_inhibitor() is not None
|
|
|
|
cmd = captured[0]
|
|
assert "--mode=block" in cmd
|
|
assert "--what=handle-power-key:shutdown:idle" in cmd
|
|
# Never `sleep infinity`: an orphaned blocker would hold the power
|
|
# button down until the next reboot.
|
|
assert cmd[-2] == "sleep"
|
|
assert int(cmd[-1]) > 0
|
|
|
|
def test_missing_binary_never_stops_the_alarm(
|
|
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""No systemd-inhibit means an unprotected alarm, not no alarm."""
|
|
monkeypatch.setattr(_inhibit, "_systemd_inhibit_path", lambda: None)
|
|
with caplog.at_level("WARNING"):
|
|
assert _inhibit.take_power_key_inhibitor() is None
|
|
assert "systemd-inhibit not on PATH" in caplog.text
|
|
|
|
def test_a_refused_inhibit_is_reported_as_none(
|
|
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Claiming a block we did not get is worse than reporting none.
|
|
|
|
Popen succeeding only means the *binary* launched. logind can still
|
|
refuse -- polkit denies a non-interactive session outright, which a
|
|
vmbox run turned up: systemd-inhibit printed "Failed to inhibit:
|
|
Access denied" and exited 1 while the alarm logged the power key as
|
|
blocked.
|
|
"""
|
|
monkeypatch.setattr(
|
|
_inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit"
|
|
)
|
|
refused = MagicMock(returncode=1)
|
|
refused.wait.return_value = 1
|
|
refused.stderr.read.return_value = b"Failed to inhibit: Access denied"
|
|
monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: refused)
|
|
with caplog.at_level("WARNING"):
|
|
assert _inhibit.take_power_key_inhibitor() is None
|
|
assert "logind refused" in caplog.text
|
|
assert "Access denied" in caplog.text
|
|
|
|
def test_a_refusal_with_no_stderr_still_reports(
|
|
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A silent refusal is still a refusal, and still must not be claimed."""
|
|
monkeypatch.setattr(
|
|
_inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit"
|
|
)
|
|
refused = MagicMock(returncode=1, stderr=None)
|
|
refused.wait.return_value = 1
|
|
monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: refused)
|
|
with caplog.at_level("WARNING"):
|
|
assert _inhibit.take_power_key_inhibitor() is None
|
|
assert "no detail" in caplog.text
|
|
|
|
def test_spawn_failure_is_survivable(
|
|
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A failed spawn is logged and the alarm rings on."""
|
|
monkeypatch.setattr(
|
|
_inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit"
|
|
)
|
|
|
|
def boom(_cmd: list[str], **_kwargs: object) -> MagicMock:
|
|
msg = "no fork for you"
|
|
raise OSError(msg)
|
|
|
|
monkeypatch.setattr(_inhibit.subprocess, "Popen", boom)
|
|
with caplog.at_level("WARNING"):
|
|
assert _inhibit.take_power_key_inhibitor() is None
|
|
assert "rings unprotected" in caplog.text
|
|
|
|
def test_clears_a_stale_inhibitor_first(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A blocker from a crashed alarm is cleared before a new one is taken."""
|
|
monkeypatch.setattr(
|
|
_inhibit, "_systemd_inhibit_path", lambda: "/usr/bin/systemd-inhibit"
|
|
)
|
|
monkeypatch.setattr(_inhibit.subprocess, "Popen", lambda *a, **k: MagicMock())
|
|
cleared: list[str] = []
|
|
|
|
def fake_clear() -> int:
|
|
cleared.append("cleared")
|
|
return 0
|
|
|
|
monkeypatch.setattr(_inhibit, "kill_stale_inhibitors", fake_clear)
|
|
_inhibit.take_power_key_inhibitor()
|
|
assert cleared == ["cleared"]
|
|
|
|
|
|
class TestReleaseInhibitor:
|
|
"""Releasing the inhibitor when the alarm ends."""
|
|
|
|
def test_none_is_a_no_op(self) -> None:
|
|
"""on_close() runs on every path, including ones that took nothing."""
|
|
_inhibit.release_power_key_inhibitor(None)
|
|
|
|
def test_already_exited_is_a_no_op(self) -> None:
|
|
"""A child that self-terminated is not signalled again."""
|
|
process = MagicMock()
|
|
process.poll.return_value = 0
|
|
_inhibit.release_power_key_inhibitor(process)
|
|
process.terminate.assert_not_called()
|
|
|
|
def test_terminates_a_live_inhibitor(self) -> None:
|
|
"""The ordinary path: SIGTERM, then reap."""
|
|
process = MagicMock()
|
|
process.poll.return_value = None
|
|
_inhibit.release_power_key_inhibitor(process)
|
|
process.terminate.assert_called_once_with()
|
|
process.wait.assert_called_once_with(timeout=5)
|
|
process.kill.assert_not_called()
|
|
|
|
def test_kills_an_inhibitor_that_ignores_sigterm(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A stuck child must not outlive the alarm holding the power key."""
|
|
process = MagicMock()
|
|
process.poll.return_value = None
|
|
process.wait.side_effect = subprocess.TimeoutExpired("systemd-inhibit", 5)
|
|
with caplog.at_level("WARNING"):
|
|
_inhibit.release_power_key_inhibitor(process)
|
|
process.kill.assert_called_once_with()
|
|
assert "ignored SIGTERM" in caplog.text
|