mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 14:07:46 +01:00
gatelock: extract the shared escape-hatch core
Every locker here already had a bounded way out -- screen-locker's sick mode, diet-guard's log-a-meal -- except the wake alarm, which had only the challenge itself. Rather than copy sick mode a third time, the mechanism moves into gatelock so all three consumers share one implementation and one set of budget rules. `_escape.py` owns the parts that are policy-free: rolling 7/30/90-day budgets, the justification draft and its validation, the escalating lockout that doubles with each recent use, HMAC-signed history entries, and the "last ten, played back to you" summary. App-specific policy (screen-locker's commitment penalty and workout debt) stays in the consumer. Entries that fail HMAC verification are KEPT rather than dropped. Dropping them looks safer but is not: `verify_entry_hmac` also returns False when the key is merely unreadable, so "discard what you cannot verify" would turn a chmod on the key file into an unlimited escape hatch. They stay in the budget count and are reported through a `tampered` counter instead. 278 tests, 100% branch coverage, no lint suppressions.
This commit is contained in:
parent
a9b65812d7
commit
7327c287f4
@ -18,6 +18,12 @@ from gatelock._arbiter import (
|
|||||||
grab_strength,
|
grab_strength,
|
||||||
)
|
)
|
||||||
from gatelock._detect import OutputChangeDetector
|
from gatelock._detect import OutputChangeDetector
|
||||||
|
from gatelock._escape import (
|
||||||
|
EscapeDraft,
|
||||||
|
EscapeHistory,
|
||||||
|
EscapePolicy,
|
||||||
|
EscapeTracker,
|
||||||
|
)
|
||||||
from gatelock._guards import assert_not_under_pytest, wait_for_x_server
|
from gatelock._guards import assert_not_under_pytest, wait_for_x_server
|
||||||
from gatelock._outputs import (
|
from gatelock._outputs import (
|
||||||
Output,
|
Output,
|
||||||
@ -48,6 +54,10 @@ __all__ = [
|
|||||||
"Arbiter",
|
"Arbiter",
|
||||||
"ArbiterVerdict",
|
"ArbiterVerdict",
|
||||||
"Claim",
|
"Claim",
|
||||||
|
"EscapeDraft",
|
||||||
|
"EscapeHistory",
|
||||||
|
"EscapePolicy",
|
||||||
|
"EscapeTracker",
|
||||||
"GateRoot",
|
"GateRoot",
|
||||||
"LockConfig",
|
"LockConfig",
|
||||||
"LockWindow",
|
"LockWindow",
|
||||||
|
|||||||
300
gatelock/gatelock/_escape.py
Normal file
300
gatelock/gatelock/_escape.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"""A sanctioned, rate-limited way out of a lock.
|
||||||
|
|
||||||
|
A hard lock without an escape hatch is a trap. screen-locker learned this and
|
||||||
|
grew "sick mode": you cannot always do the workout, so there is a bounded,
|
||||||
|
justified, tamper-evident way to say so. diet-guard's exit is cheap by nature
|
||||||
|
(log a meal). wake-alarm had neither -- its only exit was solving four
|
||||||
|
challenges in a row, which is a poor thing to require of someone who has just
|
||||||
|
woken up, and no exit at all if the alarm misfires at the wrong hour.
|
||||||
|
|
||||||
|
This module is the generic core of that hatch, so every app gets the *same*
|
||||||
|
mechanism instead of a copy that drifts:
|
||||||
|
|
||||||
|
* **Rolling budgets.** Once any window is exhausted the hatch disappears
|
||||||
|
entirely -- it is not a slider, it is a hard stop.
|
||||||
|
* **Escalating lockout.** Each recent use doubles the wait, so the hatch gets
|
||||||
|
progressively less convenient than just doing the thing.
|
||||||
|
* **A written justification.** Long enough to be inconvenient to invent, shown
|
||||||
|
back to you next time so recycled excuses are visible.
|
||||||
|
* **Tamper evidence.** Every entry is HMAC-signed with a root-owned key, so
|
||||||
|
hand-editing the history is detectable.
|
||||||
|
|
||||||
|
App-specific policy stays with the app: screen-locker's commitment penalty and
|
||||||
|
workout debt are not general, and are deliberately not modelled here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DATE_FORMAT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
# The severity scale the dialog presents.
|
||||||
|
_MIN_SEVERITY = 1
|
||||||
|
_MAX_SEVERITY = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EscapePolicy:
|
||||||
|
"""How hard one app's escape hatch is to use.
|
||||||
|
|
||||||
|
Defaults mirror screen-locker's sick mode, which is the proven shape.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Short identifier used in logs and the summary line.
|
||||||
|
label: Human wording for one use, e.g. "sick day".
|
||||||
|
budget_per_7_days: Uses allowed in a rolling week.
|
||||||
|
budget_per_30_days: Uses allowed in a rolling month.
|
||||||
|
budget_per_90_days: Uses allowed in a rolling quarter.
|
||||||
|
lockout_seconds: Base wait before the hatch actually opens.
|
||||||
|
lockout_multiplier_per_recent: Wait multiplier per use in 30 days.
|
||||||
|
justification_min_chars: Minimum length of the written reason.
|
||||||
|
history_review_count: How many past reasons to show back to the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
budget_per_7_days: int = 1
|
||||||
|
budget_per_30_days: int = 3
|
||||||
|
budget_per_90_days: int = 10
|
||||||
|
lockout_seconds: int = 120
|
||||||
|
lockout_multiplier_per_recent: int = 2
|
||||||
|
justification_min_chars: int = 120
|
||||||
|
history_review_count: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EscapeHistory:
|
||||||
|
"""Persistent record of every use of one app's hatch."""
|
||||||
|
|
||||||
|
used_days: list[str] = field(default_factory=list)
|
||||||
|
justifications: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EscapeDraft:
|
||||||
|
"""A proposed use of the hatch, before validation."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
onset: str
|
||||||
|
severity: int
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
def _today_iso() -> str:
|
||||||
|
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||||
|
return datetime.now(tz=timezone.utc).strftime(_DATE_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(date_str: str) -> datetime | None:
|
||||||
|
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or None if unparsable.
|
||||||
|
|
||||||
|
An unparsable entry is *ignored* rather than fatal, but logged loudly:
|
||||||
|
silently counting it as zero would quietly widen the budget.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, _DATE_FORMAT).replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"escape history holds an unparsable date %r (%s) -- that entry is "
|
||||||
|
"IGNORED when counting against the budget",
|
||||||
|
date_str,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeTracker:
|
||||||
|
"""Budget, lockout and tamper-evident history for one app's hatch."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
policy: EscapePolicy,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
key_file: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Bind a policy to the file its history lives in."""
|
||||||
|
self._policy = policy
|
||||||
|
self._path = path
|
||||||
|
self._key_file = key_file
|
||||||
|
self.history = EscapeHistory()
|
||||||
|
self.tampered = 0
|
||||||
|
"""How many loaded entries failed their integrity check."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def policy(self) -> EscapePolicy:
|
||||||
|
"""The policy this tracker enforces."""
|
||||||
|
return self._policy
|
||||||
|
|
||||||
|
def load(self) -> EscapeHistory:
|
||||||
|
"""Read history from disk, tolerating a missing or corrupt file.
|
||||||
|
|
||||||
|
**Integrity problems never reduce recorded usage.** An entry that
|
||||||
|
fails its HMAC check is kept for budget counting and reported loudly,
|
||||||
|
rather than dropped. Dropping would be the intuitive move and is
|
||||||
|
exactly wrong: ``verify_entry_hmac`` also returns False when the key
|
||||||
|
file is merely *unreadable*, so "drop what you cannot verify" would
|
||||||
|
turn a chmod on the key into an unlimited escape hatch. Tampering is
|
||||||
|
something to shout about, not something that should widen the budget.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.history = EscapeHistory()
|
||||||
|
return self.history
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
_logger.warning("could not read escape history %s: %s", self._path, exc)
|
||||||
|
self.history = EscapeHistory()
|
||||||
|
return self.history
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
self.history = EscapeHistory()
|
||||||
|
return self.history
|
||||||
|
justifications = [
|
||||||
|
entry for entry in raw.get("justifications", []) if isinstance(entry, dict)
|
||||||
|
]
|
||||||
|
self.tampered = sum(1 for entry in justifications if not self._verified(entry))
|
||||||
|
self.history = EscapeHistory(
|
||||||
|
used_days=[d for d in raw.get("used_days", []) if isinstance(d, str)],
|
||||||
|
justifications=justifications,
|
||||||
|
)
|
||||||
|
return self.history
|
||||||
|
|
||||||
|
def _verified(self, entry: dict[str, Any]) -> bool:
|
||||||
|
"""Whether an entry's HMAC still matches its contents.
|
||||||
|
|
||||||
|
A failure is reported, never acted on by discarding the entry -- see
|
||||||
|
:meth:`load`.
|
||||||
|
"""
|
||||||
|
if verify_entry_hmac(entry, key_file=self._key_file):
|
||||||
|
return True
|
||||||
|
_logger.error(
|
||||||
|
"escape history entry dated %r failed its HMAC check -- the file "
|
||||||
|
"has been edited outside the app, or the signing key is "
|
||||||
|
"unreadable. The entry still counts against the budget.",
|
||||||
|
entry.get("date", "?"),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save(self) -> bool:
|
||||||
|
"""Write history back to disk. False if it could not be written."""
|
||||||
|
payload = {
|
||||||
|
"used_days": self.history.used_days,
|
||||||
|
"justifications": self.history.justifications,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
_logger.exception("could not save escape history %s", self._path)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def count_in_window(self, days: int, *, today: str | None = None) -> int:
|
||||||
|
"""How many uses fall in the trailing ``days`` window."""
|
||||||
|
today_str = today or _today_iso()
|
||||||
|
today_dt = _parse_iso(today_str)
|
||||||
|
if today_dt is None:
|
||||||
|
return 0
|
||||||
|
cutoff = today_dt - timedelta(days=days)
|
||||||
|
count = 0
|
||||||
|
for entry in self.history.used_days:
|
||||||
|
parsed = _parse_iso(entry)
|
||||||
|
if parsed is not None and cutoff < parsed <= today_dt:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def is_budget_exhausted(self, *, today: str | None = None) -> bool:
|
||||||
|
"""Whether any rolling window has reached its limit.
|
||||||
|
|
||||||
|
When True the app must not offer the hatch at all -- not offer it
|
||||||
|
greyed out, not offer it with a longer wait. Gone.
|
||||||
|
"""
|
||||||
|
policy = self._policy
|
||||||
|
return (
|
||||||
|
self.count_in_window(7, today=today) >= policy.budget_per_7_days
|
||||||
|
or self.count_in_window(30, today=today) >= policy.budget_per_30_days
|
||||||
|
or self.count_in_window(90, today=today) >= policy.budget_per_90_days
|
||||||
|
)
|
||||||
|
|
||||||
|
def compute_lockout_seconds(self, *, today: str | None = None) -> int:
|
||||||
|
"""Escalating wait: ``base * multiplier ** uses_in_30_days``."""
|
||||||
|
recent = self.count_in_window(30, today=today)
|
||||||
|
multiplier: int = self._policy.lockout_multiplier_per_recent**recent
|
||||||
|
return int(self._policy.lockout_seconds * multiplier)
|
||||||
|
|
||||||
|
def budget_summary(self, *, today: str | None = None) -> str:
|
||||||
|
"""One-line summary of remaining budget, for the dialog."""
|
||||||
|
policy = self._policy
|
||||||
|
week = self.count_in_window(7, today=today)
|
||||||
|
month = self.count_in_window(30, today=today)
|
||||||
|
quarter = self.count_in_window(90, today=today)
|
||||||
|
return (
|
||||||
|
f"{policy.label}: {week}/{policy.budget_per_7_days}w · "
|
||||||
|
f"{month}/{policy.budget_per_30_days}m · "
|
||||||
|
f"{quarter}/{policy.budget_per_90_days}q"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, draft: EscapeDraft) -> str | None:
|
||||||
|
"""Return a user-facing complaint about ``draft``, or None if valid."""
|
||||||
|
if not draft.reason.strip():
|
||||||
|
return "Say what the problem is."
|
||||||
|
if not draft.onset.strip():
|
||||||
|
return "Say when it started."
|
||||||
|
if not _MIN_SEVERITY <= draft.severity <= _MAX_SEVERITY:
|
||||||
|
return f"Severity must be between {_MIN_SEVERITY} and {_MAX_SEVERITY}."
|
||||||
|
minimum = self._policy.justification_min_chars
|
||||||
|
if len(draft.description.strip()) < minimum:
|
||||||
|
written = len(draft.description.strip())
|
||||||
|
return f"Explain properly: {written}/{minimum} characters."
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record(self, draft: EscapeDraft, *, today: str | None = None) -> bool:
|
||||||
|
"""Sign and append a use of the hatch. False if it could not be saved."""
|
||||||
|
today_str = today or _today_iso()
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"date": today_str,
|
||||||
|
"policy": self._policy.name,
|
||||||
|
"reason": draft.reason.strip(),
|
||||||
|
"onset": draft.onset.strip(),
|
||||||
|
"severity": draft.severity,
|
||||||
|
"description": draft.description.strip(),
|
||||||
|
}
|
||||||
|
signature = compute_entry_hmac(entry, key_file=self._key_file)
|
||||||
|
if signature is not None:
|
||||||
|
entry["hmac"] = signature
|
||||||
|
self.history.justifications.append(entry)
|
||||||
|
if today_str not in self.history.used_days:
|
||||||
|
self.history.used_days.append(today_str)
|
||||||
|
return self.save()
|
||||||
|
|
||||||
|
def recent_justifications(self, count: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""The most recent justifications, newest last."""
|
||||||
|
limit = count if count is not None else self._policy.history_review_count
|
||||||
|
return self.history.justifications[-limit:]
|
||||||
|
|
||||||
|
def format_recent(self, count: int | None = None) -> str:
|
||||||
|
"""Render past justifications for read-back in the dialog.
|
||||||
|
|
||||||
|
Showing these is half the deterrent: a recycled excuse is obvious when
|
||||||
|
the last ten are on screen above the form.
|
||||||
|
"""
|
||||||
|
entries = self.recent_justifications(count)
|
||||||
|
if not entries:
|
||||||
|
return "(no previous uses)"
|
||||||
|
return "\n".join(
|
||||||
|
f"{entry.get('date', '?')} [{entry.get('severity', '?')}/10] "
|
||||||
|
f"{entry.get('reason', '')}: {entry.get('description', '')[:120]}"
|
||||||
|
for entry in entries
|
||||||
|
)
|
||||||
343
gatelock/gatelock/tests/test_escape.py
Normal file
343
gatelock/gatelock/tests/test_escape.py
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudget:
|
||||||
|
"""Rolling windows."""
|
||||||
|
|
||||||
|
def test_counts_only_inside_the_window(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""An old use falls out of the 7-day window."""
|
||||||
|
tracker.history.used_days = ["2026-07-24", "2026-06-01"]
|
||||||
|
assert tracker.count_in_window(7, today=TODAY) == 1
|
||||||
|
assert tracker.count_in_window(90, today=TODAY) == 2
|
||||||
|
|
||||||
|
def test_unparsable_today_counts_nothing(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A broken 'today' cannot silently exhaust the budget."""
|
||||||
|
tracker.history.used_days = [TODAY]
|
||||||
|
assert tracker.count_in_window(7, today="garbage") == 0
|
||||||
|
|
||||||
|
def test_unparsable_entry_is_skipped(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A corrupt stored date is ignored, loudly."""
|
||||||
|
tracker.history.used_days = ["nonsense", TODAY]
|
||||||
|
assert tracker.count_in_window(7, today=TODAY) == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("days", "expected"),
|
||||||
|
[(["2026-07-25"], True), ([], False)],
|
||||||
|
)
|
||||||
|
def test_week_window(
|
||||||
|
self, tracker: EscapeTracker, days: list[str], *, expected: bool
|
||||||
|
) -> None:
|
||||||
|
"""One use exhausts the weekly budget of 1."""
|
||||||
|
tracker.history.used_days = days
|
||||||
|
assert tracker.is_budget_exhausted(today=TODAY) is expected
|
||||||
|
|
||||||
|
def test_month_window(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Three uses in 30 days exhausts, even if none is in the last week."""
|
||||||
|
tracker.history.used_days = ["2026-07-05", "2026-07-06", "2026-07-07"]
|
||||||
|
assert tracker.is_budget_exhausted(today=TODAY) is True
|
||||||
|
|
||||||
|
def test_quarter_window(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Ten uses in 90 days exhausts, spread out enough to clear the others."""
|
||||||
|
tracker.history.used_days = [f"2026-05-{day:02d}" for day in range(1, 11)]
|
||||||
|
assert tracker.is_budget_exhausted(today=TODAY) is True
|
||||||
|
|
||||||
|
def test_nothing_used_is_not_exhausted(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A clean history leaves the hatch available."""
|
||||||
|
assert tracker.is_budget_exhausted(today=TODAY) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLockout:
|
||||||
|
"""The wait grows the more you lean on the hatch."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("recent", "seconds"), [(0, 120), (1, 240), (2, 480), (3, 960)]
|
||||||
|
)
|
||||||
|
def test_doubles_per_recent_use(
|
||||||
|
self, tracker: EscapeTracker, recent: int, seconds: int
|
||||||
|
) -> None:
|
||||||
|
"""base * 2 ** uses_in_30_days."""
|
||||||
|
tracker.history.used_days = [f"2026-07-{10 + i:02d}" for i in range(recent)]
|
||||||
|
assert tracker.compute_lockout_seconds(today=TODAY) == seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
"""What counts as a real justification."""
|
||||||
|
|
||||||
|
def test_accepts_a_proper_one(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A complete draft passes."""
|
||||||
|
assert tracker.validate(good_draft()) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("draft", "fragment"),
|
||||||
|
[
|
||||||
|
(EscapeDraft("", "03:00", 5, "x" * 130), "what the problem is"),
|
||||||
|
(EscapeDraft("flu", "", 5, "x" * 130), "when it started"),
|
||||||
|
(EscapeDraft("flu", "03:00", 0, "x" * 130), "between 1 and 10"),
|
||||||
|
(EscapeDraft("flu", "03:00", 11, "x" * 130), "between 1 and 10"),
|
||||||
|
(EscapeDraft("flu", "03:00", 5, "short"), "Explain properly"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejections(
|
||||||
|
self, tracker: EscapeTracker, draft: EscapeDraft, fragment: str
|
||||||
|
) -> None:
|
||||||
|
"""Each missing or lazy field is refused with a specific complaint."""
|
||||||
|
message = tracker.validate(draft)
|
||||||
|
assert message is not None
|
||||||
|
assert fragment in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecord:
|
||||||
|
"""Writing a use down."""
|
||||||
|
|
||||||
|
def test_records_and_signs(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A use is appended, dated and signed."""
|
||||||
|
assert tracker.record(good_draft(), today=TODAY) is True
|
||||||
|
entry = tracker.history.justifications[-1]
|
||||||
|
assert entry["date"] == TODAY
|
||||||
|
assert entry["policy"] == "sick"
|
||||||
|
assert "hmac" in entry
|
||||||
|
|
||||||
|
def test_unsigned_when_key_unavailable(
|
||||||
|
self, tracker: EscapeTracker, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Without a key the entry is still recorded, just unsigned."""
|
||||||
|
tracker._key_file = tmp_path / "absent.key"
|
||||||
|
assert tracker.record(good_draft(), today=TODAY) is True
|
||||||
|
assert "hmac" not in tracker.history.justifications[-1]
|
||||||
|
|
||||||
|
def test_same_day_twice_counts_once(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Two uses on one day are one day of budget."""
|
||||||
|
tracker.record(good_draft(), today=TODAY)
|
||||||
|
tracker.record(good_draft(), today=TODAY)
|
||||||
|
assert tracker.history.used_days == [TODAY]
|
||||||
|
assert len(tracker.history.justifications) == 2
|
||||||
|
|
||||||
|
def test_uses_today_when_unspecified(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Omitting the date records the real today."""
|
||||||
|
tracker.record(good_draft())
|
||||||
|
assert tracker.history.used_days == [_today_iso()]
|
||||||
|
|
||||||
|
def test_save_failure_is_reported(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A read-only disk is surfaced, not swallowed."""
|
||||||
|
with patch("pathlib.Path.write_text", side_effect=OSError("ro")):
|
||||||
|
assert tracker.record(good_draft(), today=TODAY) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadBack:
|
||||||
|
"""Showing past excuses back to the user is half the deterrent."""
|
||||||
|
|
||||||
|
def test_empty(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""A clean history says so."""
|
||||||
|
assert tracker.format_recent() == "(no previous uses)"
|
||||||
|
assert tracker.recent_justifications() == []
|
||||||
|
|
||||||
|
def test_lists_recent(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Past entries are rendered with date, severity and reason."""
|
||||||
|
tracker.record(good_draft(), today=TODAY)
|
||||||
|
rendered = tracker.format_recent()
|
||||||
|
assert TODAY in rendered
|
||||||
|
assert "8/10" in rendered
|
||||||
|
assert "migraine" in rendered
|
||||||
|
|
||||||
|
def test_respects_explicit_limit(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""An explicit count overrides the policy default."""
|
||||||
|
for day in range(1, 6):
|
||||||
|
tracker.record(good_draft(), today=f"2026-07-{day:02d}")
|
||||||
|
assert len(tracker.recent_justifications(2)) == 2
|
||||||
|
|
||||||
|
def test_handles_entries_missing_fields(
|
||||||
|
self, tracker: EscapeTracker, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A partial entry renders without raising."""
|
||||||
|
(tmp_path / "escape.json").write_text(
|
||||||
|
json.dumps({"used_days": [], "justifications": [{}]}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
tracker.load()
|
||||||
|
assert "?" in tracker.format_recent()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummary:
|
||||||
|
"""The one-line budget readout."""
|
||||||
|
|
||||||
|
def test_summary_shows_all_three_windows(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""Week, month and quarter counts all appear."""
|
||||||
|
tracker.history.used_days = [TODAY]
|
||||||
|
assert tracker.budget_summary(today=TODAY) == "Sick: 1/1w · 1/3m · 1/10q"
|
||||||
|
|
||||||
|
def test_policy_property(self, tracker: EscapeTracker) -> None:
|
||||||
|
"""The tracker exposes the policy it enforces."""
|
||||||
|
assert tracker.policy is POLICY
|
||||||
Loading…
Reference in New Issue
Block a user