- Remove SIGUSR1 unlock handler (unauthenticated unlock vector) - Sanitize LD_PRELOAD (discard inherited environment) - Refuse to run as root - Validate D-Bus signal sender against fprintd proxy owner - Pass bytearray (not str) to PAM conversation callback for wipeable password - Resolve libc before returning CFUNCTYPE callback - Bundle fingerprint success idle_add into single atomic callback - Add running/device_proxy guards to VerifyStart retries with error handling - Add fingerprint attempt counter (max 10 before disabling) - Add power button confirmation dialog (inline yes/cancel) - Move fingerprint D-Bus init before session lock to avoid mainloop blocking - Resolve wallpaper path once, share across all monitor windows - Document faillock as UI-only (pam_faillock handles real brute-force protection) - Fix type hints (Callable), remove dead import (c_char), fix import order
230 lines
7.7 KiB
Python
230 lines
7.7 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_does_not_unlock_session(
|
|
self, mock_session_lock, mock_display, mock_fp, mock_window_cls
|
|
):
|
|
"""If ALL windows fail, session stays locked (compositor policy)."""
|
|
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 must NOT be unlocked — compositor keeps screen locked
|
|
lock_instance.unlock.assert_not_called()
|
|
|
|
|
|
class TestExcepthook:
|
|
"""Tests for the global exception handler."""
|
|
|
|
def test_excepthook_does_not_unlock(self):
|
|
"""Unhandled exceptions must NOT unlock the session."""
|
|
MoonlockApp, _ = _import_main()
|
|
from moonlock.main import _install_excepthook
|
|
|
|
app = MoonlockApp.__new__(MoonlockApp)
|
|
app._lock_instance = MagicMock()
|
|
|
|
original_hook = sys.excepthook
|
|
_install_excepthook()
|
|
|
|
try:
|
|
with patch("moonlock.main.logger"):
|
|
sys.excepthook(RuntimeError, RuntimeError("crash"), None)
|
|
|
|
# Must NOT have called unlock
|
|
app._lock_instance.unlock.assert_not_called()
|
|
finally:
|
|
sys.excepthook = original_hook
|
|
|
|
def test_no_sigusr1_handler(self):
|
|
"""SIGUSR1 must NOT be handled — signal-based unlock is a security hole."""
|
|
import signal
|
|
handler = signal.getsignal(signal.SIGUSR1)
|
|
assert handler is signal.SIG_DFL
|