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:
2026-03-26 12:28:17 +01:00
commit d1c0b741fa
18 changed files with 932 additions and 0 deletions
View File
+65
View File
@@ -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"
+121
View File
@@ -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")
+67
View File
@@ -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
+24
View File
@@ -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)
+81
View File
@@ -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