Global sys.excepthook unlocks the session on unhandled exceptions. Structured logging to stderr and optional file at /var/cache/moonlock/. Window creation, CSS loading, and fingerprint start wrapped in try/except with automatic session unlock when all windows fail.
243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
# ABOUTME: Tests for the Moonlock application entry point.
|
|
# ABOUTME: Covers logging setup, defensive window creation, and CSS error handling.
|
|
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_gtk(monkeypatch):
|
|
"""Prevent GTK from requiring a display during test collection."""
|
|
mock_gi = MagicMock()
|
|
mock_gtk = MagicMock()
|
|
mock_gdk = MagicMock()
|
|
mock_session_lock = MagicMock()
|
|
|
|
# Pre-populate gi.repository with our mocks
|
|
modules = {
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk, Gdk=mock_gdk, Gtk4SessionLock=mock_session_lock),
|
|
}
|
|
|
|
with monkeypatch.context() as m:
|
|
# Only patch missing/problematic modules if not already loaded
|
|
for mod_name, mod in modules.items():
|
|
if mod_name not in sys.modules:
|
|
m.setitem(sys.modules, mod_name, mod)
|
|
yield
|
|
|
|
|
|
def _import_main():
|
|
"""Import main module lazily after GTK mocking is in place."""
|
|
from moonlock.main import MoonlockApp, _setup_logging
|
|
return MoonlockApp, _setup_logging
|
|
|
|
|
|
class TestSetupLogging:
|
|
"""Tests for the logging infrastructure."""
|
|
|
|
def test_setup_logging_adds_stderr_handler(self):
|
|
"""_setup_logging() should add a StreamHandler to the root logger."""
|
|
_, _setup_logging = _import_main()
|
|
root = logging.getLogger()
|
|
handlers_before = len(root.handlers)
|
|
|
|
_setup_logging()
|
|
|
|
assert len(root.handlers) > handlers_before
|
|
|
|
# Clean up handlers we added
|
|
for handler in root.handlers[handlers_before:]:
|
|
root.removeHandler(handler)
|
|
|
|
def test_setup_logging_sets_info_level(self):
|
|
"""_setup_logging() should set root logger to INFO level."""
|
|
_, _setup_logging = _import_main()
|
|
root = logging.getLogger()
|
|
original_level = root.level
|
|
|
|
_setup_logging()
|
|
|
|
assert root.level == logging.INFO
|
|
|
|
# Restore
|
|
root.setLevel(original_level)
|
|
for handler in root.handlers[:]:
|
|
root.removeHandler(handler)
|
|
|
|
@patch("moonlock.main._LOG_DIR")
|
|
@patch("logging.FileHandler")
|
|
def test_setup_logging_adds_file_handler_when_dir_exists(self, mock_fh, mock_log_dir):
|
|
"""_setup_logging() should add a FileHandler when log directory exists."""
|
|
_, _setup_logging = _import_main()
|
|
mock_log_dir.is_dir.return_value = True
|
|
|
|
root = logging.getLogger()
|
|
handlers_before = len(root.handlers)
|
|
|
|
_setup_logging()
|
|
|
|
mock_fh.assert_called_once()
|
|
|
|
# Clean up
|
|
for handler in root.handlers[handlers_before:]:
|
|
root.removeHandler(handler)
|
|
|
|
@patch("moonlock.main._LOG_DIR")
|
|
def test_setup_logging_skips_file_handler_when_dir_missing(self, mock_log_dir):
|
|
"""_setup_logging() should not fail when log directory doesn't exist."""
|
|
_, _setup_logging = _import_main()
|
|
mock_log_dir.is_dir.return_value = False
|
|
|
|
root = logging.getLogger()
|
|
handlers_before = len(root.handlers)
|
|
|
|
_setup_logging()
|
|
|
|
assert len(root.handlers) >= handlers_before
|
|
|
|
# Clean up
|
|
for handler in root.handlers[handlers_before:]:
|
|
root.removeHandler(handler)
|
|
|
|
|
|
class TestCssErrorHandling:
|
|
"""Tests for CSS loading error handling."""
|
|
|
|
@patch("moonlock.main.Gdk.Display.get_default")
|
|
@patch("moonlock.main.Gtk.CssProvider")
|
|
@patch("moonlock.main.files")
|
|
def test_load_css_logs_error_on_exception(self, mock_files, mock_css_cls, mock_display):
|
|
"""CSS loading errors should be logged, not raised."""
|
|
MoonlockApp, _ = _import_main()
|
|
mock_files.return_value.__truediv__ = MagicMock(return_value=Path("/nonexistent"))
|
|
mock_css = MagicMock()
|
|
mock_css.load_from_path.side_effect = Exception("CSS parse error")
|
|
mock_css_cls.return_value = mock_css
|
|
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._config = MagicMock()
|
|
|
|
# Should not raise
|
|
with patch("moonlock.main.logger") as mock_logger:
|
|
app._load_css()
|
|
mock_logger.exception.assert_called_once()
|
|
|
|
|
|
class TestDefensiveWindowCreation:
|
|
"""Tests for defensive window creation in session lock mode."""
|
|
|
|
@patch("moonlock.main.LockscreenWindow")
|
|
@patch("moonlock.main.FingerprintListener")
|
|
@patch("moonlock.main.Gdk.Display.get_default")
|
|
@patch("moonlock.main.Gtk4SessionLock")
|
|
def test_single_window_failure_does_not_stop_other_windows(
|
|
self, mock_session_lock, mock_display, mock_fp, mock_window_cls
|
|
):
|
|
"""If one window fails, others should still be created."""
|
|
MoonlockApp, _ = _import_main()
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._config = MagicMock()
|
|
app._windows = []
|
|
|
|
# Two monitors
|
|
monitor1 = MagicMock()
|
|
monitor2 = MagicMock()
|
|
monitors = MagicMock()
|
|
monitors.get_n_items.return_value = 2
|
|
monitors.get_item.side_effect = [monitor1, monitor2]
|
|
mock_display.return_value.get_monitors.return_value = monitors
|
|
|
|
lock_instance = MagicMock()
|
|
mock_session_lock.Instance.new.return_value = lock_instance
|
|
app._lock_instance = lock_instance
|
|
|
|
# First window creation fails, second succeeds
|
|
window_ok = MagicMock()
|
|
mock_window_cls.side_effect = [Exception("GTK error"), window_ok]
|
|
|
|
with patch("moonlock.main.logger"):
|
|
app._activate_with_session_lock()
|
|
|
|
# One window should have been created despite the first failure
|
|
assert len(app._windows) == 1
|
|
|
|
@patch("moonlock.main.LockscreenWindow")
|
|
@patch("moonlock.main.FingerprintListener")
|
|
@patch("moonlock.main.Gdk.Display.get_default")
|
|
@patch("moonlock.main.Gtk4SessionLock")
|
|
def test_all_windows_fail_unlocks_session(
|
|
self, mock_session_lock, mock_display, mock_fp, mock_window_cls
|
|
):
|
|
"""If ALL windows fail to create, session should be unlocked to prevent lockout."""
|
|
MoonlockApp, _ = _import_main()
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._config = MagicMock()
|
|
app._windows = []
|
|
|
|
# One monitor
|
|
monitors = MagicMock()
|
|
monitors.get_n_items.return_value = 1
|
|
monitors.get_item.return_value = MagicMock()
|
|
mock_display.return_value.get_monitors.return_value = monitors
|
|
|
|
lock_instance = MagicMock()
|
|
mock_session_lock.Instance.new.return_value = lock_instance
|
|
app._lock_instance = lock_instance
|
|
|
|
# Window creation fails
|
|
mock_window_cls.side_effect = Exception("GTK error")
|
|
|
|
with patch("moonlock.main.logger"):
|
|
app._activate_with_session_lock()
|
|
|
|
# Session should have been unlocked to prevent permanent lockout
|
|
lock_instance.unlock.assert_called_once()
|
|
|
|
|
|
class TestCrashGuard:
|
|
"""Tests for the global exception handler that prevents lockout on crash."""
|
|
|
|
def test_crash_guard_unlocks_session_on_unhandled_exception(self):
|
|
"""sys.excepthook should unlock the session when an exception reaches it."""
|
|
MoonlockApp, _ = _import_main()
|
|
from moonlock.main import _install_crash_guard
|
|
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._lock_instance = MagicMock()
|
|
|
|
original_hook = sys.excepthook
|
|
_install_crash_guard(app)
|
|
|
|
try:
|
|
# Simulate an unhandled exception reaching the hook
|
|
with patch("moonlock.main.logger"):
|
|
sys.excepthook(RuntimeError, RuntimeError("segfault"), None)
|
|
|
|
app._lock_instance.unlock.assert_called_once()
|
|
finally:
|
|
sys.excepthook = original_hook
|
|
|
|
def test_crash_guard_survives_unlock_failure(self):
|
|
"""Crash guard should not raise even if unlock() itself fails."""
|
|
MoonlockApp, _ = _import_main()
|
|
from moonlock.main import _install_crash_guard
|
|
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._lock_instance = MagicMock()
|
|
app._lock_instance.unlock.side_effect = Exception("protocol error")
|
|
|
|
original_hook = sys.excepthook
|
|
_install_crash_guard(app)
|
|
|
|
try:
|
|
with patch("moonlock.main.logger"):
|
|
# Should not raise despite unlock failure
|
|
sys.excepthook(RuntimeError, RuntimeError("crash"), None)
|
|
finally:
|
|
sys.excepthook = original_hook
|