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
82 lines
3.1 KiB
Python
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
|