Migrate to the shared gatelock backend

ScreenLocker now composes gatelock.GateRoot + gatelock.LockWindow for the
actual lock window instead of the inline WindowSetupMixin mechanics; the
verify/relaxed-day auxiliary windows (never the lock itself) stay as
plain Tk windows. The hand-copied _log_integrity.py is deleted in favor
of gatelock.log_integrity (the canonical, non-duplicated module). This
is the second of three migrations (diet_guard done, wake_alarm next).

Two deliberate behavior changes, both confirmed:
- dependencies = [] (pure stdlib) now includes gatelock, a documented
  departure from the prior zero-deps stance.
- production grab upgraded from single-attempt-then-local-fallback to
  diet_guard's retry-forever (robust to e.g. a fullscreen game holding
  the grab).

Net hardening as a side effect: run()/close() now go through gatelock's
signal-safe lifecycle, so SIGTERM/SIGINT restore VT switching on every
exit path -- previously only a clean close() did, leaving VT switching
disabled if the service was killed mid-lock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdT46zV8hESDvbgYMGDLt
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-21 20:11:16 +02:00
parent d8062a601f
commit 70cf6f5425
15 changed files with 239 additions and 349 deletions

View File

@ -89,6 +89,7 @@ repos:
- --jobs=4 - --jobs=4
additional_dependencies: additional_dependencies:
- pytest - pytest
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
rev: 1.7.10 rev: 1.7.10
@ -134,3 +135,17 @@ repos:
entry: python3 scripts/check_file_length.py entry: python3 scripts/check_file_length.py
language: system language: system
types_or: [python, shell] types_or: [python, shell]
- id: flutter-analyze
name: flutter analyze (workout app)
entry: bash -c 'cd stronglift_replacement/workout_app && flutter analyze'
language: system
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
pass_filenames: false
- id: flutter-test-coverage
name: flutter test --coverage 100% (workout app)
entry: bash scripts/check_flutter_coverage.sh
language: system
files: '^stronglift_replacement/workout_app/.*\.dart$'
pass_filenames: false
require_serial: true
stages: [pre-push]

View File

@ -3,7 +3,10 @@ name = "screen-locker"
version = "1.0.0" version = "1.0.0"
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] # pure stdlib — tkinter is bundled with Python # gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
dependencies = [
"gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0",
]
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"

View File

@ -4,6 +4,10 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
# Single-sourced from gatelock so the literal key path can't drift.
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage) SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
PHONE_PENALTY_DELAY_DEMO = 10 PHONE_PENALTY_DELAY_DEMO = 10
PHONE_PENALTY_DELAY_PRODUCTION = 100 PHONE_PENALTY_DELAY_PRODUCTION = 100
@ -28,17 +32,22 @@ SICK_COMMITMENT_PENALTY_DAYS = 2
# How long the commitment prompt stays visible after a workout unlock. # How long the commitment prompt stays visible after a workout unlock.
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15 COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
ADB_TIMEOUT = 15 ADB_TIMEOUT = 15
STRONGLIFTS_DB_REMOTE = ( # Workout app JSON candidate paths on the phone, in the order the app prefers
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" # when writing (see sync_service.dart). The app writes to the primary /sdcard/
# path first and only falls back to its app-external files dir if /sdcard/ is
# not writable, so the locker must check both — primary first.
WORKOUT_APP_JSON_REMOTES = (
"/sdcard/workout_result.json",
"/storage/emulated/0/Android/data/com.kuhy.workout_app/files/workout_result.json",
) )
MIN_WORKOUT_DURATION_MINUTES = 50 # Port the workout app's HTTP server listens on (no ADB/developer-options needed).
WORKOUT_HTTP_PORT = 8765
MIN_WORKOUT_DURATION_MINUTES = 60
MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
EARLY_BIRD_START_HOUR = 5 EARLY_BIRD_START_HOUR = 5
EARLY_BIRD_END_HOUR = 8 EARLY_BIRD_END_HOUR = 8
EARLY_BIRD_END_MINUTE = 30 EARLY_BIRD_END_MINUTE = 30
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
# HMAC key for signing workout log entries (root-owned, 0600)
HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
# Helper script path (relative to this file) # Helper script path (relative to this file)
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
# State file to track sick day usage and original config values # State file to track sick day usage and original config values

