wake-alarm/wake_alarm/tests/test_alarm_session_refire.py
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

135 lines
5.1 KiB
Python

"""Tests for the re-fire decision driven by the session record.
Split from ``test_alarm_session.py`` for the 250-line cap. The header is copied
whole: without the autouse ``session_file`` fixture these tests would read and
write the *production* record, which the next real trigger tick would act on.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
from typing import TYPE_CHECKING
import pytest
from wake_alarm import _alarm_session
from wake_alarm._alarm_session import (
STATE_RINGING,
finish_session,
should_refire,
start_session,
unfinished_session_age,
)
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def session_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the record at a temporary file, never the real one."""
path = tmp_path / "alarm_session.json"
monkeypatch.setattr(_alarm_session, "ALARM_SESSION_FILE", path)
return path
class TestShouldRefire:
"""The decision _trigger acts on."""
def test_no_record_does_not_refire(self) -> None:
"""The ordinary case: nothing was cut short."""
assert should_refire() is False
def test_ringing_record_refires(self) -> None:
"""The whole point: a power-off mid-alarm rings again."""
start_session("wake-20260827-0700")
assert should_refire() is True
def test_finished_record_does_not_refire(self) -> None:
"""A dismissed alarm stays dismissed."""
start_session("wake-20260827-0700")
finish_session()
assert should_refire() is False
def test_stale_record_does_not_refire(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Bounded on purpose.
An alarm that died before dismissal leaves a record nothing will ever
clear; unbounded, this would restart the alarm every minute forever.
"""
start_session("wake-20260827-0700")
much_later = datetime.now(tz=timezone.utc).astimezone() + timedelta(days=1)
with caplog.at_level("INFO"):
assert should_refire(now=much_later) is False
assert "Ignoring an unfinished alarm session" in caplog.text
def test_a_backward_clock_jump_still_refires(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Fails *closed*, which is the whole point of the record.
NTP correcting after a resume moves the clock backwards, leaving the
record stamped in the future. Discarding it there would mean: alarm
fires on a fast clock, clock is corrected, and the power-off escape
quietly works again -- on a machine that hibernates nightly, i.e. the
exact scenario this record exists to close.
"""
start_session("wake-20260827-0700")
corrected = datetime.now(tz=timezone.utc).astimezone() - timedelta(minutes=5)
with caplog.at_level("INFO"):
assert should_refire(now=corrected) is True
assert "in the future" in caplog.text
def test_a_wildly_future_record_is_ignored(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Bounded, so a broken RTC cannot create an endless re-fire."""
start_session("wake-20260827-0700")
way_earlier = datetime.now(tz=timezone.utc).astimezone() - timedelta(hours=6)
with caplog.at_level("WARNING"):
assert should_refire(now=way_earlier) is False
assert "beyond the" in caplog.text
def test_missing_timestamp_is_ignored(
self,
session_file: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A record with no usable fired_at cannot be aged, so it is dropped."""
monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True)
session_file.write_text(json.dumps({"state": STATE_RINGING}))
with caplog.at_level("WARNING"):
assert unfinished_session_age() is None
assert "no usable fired_at" in caplog.text
@pytest.mark.parametrize(
"fired_at",
[
"not-a-timestamp",
# Naive: without an offset the elapsed-time comparison is
# meaningless, which is exactly the ambiguity this record avoids.
"2026-08-27T07:00:00",
],
)
def test_unusable_timestamps_are_ignored(
self, session_file: Path, monkeypatch: pytest.MonkeyPatch, fired_at: str
) -> None:
"""Neither an unparsable nor a naive timestamp can drive a re-fire."""
monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True)
session_file.write_text(
json.dumps({"state": STATE_RINGING, "fired_at": fired_at})
)
assert should_refire() is False
def test_age_is_reported_for_a_live_session(self) -> None:
"""The age helper is what makes the bound observable."""
start_session("wake-20260827-0700")
later = datetime.now(tz=timezone.utc).astimezone() + timedelta(seconds=90)
age = unfinished_session_age(now=later)
assert age is not None
assert 89 <= age <= 92