utils/gatelock/gatelock/tests/test_escape.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

177 lines
6.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the shared escape hatch.
The load-bearing property is that nothing here can ever *widen* the budget:
not a corrupt file, not an unparsable date, not a missing signing key.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from gatelock._escape import (
EscapeDraft,
EscapePolicy,
EscapeTracker,
_parse_iso,
_today_iso,
)
if TYPE_CHECKING:
from pathlib import Path
TODAY = "2026-07-25"
POLICY = EscapePolicy(name="sick", label="Sick")
@pytest.fixture
def key_file(tmp_path: Path) -> Path:
"""A throwaway signing key, so tests never touch the real root-owned one."""
path = tmp_path / "hmac.key"
path.write_bytes(b"0" * 32)
return path
@pytest.fixture
def tracker(tmp_path: Path, key_file: Path) -> EscapeTracker:
"""A tracker over an empty history."""
return EscapeTracker(POLICY, tmp_path / "escape.json", key_file=key_file)
def good_draft() -> EscapeDraft:
"""A justification that passes validation."""
return EscapeDraft(
reason="migraine", onset="03:00", severity=8, description="x" * 130
)
class TestPolicyDefaults:
"""The defaults mirror screen-locker's proven sick mode."""
def test_defaults(self) -> None:
"""Budgets, lockout and justification length match sick mode."""
assert POLICY.budget_per_7_days == 1
assert POLICY.budget_per_30_days == 3
assert POLICY.budget_per_90_days == 10
assert POLICY.lockout_seconds == 120
assert POLICY.lockout_multiplier_per_recent == 2
assert POLICY.justification_min_chars == 120
class TestParseIso:
"""Date parsing never guesses."""
def test_valid(self) -> None:
"""A well-formed date parses."""
parsed = _parse_iso(TODAY)
assert parsed is not None
assert parsed.year == 2026
def test_invalid_returns_none(self) -> None:
"""Garbage is ignored rather than crashing the lock."""
assert _parse_iso("not-a-date") is None
def test_today_iso_round_trips(self) -> None:
"""The generated 'today' is parseable by the same parser."""
assert _parse_iso(_today_iso()) is not None
class TestLoad:
"""Reading history, and what must never happen while doing so."""
def test_missing_file(self, tracker: EscapeTracker) -> None:
"""No file yet means no uses."""
assert tracker.load().used_days == []
def test_corrupt_json(self, tracker: EscapeTracker, tmp_path: Path) -> None:
"""A truncated file degrades instead of raising."""
(tmp_path / "escape.json").write_text("{not json", encoding="utf-8")
assert tracker.load().used_days == []
def test_unreadable_file(self, tracker: EscapeTracker, tmp_path: Path) -> None:
"""An unreadable file degrades instead of raising."""
(tmp_path / "escape.json").write_text("{}", encoding="utf-8")
with patch("pathlib.Path.read_text", side_effect=OSError("denied")):
assert tracker.load().used_days == []
def test_non_dict_payload(self, tracker: EscapeTracker, tmp_path: Path) -> None:
"""A JSON list is not a history."""
(tmp_path / "escape.json").write_text("[]", encoding="utf-8")
assert tracker.load().used_days == []
def test_round_trip(self, tracker: EscapeTracker) -> None:
"""A recorded use survives a reload."""
tracker.record(good_draft(), today=TODAY)
reloaded = EscapeTracker(POLICY, tracker._path, key_file=tracker._key_file)
reloaded.load()
assert reloaded.history.used_days == [TODAY]
assert reloaded.tampered == 0
def test_non_dict_entries_are_skipped(
self, tracker: EscapeTracker, tmp_path: Path
) -> None:
"""A stray scalar in the justification list is ignored."""
(tmp_path / "escape.json").write_text(
json.dumps({"used_days": [TODAY], "justifications": ["oops", 3]}),
encoding="utf-8",
)
assert tracker.load().justifications == []
def test_non_string_days_are_skipped(
self, tracker: EscapeTracker, tmp_path: Path
) -> None:
"""A non-string date cannot count against the budget."""
(tmp_path / "escape.json").write_text(
json.dumps({"used_days": [TODAY, 7], "justifications": []}),
encoding="utf-8",
)
assert tracker.load().used_days == [TODAY]
class TestIntegrityNeverWidensTheBudget:
"""The anti-bypass property of this module."""
def test_tampered_entry_is_kept_and_reported(self, tracker: EscapeTracker) -> None:
"""Editing the file is shouted about -- and still counts against you."""
tracker.record(good_draft(), today=TODAY)
raw = json.loads(tracker._path.read_text(encoding="utf-8"))
raw["justifications"][0]["description"] = "actually I just felt like it"
tracker._path.write_text(json.dumps(raw), encoding="utf-8")
reloaded = EscapeTracker(POLICY, tracker._path, key_file=tracker._key_file)
reloaded.load()
assert reloaded.tampered == 1
# The crucial bit: the edit did NOT buy back any budget.
assert reloaded.history.used_days == [TODAY]
assert reloaded.is_budget_exhausted(today=TODAY) is True
def test_unreadable_key_does_not_reset_the_budget(
self, tracker: EscapeTracker, tmp_path: Path
) -> None:
"""chmod-ing the signing key must not become an unlimited hatch."""
tracker.record(good_draft(), today=TODAY)
missing = tmp_path / "gone.key"
reloaded = EscapeTracker(POLICY, tracker._path, key_file=missing)
reloaded.load()
assert reloaded.tampered == 1
assert reloaded.is_budget_exhausted(today=TODAY) is True
def test_unsigned_entry_still_counts(
self, tracker: EscapeTracker, tmp_path: Path
) -> None:
"""An entry with no signature at all is still a use."""
(tmp_path / "escape.json").write_text(
json.dumps({"used_days": [TODAY], "justifications": [{"date": TODAY}]}),
encoding="utf-8",
)
tracker.load()
assert tracker.tampered == 1
assert tracker.is_budget_exhausted(today=TODAY) is True