View File

@ -1,80 +0,0 @@
"""HMAC-based integrity checking for signed state entries."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from pathlib import Path
import secrets
_logger = logging.getLogger(__name__)
# HMAC key for signing state entries (root-owned, 0600)
HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
def _load_hmac_key() -> bytes | None:
"""Load HMAC key from the root-owned key file.
Returns the key bytes, or None if the file cannot be read.
"""
try:
return HMAC_KEY_FILE.read_bytes().strip()
except OSError:
_logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE)
return None
def _generate_hmac_key() -> bytes | None:
"""Generate a new HMAC key and write it to the key file.
The key file must be writable (requires root or setup script).
Returns the new key bytes, or None on failure.
"""
key = secrets.token_bytes(32)
try:
HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
HMAC_KEY_FILE.write_bytes(key)
except OSError:
_logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE)
return None
return key
def compute_entry_hmac(entry_data: dict[str, object]) -> str | None:
"""Compute HMAC-SHA256 for a state entry.
Args:
entry_data: The entry dict (without the 'hmac' field).
Returns:
Hex-encoded HMAC string, or None if the key is unavailable.
"""
key = _load_hmac_key()
if key is None:
return None
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
def verify_entry_hmac(entry: dict[str, object]) -> bool:
"""Verify HMAC signature of a state entry.
Args:
entry: The full entry dict including the 'hmac' field.
Returns:
True if the HMAC is valid, False if invalid or key unavailable.
"""
stored_hmac = entry.get("hmac")
if not isinstance(stored_hmac, str):
return False
key = _load_hmac_key()
if key is None:
return False
entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"}
payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(stored_hmac, expected)

View File

@ -12,6 +12,8 @@ import json
import logging import logging
from typing import Any from typing import Any
from gatelock.log_integrity import compute_entry_hmac
from screen_locker._constants import ( from screen_locker._constants import (
SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_7_DAYS,
SICK_BUDGET_PER_30_DAYS, SICK_BUDGET_PER_30_DAYS,
@ -23,7 +25,6 @@ from screen_locker._constants import (
SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
SICK_LOCKOUT_SECONDS, SICK_LOCKOUT_SECONDS,
) )
from screen_locker._log_integrity import compute_entry_hmac
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -15,8 +15,9 @@ from datetime import datetime, timezone
import json import json
import logging import logging
from gatelock.log_integrity import verify_entry_hmac
from screen_locker._constants import WAKE_STATE_FILE from screen_locker._constants import WAKE_STATE_FILE
from screen_locker._log_integrity import verify_entry_hmac
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -1,49 +1,28 @@
"""Window configuration and input-grab helpers for ScreenLocker.""" """Auxiliary (non-lock) window setup for ScreenLocker.
The fullscreen lock-window mechanics (overrideredirect, input grab,
VT-disable) now live in the shared ``gatelock`` package. This module keeps
only the screen-locker-specific windows that are never the lock itself: the
post-sick-day verification window, the demo close button, and the optional
relaxed-day prompt.
"""
from __future__ import annotations from __future__ import annotations
import contextlib
import logging
import shutil
import subprocess
import tkinter as tk import tkinter as tk
_logger = logging.getLogger(__name__)
class WindowSetupMixin: class WindowSetupMixin:
"""Mixin providing window setup, VT switching control, and input-grab helpers.""" """Mixin providing the screen-locker-specific auxiliary windows."""
def _disable_vt_switching(self) -> None: def on_focus_ready(self) -> None:
"""Disable VT switching in X11 while the lock is active. """No typed-input field in the lock window; nothing to focus."""
Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. def on_callback_error(self) -> None:
Best-effort: silently ignored if setxkbmap is unavailable. """Surfaced via GateRoot's logging already; no extra action yet."""
"""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
_logger.warning("setxkbmap not found; VT switching will not be disabled")
return
subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False)
def _restore_vt_switching(self) -> None: def on_close(self) -> None:
"""Restore VT switching after the lock is dismissed.""" """No extra hardware/state beyond what close() already handles."""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
return
subprocess.run([setxkbmap, "-option", ""], check=False)
def _setup_window(self) -> None:
"""Configure the window for fullscreen lock."""
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
self.root.overrideredirect(boolean=True)
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
self.root.attributes(fullscreen=True)
self.root.attributes(topmost=True)
self.root.configure(bg="#1a1a1a", cursor="arrow")
if not self.demo_mode:
self._disable_vt_switching()
def _setup_verify_window(self) -> None: def _setup_verify_window(self) -> None:
"""Configure window for post-sick-day workout verification.""" """Configure window for post-sick-day workout verification."""
@ -69,18 +48,3 @@ class WindowSetupMixin:
self.root.geometry("700x450") self.root.geometry("700x450")
self.root.configure(bg="#1a1a1a", cursor="arrow") self.root.configure(bg="#1a1a1a", cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close) self.root.protocol("WM_DELETE_WINDOW", self.close)
def _grab_input(self) -> None:
"""Force input focus to the locker window."""
self.root.update_idletasks()
self.root.focus_force()
if self.demo_mode:
with contextlib.suppress(tk.TclError):
self.root.grab_set()
else:
try:
self.root.grab_set_global()
except tk.TclError:
_logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError):
self.root.grab_set()

View File

@ -14,6 +14,9 @@ import sys
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from gatelock import GateRoot, LockConfig, LockWindow
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker import _sick_tracker from screen_locker import _sick_tracker
from screen_locker._constants import ( from screen_locker._constants import (
EARLY_BIRD_END_HOUR, EARLY_BIRD_END_HOUR,
@ -26,14 +29,8 @@ from screen_locker._constants import (
PHONE_PENALTY_DELAY_PRODUCTION, PHONE_PENALTY_DELAY_PRODUCTION,
SCHEDULED_SKIPS_FILE, SCHEDULED_SKIPS_FILE,
SICK_LOCKOUT_SECONDS, SICK_LOCKOUT_SECONDS,
STRONGLIFTS_DB_REMOTE,
) )
from screen_locker._early_bird import EarlyBirdMixin from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._log_integrity import (
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._phone_verification import PhoneVerificationMixin
from screen_locker._shutdown import ShutdownMixin from screen_locker._shutdown import ShutdownMixin
from screen_locker._sick_dialog import SickDialogMixin from screen_locker._sick_dialog import SickDialogMixin
@ -62,7 +59,6 @@ __all__ = [
"PHONE_PENALTY_DELAY_PRODUCTION", "PHONE_PENALTY_DELAY_PRODUCTION",
"SCHEDULED_SKIPS_FILE", "SCHEDULED_SKIPS_FILE",
"SICK_LOCKOUT_SECONDS", "SICK_LOCKOUT_SECONDS",
"STRONGLIFTS_DB_REMOTE",
"WEEKLY_WORKOUT_MINIMUM", "WEEKLY_WORKOUT_MINIMUM",
"ScreenLocker", "ScreenLocker",
] ]
@ -111,19 +107,27 @@ class ScreenLocker(
self.workout_data: dict[str, str] = {} self.workout_data: dict[str, str] = {}
self._relaxed_day_mode: bool = False self._relaxed_day_mode: bool = False
self._check_early_exits(verify_only=verify_only) self._check_early_exits(verify_only=verify_only)
self.root = tk.Tk() self.root = GateRoot()
self.root.on_callback_error = self.on_callback_error
title_suffix = ( title_suffix = (
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
) )
self.root.title("Workout Locker" + title_suffix) self.root.title("Workout Locker" + title_suffix)
self.demo_mode = demo_mode self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800 self.lockout_time = 10 if demo_mode else 1800
self._lock: LockWindow | None = None
if verify_only: if verify_only:
self._setup_verify_window() self._setup_verify_window()
elif self._relaxed_day_mode: elif self._relaxed_day_mode:
self._setup_relaxed_day_window() self._setup_relaxed_day_window()
else: else:
self._setup_window() config = LockConfig(
mode="hard",
grab="local" if demo_mode else "global",
disable_vt=not demo_mode,
)
self._lock = LockWindow(self.root, config, hooks=self)
self._lock.setup()
if demo_mode: if demo_mode:
self._setup_demo_close_button() self._setup_demo_close_button()
self.container = tk.Frame(self.root, bg="#1a1a1a") self.container = tk.Frame(self.root, bg="#1a1a1a")
@ -135,7 +139,10 @@ class ScreenLocker(
self._start_relaxed_day_flow() self._start_relaxed_day_flow()
else: else:
self._start_phone_check() self._start_phone_check()
self._grab_input() # Always set on this branch; guard only for mypy (can't narrow
# across two separate if/elif/else statements).
if self._lock is not None: # pragma: no branch
self._lock.grab_input()
def _is_sick_day_log(self) -> bool: def _is_sick_day_log(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified).""" """Check if today's workout log is a sick day (not yet verified)."""
@ -304,7 +311,7 @@ class ScreenLocker(
return False return False
if verify_entry_hmac(entry): if verify_entry_hmac(entry):
return entry.get("workout_data", {}).get("type") != "early_bird" return entry.get("workout_data", {}).get("type") != "early_bird"
if _load_hmac_key() is None and "hmac" not in entry: if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry:
_logger.info( _logger.info(
"HMAC key unavailable — accepting unsigned entry", "HMAC key unavailable — accepting unsigned entry",
) )
@ -358,13 +365,17 @@ class ScreenLocker(
def close(self) -> None: def close(self) -> None:
"""Close the application and exit.""" """Close the application and exit."""
if not self.demo_mode: if self._lock is not None:
self._restore_vt_switching() self._lock.close()
else:
self.root.destroy() self.root.destroy()
sys.exit(0) sys.exit(0)
def run(self) -> None: def run(self) -> None:
"""Start the Tkinter main event loop.""" """Start the Tkinter main event loop."""
if self._lock is not None:
self._lock.run()
else:
self.root.mainloop() self.root.mainloop()

View File

@ -2,7 +2,8 @@
Safety: Safety:
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
module reference inside ``screen_lock`` with a MagicMock and stubs module reference inside ``screen_lock`` with a MagicMock, replaces
``GateRoot`` with a callable returning that same mock root, and stubs
``sys.exit``. This makes it physically impossible for any test to ``sys.exit``. This makes it physically impossible for any test to
create a real Tk root window, go fullscreen, or grab input even if create a real Tk root window, go fullscreen, or grab input even if
the test forgets to request the explicit ``mock_tk`` fixture. the test forgets to request the explicit ``mock_tk`` fixture.
@ -10,6 +11,7 @@ Safety:
from __future__ import annotations from __future__ import annotations
from contextlib import ExitStack
from pathlib import Path from pathlib import Path
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -24,6 +26,20 @@ if TYPE_CHECKING:
from typing import Literal from typing import Literal
# Every module that imports ``tkinter as tk`` and calls it directly. The UI was
# split across these modules, so each ``tk`` reference must be patched — both to
# guarantee no test can touch a real display and so a test holding ``mock_tk``
# sees widgets created on that same mock (not a divergent autouse mock).
_TK_MODULES = (
"screen_locker.screen_lock",
"screen_locker._sick_dialog",
"screen_locker._ui_widgets",
"screen_locker._window_setup",
)
_VT_SHUTIL = "gatelock._vt.shutil"
_VT_SUBPROCESS = "gatelock._vt.subprocess"
def _make_mock_tk() -> MagicMock: def _make_mock_tk() -> MagicMock:
"""Build a MagicMock that stands in for the ``tkinter`` module.""" """Build a MagicMock that stands in for the ``tkinter`` module."""
mock = MagicMock() mock = MagicMock()
@ -50,11 +66,16 @@ def _block_real_tk_and_exit() -> Iterator[None]:
""" """
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with ExitStack() as stack:
patch("screen_locker.screen_lock.tk", mock), for module in _TK_MODULES:
patch("screen_locker._sick_dialog.tk", mock), stack.enter_context(patch(f"{module}.tk", mock))
patch("screen_locker.screen_lock.sys.exit"), stack.enter_context(
): patch(
"screen_locker.screen_lock.GateRoot",
return_value=mock.Tk.return_value,
)
)
stack.enter_context(patch("screen_locker.screen_lock.sys.exit"))
yield yield
@ -69,15 +90,29 @@ def mock_subprocess_run() -> Generator[MagicMock]:
regardless of whether setxkbmap is installed on the host machine. regardless of whether setxkbmap is installed on the host machine.
""" """
with ( with (
patch( patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
"screen_locker._window_setup.shutil.which", patch(f"{_VT_SUBPROCESS}.run") as mock,
return_value="/usr/bin/setxkbmap",
),
patch("screen_locker._window_setup.subprocess.run") as mock,
): ):
yield mock yield mock
@pytest.fixture(autouse=True)
def _block_real_network() -> Iterator[None]:
"""Block real subnet probes for every test.
``_scan_for_http_server`` / ``_try_wireless_reconnect`` open real TCP
sockets to scan the LAN; without this an unmocked ``_verify_phone_workout``
would actually reach the phone over the network (flaky, environment-coupled).
Defaults ``create_connection`` to refuse tests needing a successful probe
patch it locally, which takes precedence inside the test body.
"""
with patch(
"screen_locker._phone_verification.socket.create_connection",
side_effect=OSError("network blocked in tests"),
):
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state.""" """Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
@ -130,22 +165,22 @@ def _mock_weekly_logic() -> Iterator[None]:
@pytest.fixture @pytest.fixture
def mock_tk() -> Generator[MagicMock]: def mock_tk() -> Generator[MagicMock]:
"""Mock tkinter module for testing without display.""" """Mock the ``tkinter`` module across every UI module for display-free tests.
with patch("screen_locker.screen_lock.tk") as mock:
# Set up Tk root mock
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
# Set up Frame mock
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Set up TclError as actual exception class
mock.TclError = tk.TclError
Patches the same single mock into all ``_TK_MODULES`` so assertions on
``mock_tk.Button`` capture widgets created by any of the split UI mixins
(``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``.
"""
mock = _make_mock_tk()
with ExitStack() as stack:
for module in _TK_MODULES:
stack.enter_context(patch(f"{module}.tk", mock))
stack.enter_context(
patch(
"screen_locker.screen_lock.GateRoot",
return_value=mock.Tk.return_value,
)
)
yield mock yield mock

View File

@ -175,7 +175,7 @@ class TestHasLoggedToday:
return_value=False, return_value=False,
), ),
patch( patch(
"screen_locker.screen_lock._load_hmac_key", "screen_locker.screen_lock.compute_entry_hmac",
return_value=None, return_value=None,
), ),
): ):
@ -202,8 +202,8 @@ class TestHasLoggedToday:
return_value=False, return_value=False,
), ),
patch( patch(
"screen_locker.screen_lock._load_hmac_key", "screen_locker.screen_lock.compute_entry_hmac",
return_value=b"secret-key", return_value="some-signature",
), ),
): ):
assert locker.has_logged_today() is False assert locker.has_logged_today() is False

