mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 12:03:09 +02:00
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:
parent
d8062a601f
commit
70cf6f5425
@ -89,6 +89,7 @@ repos:
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.10
|
||||
@ -134,3 +135,17 @@ repos:
|
||||
entry: python3 scripts/check_file_length.py
|
||||
language: system
|
||||
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]
|
||||
|
||||
@ -3,7 +3,10 @@ name = "screen-locker"
|
||||
version = "1.0.0"
|
||||
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
||||
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]
|
||||
target-version = "py310"
|
||||
|
||||
@ -4,6 +4,10 @@ from __future__ import annotations
|
||||
|
||||
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)
|
||||
PHONE_PENALTY_DELAY_DEMO = 10
|
||||
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.
|
||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
||||
ADB_TIMEOUT = 15
|
||||
STRONGLIFTS_DB_REMOTE = (
|
||||
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
|
||||
# Workout app JSON candidate paths on the phone, in the order the app prefers
|
||||
# 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
|
||||
EARLY_BIRD_START_HOUR = 5
|
||||
EARLY_BIRD_END_HOUR = 8
|
||||
EARLY_BIRD_END_MINUTE = 30
|
||||
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)
|
||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||
# State file to track sick day usage and original config values
|
||||
|
||||
@ -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)
|
||||
@ -12,6 +12,8 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from screen_locker._constants import (
|
||||
SICK_BUDGET_PER_7_DAYS,
|
||||
SICK_BUDGET_PER_30_DAYS,
|
||||
@ -23,7 +25,6 @@ from screen_locker._constants import (
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
from screen_locker._log_integrity import compute_entry_hmac
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -15,8 +15,9 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import WAKE_STATE_FILE
|
||||
from screen_locker._log_integrity import verify_entry_hmac
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tkinter as tk
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
"""Disable VT switching in X11 while the lock is active.
|
||||
def on_focus_ready(self) -> None:
|
||||
"""No typed-input field in the lock window; nothing to focus."""
|
||||
|
||||
Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn.
|
||||
Best-effort: silently ignored if setxkbmap is unavailable.
|
||||
"""
|
||||
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 on_callback_error(self) -> None:
|
||||
"""Surfaced via GateRoot's logging already; no extra action yet."""
|
||||
|
||||
def _restore_vt_switching(self) -> None:
|
||||
"""Restore VT switching after the lock is dismissed."""
|
||||
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 on_close(self) -> None:
|
||||
"""No extra hardware/state beyond what close() already handles."""
|
||||
|
||||
def _setup_verify_window(self) -> None:
|
||||
"""Configure window for post-sick-day workout verification."""
|
||||
@ -69,18 +48,3 @@ class WindowSetupMixin:
|
||||
self.root.geometry("700x450")
|
||||
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
||||
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()
|
||||
|
||||
@ -14,6 +14,9 @@ import sys
|
||||
import tkinter as tk
|
||||
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._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
@ -26,14 +29,8 @@ from screen_locker._constants import (
|
||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||
SCHEDULED_SKIPS_FILE,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
STRONGLIFTS_DB_REMOTE,
|
||||
)
|
||||
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._shutdown import ShutdownMixin
|
||||
from screen_locker._sick_dialog import SickDialogMixin
|
||||
@ -62,7 +59,6 @@ __all__ = [
|
||||
"PHONE_PENALTY_DELAY_PRODUCTION",
|
||||
"SCHEDULED_SKIPS_FILE",
|
||||
"SICK_LOCKOUT_SECONDS",
|
||||
"STRONGLIFTS_DB_REMOTE",
|
||||
"WEEKLY_WORKOUT_MINIMUM",
|
||||
"ScreenLocker",
|
||||
]
|
||||
@ -111,19 +107,27 @@ class ScreenLocker(
|
||||
self.workout_data: dict[str, str] = {}
|
||||
self._relaxed_day_mode: bool = False
|
||||
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 = (
|
||||
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
||||
)
|
||||
self.root.title("Workout Locker" + title_suffix)
|
||||
self.demo_mode = demo_mode
|
||||
self.lockout_time = 10 if demo_mode else 1800
|
||||
self._lock: LockWindow | None = None
|
||||
if verify_only:
|
||||
self._setup_verify_window()
|
||||
elif self._relaxed_day_mode:
|
||||
self._setup_relaxed_day_window()
|
||||
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:
|
||||
self._setup_demo_close_button()
|
||||
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
||||
@ -135,7 +139,10 @@ class ScreenLocker(
|
||||
self._start_relaxed_day_flow()
|
||||
else:
|
||||
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:
|
||||
"""Check if today's workout log is a sick day (not yet verified)."""
|
||||
@ -304,7 +311,7 @@ class ScreenLocker(
|
||||
return False
|
||||
if verify_entry_hmac(entry):
|
||||
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(
|
||||
"HMAC key unavailable — accepting unsigned entry",
|
||||
)
|
||||
@ -358,13 +365,17 @@ class ScreenLocker(
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the application and exit."""
|
||||
if not self.demo_mode:
|
||||
self._restore_vt_switching()
|
||||
if self._lock is not None:
|
||||
self._lock.close()
|
||||
else:
|
||||
self.root.destroy()
|
||||
sys.exit(0)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Start the Tkinter main event loop."""
|
||||
if self._lock is not None:
|
||||
self._lock.run()
|
||||
else:
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
Safety:
|
||||
``_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
|
||||
create a real Tk root window, go fullscreen, or grab input — even if
|
||||
the test forgets to request the explicit ``mock_tk`` fixture.
|
||||
@ -10,6 +11,7 @@ Safety:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
@ -24,6 +26,20 @@ if TYPE_CHECKING:
|
||||
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:
|
||||
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
||||
mock = MagicMock()
|
||||
@ -50,11 +66,16 @@ def _block_real_tk_and_exit() -> Iterator[None]:
|
||||
"""
|
||||
mock = _make_mock_tk()
|
||||
|
||||
with (
|
||||
patch("screen_locker.screen_lock.tk", mock),
|
||||
patch("screen_locker._sick_dialog.tk", mock),
|
||||
patch("screen_locker.screen_lock.sys.exit"),
|
||||
):
|
||||
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,
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch("screen_locker.screen_lock.sys.exit"))
|
||||
yield
|
||||
|
||||
|
||||
@ -69,15 +90,29 @@ def mock_subprocess_run() -> Generator[MagicMock]:
|
||||
regardless of whether setxkbmap is installed on the host machine.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._window_setup.shutil.which",
|
||||
return_value="/usr/bin/setxkbmap",
|
||||
),
|
||||
patch("screen_locker._window_setup.subprocess.run") as mock,
|
||||
patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
|
||||
patch(f"{_VT_SUBPROCESS}.run") as 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)
|
||||
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
|
||||
"""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
|
||||
def mock_tk() -> Generator[MagicMock]:
|
||||
"""Mock tkinter module for testing without display."""
|
||||
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
|
||||
"""Mock the ``tkinter`` module across every UI module for display-free tests.
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -175,7 +175,7 @@ class TestHasLoggedToday:
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"screen_locker.screen_lock._load_hmac_key",
|
||||
"screen_locker.screen_lock.compute_entry_hmac",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
@ -202,8 +202,8 @@ class TestHasLoggedToday:
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"screen_locker.screen_lock._load_hmac_key",
|
||||
return_value=b"secret-key",
|
||||
"screen_locker.screen_lock.compute_entry_hmac",
|
||||
return_value="some-signature",
|
||||
),
|
||||
):
|
||||
assert locker.has_logged_today() is False
|
||||
|
||||
@ -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
|
||||
@ -161,6 +161,27 @@ class TestVerifyOnlyInit:
|
||||
)
|
||||
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:
|
||||
"""Tests for _setup_verify_window."""
|
||||
|
||||
@ -109,7 +109,7 @@ class TestVTSwitching:
|
||||
) -> None:
|
||||
"""No crash and no subprocess call when setxkbmap is not installed."""
|
||||
with patch(
|
||||
"screen_locker._window_setup.shutil.which",
|
||||
"gatelock._vt.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
create_locker(mock_tk, tmp_path, demo_mode=False)
|
||||
@ -128,7 +128,7 @@ class TestVTSwitching:
|
||||
mock_subprocess_run.reset_mock()
|
||||
|
||||
with patch(
|
||||
"screen_locker._window_setup.shutil.which",
|
||||
"gatelock._vt.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
locker.close()
|
||||
|
||||
@ -82,7 +82,7 @@ class TestRelaxedDayBranch:
|
||||
"screen_locker.screen_lock.has_weekly_minimum",
|
||||
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, "_start_phone_check"),
|
||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
||||
@ -91,7 +91,7 @@ class TestRelaxedDayBranch:
|
||||
ScreenLocker(demo_mode=True)
|
||||
|
||||
mock_small.assert_called_once()
|
||||
mock_full.assert_not_called()
|
||||
mock_lock_window.assert_not_called()
|
||||
|
||||
def test_relaxed_day_no_grab_input(
|
||||
self,
|
||||
@ -116,14 +116,14 @@ class TestRelaxedDayBranch:
|
||||
"screen_locker.screen_lock.has_weekly_minimum",
|
||||
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_relaxed_day_flow"),
|
||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
||||
):
|
||||
ScreenLocker(demo_mode=True)
|
||||
|
||||
mock_grab.assert_not_called()
|
||||
mock_lock_window.assert_not_called()
|
||||
|
||||
def test_has_logged_today_exits_before_relaxed_check(
|
||||
self,
|
||||
@ -217,7 +217,7 @@ class TestStartRelaxedDayFlow:
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._ui_flows.count_weekly_workouts",
|
||||
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||
return_value=2,
|
||||
),
|
||||
patch.object(locker, "_text") as mock_text,
|
||||
@ -241,7 +241,7 @@ class TestStartRelaxedDayFlow:
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._ui_flows.count_weekly_workouts",
|
||||
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||
return_value=0,
|
||||
),
|
||||
patch.object(locker, "_button") as mock_button,
|
||||
@ -268,7 +268,7 @@ class TestStartRelaxedDayFlow:
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._ui_flows.count_weekly_workouts",
|
||||
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||
return_value=1,
|
||||
),
|
||||
patch.object(locker, "_button") as mock_button,
|
||||
@ -347,3 +347,44 @@ class TestStartRelaxedPhoneCheck:
|
||||
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
||||
locker._poll_relaxed_phone_check()
|
||||
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
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
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.tests.conftest import create_locker
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _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):
|
||||
locker._check_non_verify_exits()
|
||||
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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user