mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 21:53:48 +01:00
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
162 lines
5.5 KiB
Python
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
|