View File

@ -1,154 +0,0 @@
"""Tests for _log_integrity HMAC signing and verification."""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._log_integrity import (
_generate_hmac_key,
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
_HMAC_KEY_FILE_PATH = "screen_locker._log_integrity.HMAC_KEY_FILE"
if TYPE_CHECKING:
from pathlib import Path
class TestLoadHmacKey:
"""Tests for _load_hmac_key."""
def test_loads_key_from_file(self, tmp_path: Path) -> None:
"""Test loading HMAC key from existing file."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"secret_key_bytes")
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _load_hmac_key()
assert result == b"secret_key_bytes"
def test_returns_none_on_missing_file(self, tmp_path: Path) -> None:
"""Test returns None when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _load_hmac_key()
assert result is None
class TestGenerateHmacKey:
"""Tests for _generate_hmac_key."""
def test_generates_and_writes_key(self, tmp_path: Path) -> None:
"""Test key generation creates file with 32-byte key."""
key_file = tmp_path / "subdir" / "hmac.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _generate_hmac_key()
assert result is not None
assert len(result) == 32
assert key_file.read_bytes() == result
def test_returns_none_on_write_failure(self) -> None:
"""Test returns None when file cannot be written."""
with patch(
_HMAC_KEY_FILE_PATH,
) as mock_path:
mock_path.parent.mkdir.side_effect = OSError("permission denied")
result = _generate_hmac_key()
assert result is None
class TestComputeEntryHmac:
"""Tests for compute_entry_hmac."""
def test_computes_hmac_for_entry(self, tmp_path: Path) -> None:
"""Test HMAC computation produces valid hex string."""
key_file = tmp_path / "hmac.key"
key = b"test_key_12345"
key_file.write_bytes(key)
entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = compute_entry_hmac(entry)
assert result is not None
# Verify manually
payload = json.dumps(entry, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
assert result == expected
def test_returns_none_when_no_key(self, tmp_path: Path) -> None:
"""Test returns None when key file is missing."""
key_file = tmp_path / "nonexistent.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = compute_entry_hmac({"data": "test"})
assert result is None
class TestVerifyEntryHmac:
"""Tests for verify_entry_hmac."""
def test_valid_hmac(self, tmp_path: Path) -> None:
"""Test verification passes with correct HMAC."""
key_file = tmp_path / "hmac.key"
key = b"verification_key"
key_file.write_bytes(key)
entry_data = {"timestamp": "2025-01-01", "workout_data": {"type": "test"}}
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
correct_hmac = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
entry = {**entry_data, "hmac": correct_hmac}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is True
def test_invalid_hmac(self, tmp_path: Path) -> None:
"""Test verification fails with wrong HMAC."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"verification_key")
entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is False
def test_missing_hmac_field(self) -> None:
"""Test verification fails when entry has no hmac field."""
entry: dict[str, object] = {"timestamp": "2025-01-01"}
assert verify_entry_hmac(entry) is False
def test_non_string_hmac_field(self) -> None:
"""Test verification fails when hmac field is not a string."""
entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": 12345}
assert verify_entry_hmac(entry) is False
def test_missing_key_file(self, tmp_path: Path) -> None:
"""Test verification fails when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is False

View File

@ -161,6 +161,27 @@ class TestVerifyOnlyInit:
) )
locker.root.title.assert_called_with("Workout Locker [VERIFY]") locker.root.title.assert_called_with("Workout Locker [VERIFY]")
def test_close_and_run_use_root_directly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No LockWindow is built for verify_only; close()/run() use root."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
assert locker._lock is None
locker.run()
locker.root.mainloop.assert_called_once()
locker.close()
locker.root.destroy.assert_called_once()
class TestSetupVerifyWindow: class TestSetupVerifyWindow:
"""Tests for _setup_verify_window.""" """Tests for _setup_verify_window."""

