mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
- addopts: -n 4 --dist worksteal (fixed, not auto: 24 workers would break capped.sh's 4 GiB). CI's own -n auto still wins. - coverage core = sysmon (PEP 669): ~40% less tracing overhead, same data. - shutdown ceiling invariant: one plain-function patch of earned_units per day instead of a MagicMock per unit (~5000 per case); every day and every case still checked. - poke/web server tests: serve_forever(poll_interval=0.01), so shutdown() no longer waits 0.5 s per test. - autouse _no_real_ntp: check_clock_skew sent real UDP to pool.ntp.org from every verification test, and a lost reply cost a random test 5 s. - temperature hard-timeout test: pins the default to 5 s and times the mechanism at 0.1 s instead of waiting 5 real seconds. - _xvfb_reexec: Xvfb -displayfd instead of xvfb-run's fixed 3 s sleep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
8.5 KiB
Python
250 lines
8.5 KiB
Python
"""The poke HTTP front end, driven over real loopback HTTP on an ephemeral port."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
from http.client import HTTPConnection
|
|
import json
|
|
import socket
|
|
import threading
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from screen_locker import _poke_credit, _poke_server
|
|
from screen_locker._poke_server import MAX_BODY_BYTES, POKE_PATH, PokeServer
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
# conftest stubs socket.create_connection process-wide (see test_web_server).
|
|
_REAL_CREATE_CONNECTION = socket.create_connection
|
|
_KEY = bytes(range(32))
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return time.time_ns() // 1_000_000
|
|
|
|
|
|
def _body(nonce: str = "n-1", **overrides: object) -> bytes:
|
|
doc: dict[str, Any] = {
|
|
"v": 1,
|
|
"sent_at_ms": _now_ms(),
|
|
"nonce": nonce,
|
|
"sandbox": False,
|
|
"record_id": "rec-1",
|
|
"payload": {"date": "2026-10-09", "note": "zażółć"},
|
|
}
|
|
doc.update(overrides)
|
|
return json.dumps(doc, ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def _sign(body: bytes) -> str:
|
|
return hmac.new(_KEY, body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
@pytest.fixture
|
|
def port(monkeypatch: pytest.MonkeyPatch) -> Iterator[int]:
|
|
"""A running PokeServer on 127.0.0.1:0; shut down afterwards."""
|
|
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
|
|
server = PokeServer(("127.0.0.1", 0), _KEY)
|
|
# poll_interval: shutdown() waits out one poll, 0.5 s per test by default.
|
|
thread = threading.Thread(target=server.serve_forever, args=(0.01,), daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server.server_address[1]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def credit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> MagicMock:
|
|
"""Stub the PC side: tmp locker, fixed snapshot, a spy for the credit."""
|
|
locker = MagicMock()
|
|
locker.log_file = tmp_path / "log.json"
|
|
monkeypatch.setattr(_poke_credit, "fresh_locker", lambda: locker)
|
|
monkeypatch.setattr(_poke_credit, "current_shutdown", lambda: "20:30")
|
|
monkeypatch.setattr(_poke_credit, "current_gaming_minutes", lambda _p: 75)
|
|
spy = MagicMock(
|
|
return_value=_poke_credit.PokeOutcome(
|
|
ok=True, credited=True, duplicate=False, reason="workout credited"
|
|
)
|
|
)
|
|
monkeypatch.setattr(_poke_credit, "credit_session", spy)
|
|
return spy
|
|
|
|
|
|
def _post(
|
|
port: int,
|
|
body: bytes,
|
|
signature: str | None,
|
|
path: str = POKE_PATH,
|
|
) -> tuple[int, dict[str, Any]]:
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
|
headers = {"Content-Type": "application/json"}
|
|
if signature is not None:
|
|
headers["X-Poke-Signature"] = signature
|
|
conn.request("POST", path, body=body, headers=headers)
|
|
resp = conn.getresponse()
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
conn.close()
|
|
return resp.status, data
|
|
|
|
|
|
def _raw(port: int, lines: list[str], send: bytes = b"") -> tuple[int, dict[str, Any]]:
|
|
"""A hand-written POST (so headers http.client would add can be omitted)."""
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
|
conn.putrequest("POST", POKE_PATH)
|
|
for line in lines:
|
|
name, value = line.split(": ", 1)
|
|
conn.putheader(name, value)
|
|
conn.endheaders()
|
|
if send and conn.sock is not None:
|
|
conn.sock.sendall(send)
|
|
if conn.sock is not None:
|
|
conn.sock.shutdown(socket.SHUT_WR)
|
|
resp = conn.getresponse()
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
conn.close()
|
|
return resp.status, data
|
|
|
|
|
|
class TestCredit:
|
|
def test_valid_poke_credits_and_answers_the_contract_shape(
|
|
self, port: int, credit: MagicMock
|
|
) -> None:
|
|
body = _body()
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert status == 200
|
|
assert reply["ok"] is True
|
|
assert reply["credited"] is True
|
|
assert reply["duplicate"] is False
|
|
assert reply["sandbox"] is False
|
|
assert reply["shutdown"] == "20:30"
|
|
assert reply["gaming_budget_minutes"] == 75
|
|
assert isinstance(reply["pc_ms"], int)
|
|
credit.assert_called_once_with(
|
|
"rec-1", {"date": "2026-10-09", "note": "zażółć"}
|
|
)
|
|
|
|
def test_replayed_nonce_is_409_and_credits_once(
|
|
self, port: int, credit: MagicMock
|
|
) -> None:
|
|
body = _body()
|
|
assert _post(port, body, _sign(body))[0] == 200
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert status == 409
|
|
assert reply["ok"] is False
|
|
assert reply["shutdown"] is None
|
|
assert credit.call_count == 1
|
|
|
|
def test_stale_timestamp_is_401(self, port: int, credit: MagicMock) -> None:
|
|
body = _body(sent_at_ms=_now_ms() - 600_000)
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert status == 401
|
|
assert "stale" in reply["reason"]
|
|
credit.assert_not_called()
|
|
|
|
def test_bad_or_missing_signature_is_401(
|
|
self, port: int, credit: MagicMock
|
|
) -> None:
|
|
body = _body()
|
|
assert _post(port, body, "00" * 32)[0] == 401
|
|
assert _post(port, body, None)[0] == 401
|
|
credit.assert_not_called()
|
|
|
|
def test_schema_violation_is_400(self, port: int, credit: MagicMock) -> None:
|
|
body = _body(v=2)
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert status == 400
|
|
assert "protocol version" in reply["reason"]
|
|
credit.assert_not_called()
|
|
|
|
def test_internal_error_is_500_and_says_the_sync_will_cover_it(
|
|
self,
|
|
port: int,
|
|
credit: MagicMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
credit.side_effect = RuntimeError("boom")
|
|
body = _body()
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert status == 500
|
|
assert reply["ok"] is False
|
|
assert "regular sync will still credit" in reply["reason"]
|
|
assert "crashed" in caplog.text
|
|
|
|
|
|
class TestSandbox:
|
|
def test_sandbox_poke_over_http_is_answered_but_not_credited(
|
|
self, port: int, credit: MagicMock
|
|
) -> None:
|
|
body = _body(sandbox=True)
|
|
status, reply = _post(port, body, _sign(body))
|
|
assert (status, reply["ok"], reply["sandbox"]) == (200, True, True)
|
|
assert reply["credited"] is False
|
|
credit.assert_not_called()
|
|
|
|
def test_sandbox_with_a_bad_signature_is_still_refused(
|
|
self, port: int, credit: MagicMock
|
|
) -> None:
|
|
status, reply = _post(port, _body(sandbox=True), "00" * 32)
|
|
assert status == 401
|
|
assert reply["sandbox"] is False
|
|
|
|
|
|
class TestRouting:
|
|
def test_unknown_path_is_404(self, port: int, credit: MagicMock) -> None:
|
|
body = _body()
|
|
status, reply = _post(port, body, _sign(body), path="/v2/other")
|
|
assert status == 404
|
|
assert "unknown path" in reply["reason"]
|
|
|
|
def test_get_is_405(self, port: int) -> None:
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
|
conn.request("GET", POKE_PATH)
|
|
resp = conn.getresponse()
|
|
data = json.loads(resp.read())
|
|
conn.close()
|
|
assert resp.status == 405
|
|
assert "only POST" in data["reason"]
|
|
|
|
def test_missing_content_length_is_411(self, port: int) -> None:
|
|
status, _ = _raw(port, ["Host: x"])
|
|
assert status == 411
|
|
|
|
@pytest.mark.parametrize("value", ["abc", "-5", ""])
|
|
def test_malformed_content_length_is_400(self, port: int, value: str) -> None:
|
|
status, reply = _raw(port, [f"Content-Length: {value}"])
|
|
assert status == 400
|
|
assert "bad Content-Length" in reply["reason"]
|
|
|
|
def test_oversized_body_is_413_without_reading_it(self, port: int) -> None:
|
|
status, reply = _raw(port, [f"Content-Length: {MAX_BODY_BYTES + 1}"])
|
|
assert status == 413
|
|
assert str(MAX_BODY_BYTES) in reply["reason"]
|
|
|
|
def test_body_at_the_cap_is_read_and_judged_on_its_signature(
|
|
self, port: int
|
|
) -> None:
|
|
body = b"x" * MAX_BODY_BYTES
|
|
assert _post(port, body, _sign(body))[0] == 400
|
|
|
|
def test_truncated_body_is_400(self, port: int) -> None:
|
|
status, reply = _raw(port, ["Content-Length: 100"], send=b"0123456789")
|
|
assert status == 400
|
|
assert "10 of 100 bytes" in reply["reason"]
|
|
|
|
|
|
def test_reply_for_a_refusal_leaves_the_snapshot_null() -> None:
|
|
reply = _poke_server._refused("nope")
|
|
assert reply["ok"] is False
|
|
assert (reply["shutdown"], reply["gaming_budget_minutes"]) == (None, None)
|
|
assert reply["reason"] == "nope"
|