screen-locker/screen_locker/tests/test_web_server.py
Krzysztof kuhy Rudnicki 8f911585c1 Serve the lock decision, its history and its health at 127.0.0.1:8770
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
2026-08-29 22:50:19 +02:00

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()