View File

@ -109,7 +109,7 @@ class TestVTSwitching:
) -> None: ) -> None:
"""No crash and no subprocess call when setxkbmap is not installed.""" """No crash and no subprocess call when setxkbmap is not installed."""
with patch( with patch(
"screen_locker._window_setup.shutil.which", "gatelock._vt.shutil.which",
return_value=None, return_value=None,
): ):
create_locker(mock_tk, tmp_path, demo_mode=False) create_locker(mock_tk, tmp_path, demo_mode=False)
@ -128,7 +128,7 @@ class TestVTSwitching:
mock_subprocess_run.reset_mock() mock_subprocess_run.reset_mock()
with patch( with patch(
"screen_locker._window_setup.shutil.which", "gatelock._vt.shutil.which",
return_value=None, return_value=None,
): ):
locker.close() locker.close()

View File

@ -82,7 +82,7 @@ class TestRelaxedDayBranch:
"screen_locker.screen_lock.has_weekly_minimum", "screen_locker.screen_lock.has_weekly_minimum",
return_value=False, return_value=False,
), ),
patch.object(ScreenLocker, "_setup_window") as mock_full, patch("screen_locker.screen_lock.LockWindow") as mock_lock_window,
patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small, patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small,
patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"), patch.object(ScreenLocker, "_start_relaxed_day_flow"),
@ -91,7 +91,7 @@ class TestRelaxedDayBranch:
ScreenLocker(demo_mode=True) ScreenLocker(demo_mode=True)
mock_small.assert_called_once() mock_small.assert_called_once()
mock_full.assert_not_called() mock_lock_window.assert_not_called()
def test_relaxed_day_no_grab_input( def test_relaxed_day_no_grab_input(
self, self,
@ -116,14 +116,14 @@ class TestRelaxedDayBranch:
"screen_locker.screen_lock.has_weekly_minimum", "screen_locker.screen_lock.has_weekly_minimum",
return_value=False, return_value=False,
), ),
patch.object(ScreenLocker, "_grab_input") as mock_grab, patch("screen_locker.screen_lock.LockWindow") as mock_lock_window,
patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"), patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_start_verify_workout_check"),
): ):
ScreenLocker(demo_mode=True) ScreenLocker(demo_mode=True)
mock_grab.assert_not_called() mock_lock_window.assert_not_called()
def test_has_logged_today_exits_before_relaxed_check( def test_has_logged_today_exits_before_relaxed_check(
self, self,
@ -217,7 +217,7 @@ class TestStartRelaxedDayFlow:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch( patch(
"screen_locker._ui_flows.count_weekly_workouts", "screen_locker._ui_flows_relaxed.count_weekly_workouts",
return_value=2, return_value=2,
), ),
patch.object(locker, "_text") as mock_text, patch.object(locker, "_text") as mock_text,
@ -241,7 +241,7 @@ class TestStartRelaxedDayFlow:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch( patch(
"screen_locker._ui_flows.count_weekly_workouts", "screen_locker._ui_flows_relaxed.count_weekly_workouts",
return_value=0, return_value=0,
), ),
patch.object(locker, "_button") as mock_button, patch.object(locker, "_button") as mock_button,
@ -268,7 +268,7 @@ class TestStartRelaxedDayFlow:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch( patch(
"screen_locker._ui_flows.count_weekly_workouts", "screen_locker._ui_flows_relaxed.count_weekly_workouts",
return_value=1, return_value=1,
), ),
patch.object(locker, "_button") as mock_button, patch.object(locker, "_button") as mock_button,
@ -347,3 +347,44 @@ class TestStartRelaxedPhoneCheck:
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check() locker._poll_relaxed_phone_check()
mock_handle.assert_not_called() mock_handle.assert_not_called()
class TestHandleRelaxedPhoneResult:
"""Tests for _handle_relaxed_phone_result routing and the retry screen."""
def test_verified_saves_and_schedules_unlock(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._handle_relaxed_phone_result("verified", "Workout verified!")
assert locker.workout_data["type"] == "phone_verified"
assert locker.workout_data["source"] == "Workout verified!"
locker.root.after.assert_called_with(1500, locker.unlock_screen)
def test_non_verified_shows_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("not_verified", "nope")
mock_retry.assert_called_once_with("nope", "not_verified")
def test_show_relaxed_retry_renders_buttons(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._show_relaxed_retry("No workout", "not_verified")
button_texts = {
call.kwargs.get("text") for call in mock_tk.Button.call_args_list
}
assert "TRY AGAIN" in button_texts
assert "Close (Skip)" in button_texts

View File

@ -2,11 +2,15 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day
if TYPE_CHECKING:
from pathlib import Path
from screen_locker.screen_lock import ScreenLocker from screen_locker.screen_lock import ScreenLocker
from screen_locker.tests.conftest import create_locker
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _check_today_state_exits: return True/False branches # _check_today_state_exits: return True/False branches
@ -156,3 +160,22 @@ class TestCheckNonVerifyExitsScheduledSkip:
with patch.object(locker, "_is_scheduled_skip_today", return_value=True): with patch.object(locker, "_is_scheduled_skip_today", return_value=True):
locker._check_non_verify_exits() locker._check_non_verify_exits()
mock_sys_exit.assert_called_once_with(0) mock_sys_exit.assert_called_once_with(0)
class TestRelaxedDayCloseAndRun:
"""No LockWindow is built on a relaxed day; close()/run() use root."""
def test_relaxed_day_close_and_run_use_root_directly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker_relaxed_day(mock_tk, tmp_path)
assert locker._lock is None
locker.run()
locker.root.mainloop.assert_called_once()
locker.close()
locker.root.destroy.assert_called_once()