- 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
238 lines
8.7 KiB
Python
238 lines
8.7 KiB
Python
# ABOUTME: Tests for fprintd D-Bus integration.
|
|
# ABOUTME: Verifies fingerprint listener lifecycle and signal handling with mocked D-Bus.
|
|
|
|
from unittest.mock import patch, MagicMock, call
|
|
|
|
from moonlock.fingerprint import FingerprintListener
|
|
|
|
|
|
class TestFingerprintListenerAvailability:
|
|
"""Tests for checking fprintd availability."""
|
|
|
|
@patch("moonlock.fingerprint.Gio.DBusProxy.new_for_bus_sync")
|
|
def test_is_available_when_fprintd_running_and_enrolled(self, mock_proxy_cls):
|
|
manager = MagicMock()
|
|
mock_proxy_cls.return_value = manager
|
|
manager.GetDefaultDevice.return_value = ("(o)", "/dev/0")
|
|
|
|
device = MagicMock()
|
|
mock_proxy_cls.return_value = device
|
|
device.ListEnrolledFingers.return_value = ("(as)", ["right-index-finger"])
|
|
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._manager_proxy = manager
|
|
listener._device_proxy = device
|
|
listener._device_path = "/dev/0"
|
|
|
|
assert listener.is_available("testuser") is True
|
|
|
|
def test_is_available_returns_false_when_no_device(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = None
|
|
listener._device_path = None
|
|
|
|
assert listener.is_available("testuser") is False
|
|
|
|
|
|
class TestFingerprintListenerLifecycle:
|
|
"""Tests for start/stop lifecycle."""
|
|
|
|
def test_start_calls_verify_start(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._device_path = "/dev/0"
|
|
listener._signal_id = None
|
|
listener._running = False
|
|
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
|
|
listener.start("testuser", on_success=on_success, on_failure=on_failure)
|
|
|
|
listener._device_proxy.Claim.assert_called_once_with("(s)", "testuser")
|
|
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
|
|
|
|
def test_stop_calls_verify_stop_and_release(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
listener._signal_id = 42
|
|
|
|
listener.stop()
|
|
|
|
listener._device_proxy.VerifyStop.assert_called_once()
|
|
listener._device_proxy.Release.assert_called_once()
|
|
assert listener._running is False
|
|
|
|
def test_stop_is_noop_when_not_running(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = False
|
|
|
|
listener.stop()
|
|
|
|
listener._device_proxy.VerifyStop.assert_not_called()
|
|
|
|
|
|
class TestFingerprintSignalHandling:
|
|
"""Tests for VerifyStatus signal processing."""
|
|
|
|
def test_verify_match_calls_on_success(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
listener._on_success = on_success
|
|
listener._on_failure = on_failure
|
|
|
|
listener._on_verify_status("verify-match", False)
|
|
|
|
on_success.assert_called_once()
|
|
|
|
def test_verify_no_match_calls_on_failure_and_retries_when_done(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
listener._on_success = on_success
|
|
listener._on_failure = on_failure
|
|
|
|
listener._on_verify_status("verify-no-match", True)
|
|
|
|
on_failure.assert_called_once()
|
|
# Should restart verification when done=True
|
|
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
|
|
|
|
def test_verify_no_match_calls_on_failure_without_restart_when_not_done(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
listener._on_success = on_success
|
|
listener._on_failure = on_failure
|
|
|
|
listener._on_verify_status("verify-no-match", False)
|
|
|
|
on_failure.assert_called_once()
|
|
# Should NOT restart verification when done=False (still in progress)
|
|
listener._device_proxy.VerifyStart.assert_not_called()
|
|
|
|
def test_verify_swipe_too_short_retries_when_done(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
listener._on_success = on_success
|
|
listener._on_failure = on_failure
|
|
|
|
listener._on_verify_status("verify-swipe-too-short", True)
|
|
|
|
on_success.assert_not_called()
|
|
on_failure.assert_not_called()
|
|
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
|
|
|
|
def test_retry_status_does_not_restart_when_not_done(self):
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._running = True
|
|
listener._failed_attempts = 0
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
listener._on_success = on_success
|
|
listener._on_failure = on_failure
|
|
|
|
listener._on_verify_status("verify-swipe-too-short", False)
|
|
|
|
on_success.assert_not_called()
|
|
on_failure.assert_not_called()
|
|
# Should NOT restart — verification still in progress
|
|
listener._device_proxy.VerifyStart.assert_not_called()
|
|
|
|
|
|
class TestFingerprintStartErrorHandling:
|
|
"""Tests for GLib.Error handling in start()."""
|
|
|
|
def test_claim_glib_error_logs_and_returns_without_starting(self):
|
|
"""When Claim() raises GLib.Error, start() should not proceed."""
|
|
from gi.repository import GLib
|
|
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._device_path = "/dev/0"
|
|
listener._signal_id = None
|
|
listener._running = False
|
|
listener._on_success = None
|
|
listener._on_failure = None
|
|
|
|
listener._device_proxy.Claim.side_effect = GLib.Error(
|
|
"net.reactivated.Fprint.Error.AlreadyClaimed"
|
|
)
|
|
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
|
|
listener.start("testuser", on_success=on_success, on_failure=on_failure)
|
|
|
|
# Should NOT have connected signals or started verification
|
|
listener._device_proxy.connect.assert_not_called()
|
|
listener._device_proxy.VerifyStart.assert_not_called()
|
|
assert listener._running is False
|
|
|
|
def test_verify_start_glib_error_disconnects_and_releases(self):
|
|
"""When VerifyStart() raises GLib.Error, start() should clean up."""
|
|
from gi.repository import GLib
|
|
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._device_path = "/dev/0"
|
|
listener._signal_id = None
|
|
listener._running = False
|
|
listener._on_success = None
|
|
listener._on_failure = None
|
|
|
|
# Claim succeeds, signal connect returns an ID, VerifyStart fails
|
|
listener._device_proxy.connect.return_value = 99
|
|
listener._device_proxy.VerifyStart.side_effect = GLib.Error(
|
|
"net.reactivated.Fprint.Error.Internal"
|
|
)
|
|
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
|
|
listener.start("testuser", on_success=on_success, on_failure=on_failure)
|
|
|
|
# Should have disconnected the signal
|
|
listener._device_proxy.disconnect.assert_called_once_with(99)
|
|
# Should have released the device
|
|
listener._device_proxy.Release.assert_called_once()
|
|
assert listener._running is False
|
|
assert listener._signal_id is None
|
|
|
|
def test_start_sets_running_true_only_on_success(self):
|
|
"""_running should only be True after both Claim and VerifyStart succeed."""
|
|
listener = FingerprintListener.__new__(FingerprintListener)
|
|
listener._device_proxy = MagicMock()
|
|
listener._device_path = "/dev/0"
|
|
listener._signal_id = None
|
|
listener._running = False
|
|
listener._on_success = None
|
|
listener._on_failure = None
|
|
|
|
listener._device_proxy.connect.return_value = 42
|
|
|
|
on_success = MagicMock()
|
|
on_failure = MagicMock()
|
|
|
|
listener.start("testuser", on_success=on_success, on_failure=on_failure)
|
|
|
|
assert listener._running is True
|