Initial project setup with core modules
Moonlock lockscreen scaffolding: PAM auth (ctypes), fprintd D-Bus listener, i18n (DE/EN), user detection, power actions. 33 tests passing.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# ABOUTME: Tests for PAM authentication via ctypes wrapper.
|
||||
# ABOUTME: Verifies authenticate() calls libpam correctly and handles success/failure.
|
||||
|
||||
from unittest.mock import patch, MagicMock, ANY
|
||||
import ctypes
|
||||
|
||||
from moonlock.auth import authenticate, PAM_SUCCESS, PAM_AUTH_ERR
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""Tests for PAM authentication."""
|
||||
|
||||
@patch("moonlock.auth._get_libpam")
|
||||
def test_returns_true_on_successful_auth(self, mock_get_libpam):
|
||||
libpam = MagicMock()
|
||||
mock_get_libpam.return_value = libpam
|
||||
libpam.pam_start.return_value = PAM_SUCCESS
|
||||
libpam.pam_authenticate.return_value = PAM_SUCCESS
|
||||
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
|
||||
libpam.pam_end.return_value = PAM_SUCCESS
|
||||
|
||||
assert authenticate("testuser", "correctpassword") is True
|
||||
|
||||
@patch("moonlock.auth._get_libpam")
|
||||
def test_returns_false_on_wrong_password(self, mock_get_libpam):
|
||||
libpam = MagicMock()
|
||||
mock_get_libpam.return_value = libpam
|
||||
libpam.pam_start.return_value = PAM_SUCCESS
|
||||
libpam.pam_authenticate.return_value = PAM_AUTH_ERR
|
||||
libpam.pam_end.return_value = PAM_SUCCESS
|
||||
|
||||
assert authenticate("testuser", "wrongpassword") is False
|
||||
|
||||
@patch("moonlock.auth._get_libpam")
|
||||
def test_pam_end_always_called(self, mock_get_libpam):
|
||||
libpam = MagicMock()
|
||||
mock_get_libpam.return_value = libpam
|
||||
libpam.pam_start.return_value = PAM_SUCCESS
|
||||
libpam.pam_authenticate.return_value = PAM_AUTH_ERR
|
||||
libpam.pam_end.return_value = PAM_SUCCESS
|
||||
|
||||
authenticate("testuser", "wrongpassword")
|
||||
libpam.pam_end.assert_called_once()
|
||||
|
||||
@patch("moonlock.auth._get_libpam")
|
||||
def test_returns_false_when_pam_start_fails(self, mock_get_libpam):
|
||||
libpam = MagicMock()
|
||||
mock_get_libpam.return_value = libpam
|
||||
libpam.pam_start.return_value = PAM_AUTH_ERR
|
||||
|
||||
assert authenticate("testuser", "password") is False
|
||||
|
||||
@patch("moonlock.auth._get_libpam")
|
||||
def test_uses_moonlock_as_service_name(self, mock_get_libpam):
|
||||
libpam = MagicMock()
|
||||
mock_get_libpam.return_value = libpam
|
||||
libpam.pam_start.return_value = PAM_SUCCESS
|
||||
libpam.pam_authenticate.return_value = PAM_SUCCESS
|
||||
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
|
||||
libpam.pam_end.return_value = PAM_SUCCESS
|
||||
|
||||
authenticate("testuser", "password")
|
||||
args = libpam.pam_start.call_args
|
||||
# First positional arg should be the service name
|
||||
assert args[0][0] == b"moonlock"
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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._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
|
||||
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(self):
|
||||
listener = FingerprintListener.__new__(FingerprintListener)
|
||||
listener._device_proxy = MagicMock()
|
||||
listener._running = True
|
||||
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 restart verification for retry
|
||||
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
|
||||
|
||||
def test_verify_swipe_too_short_retries_without_failure(self):
|
||||
listener = FingerprintListener.__new__(FingerprintListener)
|
||||
listener._device_proxy = MagicMock()
|
||||
listener._running = True
|
||||
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")
|
||||
@@ -0,0 +1,67 @@
|
||||
# ABOUTME: Tests for locale detection and string lookup.
|
||||
# ABOUTME: Verifies correct language detection from env vars and /etc/locale.conf.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonlock.i18n import Strings, detect_locale, load_strings
|
||||
|
||||
|
||||
class TestDetectLocale:
|
||||
"""Tests for locale detection."""
|
||||
|
||||
@patch.dict(os.environ, {"LANG": "de_DE.UTF-8"})
|
||||
def test_detects_german_from_env(self):
|
||||
assert detect_locale() == "de"
|
||||
|
||||
@patch.dict(os.environ, {"LANG": "en_US.UTF-8"})
|
||||
def test_detects_english_from_env(self):
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict(os.environ, {"LANG": ""}, clear=False)
|
||||
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path):
|
||||
locale_conf = tmp_path / "locale.conf"
|
||||
locale_conf.write_text("LANG=de_DE.UTF-8\n")
|
||||
assert detect_locale(locale_conf_path=locale_conf) == "de"
|
||||
|
||||
@patch.dict(os.environ, {"LANG": "C"})
|
||||
def test_c_locale_defaults_to_english(self):
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict(os.environ, {"LANG": "POSIX"})
|
||||
def test_posix_locale_defaults_to_english(self):
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_missing_env_and_no_file_defaults_to_english(self, tmp_path: Path):
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
assert detect_locale(locale_conf_path=nonexistent) == "en"
|
||||
|
||||
|
||||
class TestLoadStrings:
|
||||
"""Tests for string table loading."""
|
||||
|
||||
def test_german_strings(self):
|
||||
strings = load_strings("de")
|
||||
assert isinstance(strings, Strings)
|
||||
assert strings.password_placeholder == "Passwort"
|
||||
|
||||
def test_english_strings(self):
|
||||
strings = load_strings("en")
|
||||
assert strings.password_placeholder == "Password"
|
||||
|
||||
def test_unknown_locale_falls_back_to_english(self):
|
||||
strings = load_strings("fr")
|
||||
assert strings.password_placeholder == "Password"
|
||||
|
||||
def test_lockscreen_specific_strings_exist(self):
|
||||
strings = load_strings("de")
|
||||
assert strings.unlock_button is not None
|
||||
assert strings.fingerprint_prompt is not None
|
||||
assert strings.fingerprint_success is not None
|
||||
|
||||
def test_faillock_template_strings(self):
|
||||
strings = load_strings("de")
|
||||
msg = strings.faillock_attempts_remaining.format(n=2)
|
||||
assert "2" in msg
|
||||
@@ -0,0 +1,24 @@
|
||||
# ABOUTME: Tests for power actions (reboot, shutdown).
|
||||
# ABOUTME: Verifies loginctl commands are called correctly.
|
||||
|
||||
from unittest.mock import patch, call
|
||||
|
||||
from moonlock.power import reboot, shutdown
|
||||
|
||||
|
||||
class TestReboot:
|
||||
"""Tests for the reboot function."""
|
||||
|
||||
@patch("moonlock.power.subprocess.run")
|
||||
def test_reboot_calls_loginctl(self, mock_run):
|
||||
reboot()
|
||||
mock_run.assert_called_once_with(["loginctl", "reboot"], check=True)
|
||||
|
||||
|
||||
class TestShutdown:
|
||||
"""Tests for the shutdown function."""
|
||||
|
||||
@patch("moonlock.power.subprocess.run")
|
||||
def test_shutdown_calls_loginctl(self, mock_run):
|
||||
shutdown()
|
||||
mock_run.assert_called_once_with(["loginctl", "poweroff"], check=True)
|
||||
@@ -0,0 +1,81 @@
|
||||
# ABOUTME: Tests for current user detection and avatar loading.
|
||||
# ABOUTME: Verifies user info retrieval from the system.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonlock.users import get_current_user, get_avatar_path, User
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
"""Tests for current user detection."""
|
||||
|
||||
@patch("moonlock.users.os.getlogin", return_value="testuser")
|
||||
@patch("moonlock.users.pwd.getpwnam")
|
||||
def test_returns_user_with_correct_username(self, mock_pwd, mock_login):
|
||||
mock_pwd.return_value.pw_name = "testuser"
|
||||
mock_pwd.return_value.pw_gecos = "Test User"
|
||||
mock_pwd.return_value.pw_dir = "/home/testuser"
|
||||
mock_pwd.return_value.pw_uid = 1000
|
||||
user = get_current_user()
|
||||
assert user.username == "testuser"
|
||||
assert user.display_name == "Test User"
|
||||
assert user.home == Path("/home/testuser")
|
||||
|
||||
@patch("moonlock.users.os.getlogin", return_value="testuser")
|
||||
@patch("moonlock.users.pwd.getpwnam")
|
||||
def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_login):
|
||||
mock_pwd.return_value.pw_name = "testuser"
|
||||
mock_pwd.return_value.pw_gecos = ""
|
||||
mock_pwd.return_value.pw_dir = "/home/testuser"
|
||||
mock_pwd.return_value.pw_uid = 1000
|
||||
user = get_current_user()
|
||||
assert user.display_name == "testuser"
|
||||
|
||||
@patch("moonlock.users.os.getlogin", return_value="testuser")
|
||||
@patch("moonlock.users.pwd.getpwnam")
|
||||
def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_login):
|
||||
mock_pwd.return_value.pw_name = "testuser"
|
||||
mock_pwd.return_value.pw_gecos = "Test User,,,Room 42"
|
||||
mock_pwd.return_value.pw_dir = "/home/testuser"
|
||||
mock_pwd.return_value.pw_uid = 1000
|
||||
user = get_current_user()
|
||||
assert user.display_name == "Test User"
|
||||
|
||||
|
||||
class TestGetAvatarPath:
|
||||
"""Tests for avatar path resolution."""
|
||||
|
||||
def test_returns_face_file_if_exists(self, tmp_path: Path):
|
||||
face = tmp_path / ".face"
|
||||
face.write_text("fake image")
|
||||
path = get_avatar_path(tmp_path)
|
||||
assert path == face
|
||||
|
||||
def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path):
|
||||
username = "testuser"
|
||||
icons_dir = tmp_path / "icons"
|
||||
icons_dir.mkdir()
|
||||
icon = icons_dir / username
|
||||
icon.write_text("fake image")
|
||||
path = get_avatar_path(
|
||||
tmp_path, username=username, accountsservice_dir=icons_dir
|
||||
)
|
||||
assert path == icon
|
||||
|
||||
def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path):
|
||||
face = tmp_path / ".face"
|
||||
face.write_text("fake image")
|
||||
icons_dir = tmp_path / "icons"
|
||||
icons_dir.mkdir()
|
||||
icon = icons_dir / "testuser"
|
||||
icon.write_text("fake image")
|
||||
path = get_avatar_path(
|
||||
tmp_path, username="testuser", accountsservice_dir=icons_dir
|
||||
)
|
||||
assert path == face
|
||||
|
||||
def test_returns_none_when_no_avatar(self, tmp_path: Path):
|
||||
path = get_avatar_path(tmp_path)
|
||||
assert path is None
|
||||
Reference in New Issue
Block a user