moonlock/tests/test_main.py
nevaforget fb11c551bd Security hardening based on triple audit (security, quality, performance)
- 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
2026-03-26 22:11:00 +01:00

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