mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:47:13 +01:00
The only status surface was a GTK tray icon, commented out of ~/.config/i3 on 2026-08-21. So when the weekly count went wrong, nothing on the machine said so for two weeks -- the same silence that let a deleted timer job stop enforcement for thirteen days in August. Three read-only endpoints, and a React 19 UI over them: /api/status the decision, the week, and whether today has a workout /api/decisions the recorded trail, newest first /api/health timers armed, disarm marker, log and trail freshness The Why view is the one that matters: it renders the run of "weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone having to reach for journalctl. /api/status also carries gaming.workout_today, which is the fact steam-backlog-enforcer will cut the daily budget on. Deliberately just the fact -- the enforcer owns the hour values, so the two repos cannot come to disagree about what a workout day is. It is derived from count_day_credits, the same rule the locker enforces with. _compliance_state_word moves from status_view (tkinter) into _status_projection, and the systemd arming check moves from scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so the server can answer "is enforcement armed" without a second copy of the logic that would be free to disagree with the gate. web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no "strict" in any tsconfig, extends only tseslint recommended (no type-aware rules at all), and gates neither lint nor typecheck in pre-commit. Here all three run, on strictTypeChecked + stylisticTypeChecked, with noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of statements, branches, functions and lines. typescript stays pinned at 6.0.3: 7.0.2 is out, but every published typescript-eslint -- stable, canary and rc-v8 -- declares typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry. The 250-line cap now covers ts/tsx. It was python/shell/dart only, which would have made web/ exempt from the gate the rest of the repo lives under -- the same omission that exempted the whole Flutter app until 2026-08-15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
210 lines
7.2 KiB
Python
210 lines
7.2 KiB
Python
"""Tests for _web_server: routing, static serving, and the loopback guard.
|
|
|
|
The server is started on an ephemeral port and driven over real HTTP rather
|
|
than by calling handler methods directly, so the response codes and content
|
|
types tested here are the ones a browser and the enforcer actually see.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from http.client import HTTPConnection
|
|
import json
|
|
import socket
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from screen_locker._web_server import _decision_limit, create_server, main, serve
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
_PKG = "screen_locker._web_server"
|
|
|
|
# conftest's autouse _block_real_network patches
|
|
# "screen_locker._http_workout_fetch.socket.create_connection", and that
|
|
# attribute path resolves to the *global* socket module -- so every test in the
|
|
# suite has socket.create_connection stubbed, including loopback connections to
|
|
# a server we started ourselves. Captured at import time, before any fixture
|
|
# runs, which is the only moment the real function is still reachable. The
|
|
# fixture's own docstring says tests needing a real probe patch it locally.
|
|
_REAL_CREATE_CONNECTION = socket.create_connection
|
|
|
|
|
|
@pytest.fixture
|
|
def real_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Restore real socket connections for tests that drive the server.
|
|
|
|
Args:
|
|
monkeypatch: pytest's attribute patcher, which undoes this on teardown.
|
|
"""
|
|
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
|
|
|
|
|
|
@contextmanager
|
|
def _running() -> Iterator[int]:
|
|
"""Start the server on an ephemeral port in a thread; yield the port.
|
|
|
|
Yields:
|
|
The bound port.
|
|
"""
|
|
server = create_server("127.0.0.1", 0)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server.server_address[1]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def _get(port: int, path: str) -> tuple[int, bytes, str]:
|
|
"""Make a GET request, returning ``(status, body, content-type)``.
|
|
|
|
Args:
|
|
port: Port to connect to.
|
|
path: Request path.
|
|
|
|
Returns:
|
|
The status code, body bytes and Content-Type header.
|
|
"""
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=5)
|
|
try:
|
|
conn.request("GET", path)
|
|
resp = conn.getresponse()
|
|
return resp.status, resp.read(), resp.headers.get("Content-Type", "")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _make_dist(tmp_path: Path, *, with_index: bool = True) -> Path:
|
|
"""Create a fake built-frontend directory.
|
|
|
|
Args:
|
|
tmp_path: pytest temporary directory.
|
|
with_index: Whether to write an ``index.html``.
|
|
|
|
Returns:
|
|
The resolved dist directory.
|
|
"""
|
|
dist = (tmp_path / "dist").resolve()
|
|
dist.mkdir()
|
|
if with_index:
|
|
(dist / "index.html").write_text("<html>INDEX</html>", encoding="utf-8")
|
|
(dist / "app.css").write_text("body{}", encoding="utf-8")
|
|
(dist / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
return dist
|
|
|
|
|
|
class TestDecisionLimit:
|
|
"""?limit= is parsed defensively; a bad value never reaches the reader."""
|
|
|
|
def test_absent_limit_uses_the_default(self) -> None:
|
|
"""No query string means the default page size."""
|
|
assert _decision_limit("") > 0
|
|
|
|
def test_explicit_limit_is_honoured(self) -> None:
|
|
"""A sane limit passes through."""
|
|
assert _decision_limit("limit=5") == 5
|
|
|
|
def test_non_numeric_limit_falls_back(self) -> None:
|
|
"""Garbage in the query string must not 500 the endpoint."""
|
|
assert _decision_limit("limit=abc") == _decision_limit("")
|
|
|
|
def test_zero_or_negative_limit_falls_back(self) -> None:
|
|
"""A limit of zero would render an empty history for no reason."""
|
|
assert _decision_limit("limit=0") == _decision_limit("")
|
|
|
|
|
|
class TestApiRoutes:
|
|
"""Each API path serves its own payload as JSON."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _network(self, real_loopback: None) -> None:
|
|
"""Every test in this class talks to the server over real HTTP."""
|
|
|
|
def test_status_route(self) -> None:
|
|
"""/api/status serves the status payload."""
|
|
with (
|
|
patch(f"{_PKG}.build_status_payload", return_value={"ok": True}),
|
|
_running() as port,
|
|
):
|
|
status, body, ctype = _get(port, "/api/status")
|
|
assert status == 200
|
|
assert json.loads(body) == {"ok": True}
|
|
assert ctype.startswith("application/json")
|
|
|
|
def test_health_route(self) -> None:
|
|
"""/api/health serves the health payload."""
|
|
with (
|
|
patch(f"{_PKG}.build_health_payload", return_value={"armed": False}),
|
|
_running() as port,
|
|
):
|
|
status, body, _ = _get(port, "/api/health")
|
|
assert status == 200
|
|
assert json.loads(body) == {"armed": False}
|
|
|
|
def test_decisions_route_passes_the_limit_through(self) -> None:
|
|
"""/api/decisions forwards ?limit= to the payload builder."""
|
|
with (
|
|
patch(
|
|
f"{_PKG}.build_decisions_payload", return_value={"total": 0}
|
|
) as build,
|
|
_running() as port,
|
|
):
|
|
status, _, _ = _get(port, "/api/decisions?limit=7")
|
|
assert status == 200
|
|
build.assert_called_once_with(limit=7)
|
|
|
|
def test_a_broken_payload_returns_500_and_says_which(self) -> None:
|
|
"""A failing builder must never look like an endpoint with no news."""
|
|
with (
|
|
patch(f"{_PKG}.build_status_payload", side_effect=OSError("disk gone")),
|
|
_running() as port,
|
|
):
|
|
status, body, _ = _get(port, "/api/status")
|
|
assert status == 500
|
|
assert b"status error" in body
|
|
|
|
|
|
class TestBindGuard:
|
|
"""The API is unauthenticated, so it may only ever bind loopback."""
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"])
|
|
def test_loopback_is_allowed(self, host: str) -> None:
|
|
"""Both loopback spellings are accepted."""
|
|
server = create_server(host, 0)
|
|
server.server_close()
|
|
|
|
def test_a_public_bind_is_refused(self) -> None:
|
|
"""A LAN address would publish the payloads beyond this machine."""
|
|
with pytest.raises(ValueError, match="loopback-only"):
|
|
create_server("192.0.2.1", 0)
|
|
|
|
|
|
class TestServeEntryPoints:
|
|
"""serve() and main() wire the server to the process lifetime."""
|
|
|
|
def test_serve_closes_the_socket_on_interrupt(self) -> None:
|
|
"""Ctrl-C shuts down cleanly rather than leaving the port bound."""
|
|
server = create_server("127.0.0.1", 0)
|
|
with (
|
|
patch(f"{_PKG}.create_server", return_value=server),
|
|
patch.object(server, "serve_forever", side_effect=KeyboardInterrupt),
|
|
patch.object(server, "server_close") as close,
|
|
):
|
|
serve()
|
|
close.assert_called_once()
|
|
server.server_close()
|
|
|
|
def test_main_returns_zero(self) -> None:
|
|
"""The console-script entry point reports success on a clean exit."""
|
|
with patch(f"{_PKG}.serve") as served:
|
|
assert main() == 0
|
|
served.assert_called_once()
|