mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
8.1 KiB
Python
215 lines
8.1 KiB
Python
"""Tests for the reading shutdown hour: reading book-guard's ledger, failing closed.
|
|
|
|
Rows are signed against a temp key with gatelock's own ``compute_entry_hmac``
|
|
so a forged credit and a genuine one differ in exactly the way they do on the
|
|
machine. The key is patched on ``_reading_bonus`` itself: the module holds its
|
|
own binding of ``HMAC_KEY_FILE``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import patch
|
|
|
|
from gatelock.log_integrity import compute_entry_hmac
|
|
import pytest
|
|
|
|
from screen_locker import _reading_bonus
|
|
from screen_locker._reading_bonus import (
|
|
READING_BONUS_HOURS,
|
|
read_today,
|
|
reading_bonus_hours,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
_NOW = datetime(2026, 10, 2, 21, 0).astimezone()
|
|
|
|
|
|
@pytest.fixture
|
|
def key_file(tmp_path: Path) -> Iterator[Path]:
|
|
key = tmp_path / "hmac.key"
|
|
key.write_bytes(b"test-key-bytes")
|
|
with patch.object(_reading_bonus, "HMAC_KEY_FILE", key):
|
|
yield key
|
|
|
|
|
|
def _row(
|
|
kind: str = "credit",
|
|
*,
|
|
key: Path,
|
|
ended_at: object = None,
|
|
bonus: str = "1",
|
|
detail: object = None,
|
|
) -> dict[str, Any]:
|
|
"""One signed ledger row; ``detail`` overrides the default detail body."""
|
|
if detail is None:
|
|
stamp = _NOW - timedelta(hours=1) if ended_at is None else ended_at
|
|
if isinstance(stamp, datetime):
|
|
stamp = str(int(stamp.timestamp()))
|
|
detail = {"bonus": bonus, "ended_at": stamp}
|
|
body: dict[str, Any] = {"entry_id": f"{kind}:x", "kind": kind, "detail": detail}
|
|
return {**body, "hmac": compute_entry_hmac(body, key_file=key)}
|
|
|
|
|
|
def _ledger(tmp_path: Path, entries: list[Any]) -> Path:
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text(json.dumps({"entries": entries}))
|
|
return path
|
|
|
|
|
|
class TestReadToday:
|
|
def test_verified_bonus_credit_read_today(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
ledger = _ledger(tmp_path, [_row(key=key_file)])
|
|
assert read_today(ledger, now=_NOW) is True
|
|
|
|
def test_missing_ledger_is_a_plain_false(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
"""book-guard never ran: no reading, but no fault either."""
|
|
assert read_today(tmp_path / "missing.json", now=_NOW) is False
|
|
|
|
def test_session_ended_yesterday_does_not_count(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
"""Read at 22:30, quizzed next morning: earns the evening, not today."""
|
|
row = _row(key=key_file, ended_at=_NOW - timedelta(days=1))
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
|
|
def test_session_ending_after_now_does_not_count(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
row = _row(key=key_file, ended_at=_NOW + timedelta(minutes=5))
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
|
|
def test_small_session_without_bonus_flag_does_not_count(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
row = _row(key=key_file, bonus="0")
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
|
|
def test_non_credit_rows_never_count(self, tmp_path: Path, key_file: Path) -> None:
|
|
rows = [_row("reject", key=key_file), _row("escape", key=key_file), "junk"]
|
|
assert read_today(_ledger(tmp_path, rows), now=_NOW) is False
|
|
|
|
def test_non_dict_detail_does_not_count(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
row = _row(key=key_file, detail="not-a-dict")
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
|
|
def test_forged_credit_is_ignored(self, tmp_path: Path, key_file: Path) -> None:
|
|
row = _row(key=key_file, bonus="0")
|
|
row["detail"]["bonus"] = "1" # body no longer matches its hmac
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
|
|
@pytest.mark.parametrize("ended_at", ["soon", None])
|
|
def test_unusable_ended_at_does_not_count_and_warns(
|
|
self,
|
|
tmp_path: Path,
|
|
key_file: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
ended_at: object,
|
|
) -> None:
|
|
"""A missing ended_at stringifies to "None"; neither may invent a day."""
|
|
row = _row(key=key_file, detail={"bonus": "1", "ended_at": ended_at})
|
|
with caplog.at_level(logging.WARNING):
|
|
assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False
|
|
assert "no usable ended_at" in caplog.text
|
|
|
|
def test_one_good_row_among_bad_ones_counts(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
rows = [_row(key=key_file, bonus="0"), _row(key=key_file)]
|
|
assert read_today(_ledger(tmp_path, rows), now=_NOW) is True
|
|
|
|
def test_defaults_resolve_module_path_and_clock(
|
|
self, tmp_path: Path, key_file: Path
|
|
) -> None:
|
|
row = _row(key=key_file, ended_at=datetime.now().astimezone())
|
|
ledger = _ledger(tmp_path, [row])
|
|
with patch.object(_reading_bonus, "READING_LEDGER_FILE", ledger):
|
|
assert read_today() is True
|
|
|
|
|
|
class TestCannotCheck:
|
|
"""Every unreadable state is None -- never a confident False."""
|
|
|
|
def test_unreadable_key(self, tmp_path: Path) -> None:
|
|
with patch.object(_reading_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"):
|
|
assert read_today(_ledger(tmp_path, [])) is None
|
|
|
|
def test_unreadable_key_wins_over_a_missing_ledger(self, tmp_path: Path) -> None:
|
|
"""The key is checked first: without it even "no ledger" is unknown."""
|
|
with patch.object(_reading_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"):
|
|
assert read_today(tmp_path / "missing.json") is None
|
|
|
|
def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
key = tmp_path / "hmac.key"
|
|
key.write_bytes(b" \n")
|
|
with (
|
|
patch.object(_reading_bonus, "HMAC_KEY_FILE", key),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert read_today(_ledger(tmp_path, [])) is None
|
|
assert "is empty" in caplog.text
|
|
|
|
def test_invalid_json_is_logged_as_book_guards(
|
|
self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text("{not json")
|
|
with caplog.at_level(logging.WARNING):
|
|
assert read_today(path) is None
|
|
assert "book-guard ledger at" in caplog.text
|
|
assert "not valid JSON" in caplog.text
|
|
|
|
def test_no_entries_array(self, tmp_path: Path, key_file: Path) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
path.write_text(json.dumps({"entries": "nope"}))
|
|
assert read_today(path) is None
|
|
|
|
def test_unreadable_ledger(self, tmp_path: Path, key_file: Path) -> None:
|
|
"""A directory where the file should be: exists, but read_text fails."""
|
|
path = tmp_path / "ledger.json"
|
|
path.mkdir()
|
|
assert read_today(path) is None
|
|
|
|
|
|
class TestBonusHours:
|
|
def test_read_earns_the_hour(self) -> None:
|
|
with patch.object(_reading_bonus, "read_today", return_value=True):
|
|
assert reading_bonus_hours() == READING_BONUS_HOURS == 1
|
|
|
|
def test_not_read_earns_nothing(self) -> None:
|
|
with patch.object(_reading_bonus, "read_today", return_value=False):
|
|
assert reading_bonus_hours() == 0
|
|
|
|
def test_cannot_check_fails_closed_and_warns(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
with (
|
|
patch.object(_reading_bonus, "read_today", return_value=None),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert reading_bonus_hours() == 0
|
|
assert "could not be checked" in caplog.text
|
|
|
|
def test_passes_the_ledger_path_through(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "ledger.json"
|
|
with patch.object(_reading_bonus, "read_today", return_value=True) as m:
|
|
reading_bonus_hours(path)
|
|
m.assert_called_once_with(path)
|
|
|
|
def test_isolated_suite_never_sees_a_real_ledger(self, key_file: Path) -> None:
|
|
"""The conftest redirect points at an absent tmp file, so: 0, not None."""
|
|
assert not _reading_bonus.READING_LEDGER_FILE.exists()
|
|
assert reading_bonus_hours() == 0
|