steam-backlog-enforcer/steam_backlog_enforcer/tests/test_enforcer.py
Krzysztof kuhy Rudnicki e82ddd9a4b playtime: shadow leisure billing (C4); /proc scan from leisure_watch
The enforcer now imports the Steam/launcher/counted-process scan from
leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops
its own copies (_playtime_procs, _counted_procs, _total_block_launchers and
enforcer's two scanners). Every counted-process read passes
config.CONFIG_FILE explicitly, as before.

Each tick also bills a shadow leisure budget beside the enforced one, from
the same incoming stamp and delta clamp, before accumulate: paused (held
gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game,
a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills
shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is
earned_time.resolve(leisure=True) from the same answers, held as a same-day
high-water mark. Not billed under a total block. Shadow verdict changes go
to budget.jsonl as shadow_change (the live game view skips them); /api/budget
and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff,
warnings and existing log records are unchanged.

Also fixes two tests that assumed earned_time < 0.14 internals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:37:01 +02:00

162 lines
5.5 KiB
Python

"""Tests for enforcer module."""
from __future__ import annotations
import subprocess
from unittest.mock import patch
from steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
kill_process,
send_notification,
)
class TestEnforceAllowedGame:
"""Tests for enforce_allowed_game."""
def test_no_violations(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 440},
):
result = enforce_allowed_game({440})
assert result == []
def test_kills_unauthorized(self) -> None:
with (
patch(
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570, 200: 440},
),
patch("steam_backlog_enforcer.enforcer.kill_process") as mock_kill,
):
result = enforce_allowed_game({440}, kill_unauthorized=True)
assert result == [(100, 570)]
mock_kill.assert_called_once_with(100, 570)
def test_skips_app_id_zero(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 0},
):
result = enforce_allowed_game({440})
assert result == []
def test_detects_without_killing(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570},
):
result = enforce_allowed_game({440}, kill_unauthorized=False)
assert result == [(100, 570)]
def test_allowed_none(self) -> None:
result = enforce_allowed_game(set(), kill_unauthorized=True)
assert result == []
def test_skips_protected_app_id(self) -> None:
"""Protected IDs must never be killed even if not the assigned game."""
with (
patch(
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 1331550, 200: 440},
),
patch(
"steam_backlog_enforcer.enforcer.is_protected_app",
side_effect=lambda aid: aid == 1331550,
),
patch("steam_backlog_enforcer.enforcer.kill_process") as mock_kill,
):
result = enforce_allowed_game({440}, kill_unauthorized=True)
assert result == []
mock_kill.assert_not_called()
class TestKillProcess:
"""Tests for kill_process."""
def test_kill_success(self) -> None:
with patch("steam_backlog_enforcer.enforcer.os.kill") as mock_kill:
kill_process(123, 440)
mock_kill.assert_called_once()
def test_process_already_gone(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.os.kill",
side_effect=ProcessLookupError,
):
kill_process(123, 440) # Should not raise
def test_permission_error(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.os.kill",
side_effect=PermissionError,
):
kill_process(123, 440) # Should not raise
class TestSendNotification:
"""Tests for send_notification."""
def test_sends(self) -> None:
with patch("steam_backlog_enforcer.enforcer.subprocess.run") as mock_run:
send_notification("Title", "Body")
mock_run.assert_called_once()
def test_handles_missing_notify_send(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=FileNotFoundError,
):
send_notification("Title", "Body") # Should not raise
def test_handles_os_error(self) -> None:
with patch(
"steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=OSError,
):
send_notification("Title", "Body") # Should not raise
class TestSendNotificationPrivilegeDrop:
"""As root, notifications must be delivered into the user's session bus.
A bare root ``notify-send`` has no DBUS_SESSION_BUS_ADDRESS and silently
reached nobody, for every caller of send_notification.
"""
_PKG = "steam_backlog_enforcer.enforcer"
_ENV = "steam_backlog_enforcer._desktop_env"
def test_root_drops_to_desktop_user(self) -> None:
with (
patch(f"{self._ENV}.os.geteuid", return_value=0),
patch(f"{self._ENV}.desktop_uid", return_value=1000),
patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
send_notification("Title", "Body")
argv = mock_run.call_args[0][0]
assert argv[:4] == ["sudo", "-u", "kuhy", "env"]
assert "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" in argv
def test_non_root_does_not_wrap(self) -> None:
with (
patch(f"{self._ENV}.os.geteuid", return_value=1000),
patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
send_notification("Title", "Body")
assert mock_run.call_args[0][0][0] != "sudo"
def test_handles_timeout(self) -> None:
"""sudo can block where a bare notify-send would not."""
with patch(
f"{self._PKG}.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 5),
):
send_notification("Title", "Body") # Should not raise