screen-locker/screen_locker/tests/test_web_server_part2.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

82 lines
3.1 KiB
Python

"""Tests for _web_server static serving: SPA fallback and traversal guard.
Split out of test_web_server.py for the 250-line cap; the server harness and
the real_loopback fixture stay in that module and are imported here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from screen_locker.tests.test_web_server import (
_PKG,
_get,
_make_dist,
_running,
real_loopback,
)
if TYPE_CHECKING:
from pathlib import Path
__all__ = ["real_loopback"]
class TestStaticServing:
"""The built bundle is served with an SPA fallback and a traversal guard."""
@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_index_is_served_at_the_root(self, tmp_path: Path) -> None:
"""/ serves index.html."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/")
assert status == 200
assert b"INDEX" in body
assert ctype.startswith("text/html")
def test_an_asset_is_served_with_its_own_type(self, tmp_path: Path) -> None:
"""A real file is served rather than the SPA fallback."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/app.css")
assert status == 200
assert body == b"body{}"
assert ctype.startswith("text/css")
def test_a_binary_asset_gets_no_charset(self, tmp_path: Path) -> None:
"""A charset on image/png would be wrong; only text types get one."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/icon.png")
assert status == 200
assert body.startswith(b"\x89PNG")
assert ctype == "image/png"
def test_an_unknown_route_falls_back_to_index(self, tmp_path: Path) -> None:
"""SPA routes are handled by the bundle, not by the server."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, _ = _get(port, "/why/2026-08-29")
assert status == 200
assert b"INDEX" in body
def test_traversal_never_escapes_the_bundle(self, tmp_path: Path) -> None:
"""A ../ path is refused, not resolved against the filesystem."""
secret = tmp_path / "secret.txt"
secret.write_text("do not serve me", encoding="utf-8")
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, _ = _get(port, "/../secret.txt")
assert status == 200
assert b"do not serve me" not in body
def test_missing_bundle_says_how_to_build_it(self, tmp_path: Path) -> None:
"""404 carries the build command instead of an empty page."""
dist = _make_dist(tmp_path, with_index=False)
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, _ = _get(port, "/")
assert status == 404
assert b"npm --prefix web run build" in body