Security: - Fix path traversal in _save/_load_last_session by rejecting usernames starting with dot (blocks '..' and hidden file creation) - Add avatar file size limit (10 MB) to prevent DoS via large ~/.face - Add session_name length validation on write (symmetric with read) - Add payload size check to send_message (symmetric with recv_message) - Set log level to INFO in production (was DEBUG) Quality: - Eliminate main-thread blocking on user switch: _cancel_pending_session now sets a cancellation event and closes the socket instead of doing blocking IPC. The login worker checks the event after each step. - Move power actions (reboot/shutdown) to background threads - Catch TimeoutExpired in addition to CalledProcessError for power actions - Consolidate socket cleanup in _login_worker via finally block, remove redundant _close_greetd_sock calls from error callbacks - Fix _select_initial_user to return False for GLib.idle_add deregistration - Fix context manager leak in resolve_wallpaper_path on exception - Pass Config object to GreeterWindow instead of loading it twice
479 lines
18 KiB
Python
479 lines
18 KiB
Python
# ABOUTME: Integration tests — verifies the login flow end-to-end via a mock greetd socket.
|
|
# ABOUTME: Tests the IPC sequence: create_session → post_auth → start_session.
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import struct
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS, LAST_SESSION_DIR
|
|
from moongreet.i18n import load_strings
|
|
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
|
|
|
|
|
|
class MockGreetd:
|
|
"""A mock greetd server that listens on a Unix socket and responds to IPC messages."""
|
|
|
|
def __init__(self, sock_path: Path) -> None:
|
|
self.sock_path = sock_path
|
|
self._responses: list[dict] = []
|
|
self._received: list[dict] = []
|
|
self._server: socket.socket | None = None
|
|
|
|
def expect(self, response: dict) -> None:
|
|
"""Queue a response to send for the next received message."""
|
|
self._responses.append(response)
|
|
|
|
@property
|
|
def received(self) -> list[dict]:
|
|
return self._received
|
|
|
|
def start(self) -> None:
|
|
"""Start the mock server in a background thread."""
|
|
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self._server.bind(str(self.sock_path))
|
|
self._server.listen(1)
|
|
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
self._thread.start()
|
|
|
|
@staticmethod
|
|
def _recvall(conn: socket.socket, n: int) -> bytes:
|
|
"""Receive exactly n bytes from a socket, handling fragmented reads."""
|
|
buf = bytearray()
|
|
while len(buf) < n:
|
|
chunk = conn.recv(n - len(buf))
|
|
if not chunk:
|
|
break
|
|
buf.extend(chunk)
|
|
return bytes(buf)
|
|
|
|
def _serve(self) -> None:
|
|
conn, _ = self._server.accept()
|
|
try:
|
|
for response in self._responses:
|
|
# Receive a message
|
|
header = self._recvall(conn, 4)
|
|
if len(header) < 4:
|
|
break
|
|
length = struct.unpack("=I", header)[0]
|
|
payload = self._recvall(conn, length)
|
|
msg = json.loads(payload.decode("utf-8"))
|
|
self._received.append(msg)
|
|
|
|
# Send response
|
|
resp_payload = json.dumps(response).encode("utf-8")
|
|
conn.sendall(struct.pack("=I", len(resp_payload)) + resp_payload)
|
|
finally:
|
|
conn.close()
|
|
|
|
def stop(self) -> None:
|
|
if self._server:
|
|
self._server.close()
|
|
|
|
|
|
class TestLoginFlow:
|
|
"""Integration tests for the complete login flow via mock greetd."""
|
|
|
|
def test_successful_login(self, tmp_path: Path) -> None:
|
|
"""Simulate a complete successful login: create → auth → start."""
|
|
sock_path = tmp_path / "greetd.sock"
|
|
mock = MockGreetd(sock_path)
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
|
|
mock.expect({"type": "success"})
|
|
mock.expect({"type": "success"})
|
|
mock.start()
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(str(sock_path))
|
|
|
|
# Step 1: Create session
|
|
response = create_session(sock, "dominik")
|
|
assert response["type"] == "auth_message"
|
|
|
|
# Step 2: Send password
|
|
response = post_auth_response(sock, "geheim")
|
|
assert response["type"] == "success"
|
|
|
|
# Step 3: Start session
|
|
response = start_session(sock, ["Hyprland"])
|
|
assert response["type"] == "success"
|
|
|
|
sock.close()
|
|
finally:
|
|
mock.stop()
|
|
|
|
# Verify what the mock received
|
|
assert mock.received[0] == {"type": "create_session", "username": "dominik"}
|
|
assert mock.received[1] == {"type": "post_auth_message_response", "response": "geheim"}
|
|
assert mock.received[2] == {"type": "start_session", "cmd": ["Hyprland"]}
|
|
|
|
def test_wrong_password_sends_cancel(self, tmp_path: Path) -> None:
|
|
"""After a failed login, cancel_session must be sent to free the greetd session."""
|
|
sock_path = tmp_path / "greetd.sock"
|
|
mock = MockGreetd(sock_path)
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
|
|
mock.expect({"type": "error", "error_type": "auth_error", "description": "Authentication failed"})
|
|
mock.expect({"type": "success"}) # Response to cancel_session
|
|
mock.start()
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(str(sock_path))
|
|
|
|
response = create_session(sock, "dominik")
|
|
assert response["type"] == "auth_message"
|
|
|
|
response = post_auth_response(sock, "falsch")
|
|
assert response["type"] == "error"
|
|
assert response["description"] == "Authentication failed"
|
|
|
|
# The greeter must cancel the session after auth failure
|
|
response = cancel_session(sock)
|
|
assert response["type"] == "success"
|
|
|
|
sock.close()
|
|
finally:
|
|
mock.stop()
|
|
|
|
assert mock.received[2] == {"type": "cancel_session"}
|
|
|
|
def test_stale_session_cancel_and_retry(self, tmp_path: Path) -> None:
|
|
"""When create_session fails due to a stale session, cancel and retry."""
|
|
sock_path = tmp_path / "greetd.sock"
|
|
mock = MockGreetd(sock_path)
|
|
# First create_session → error (stale session)
|
|
mock.expect({"type": "error", "error_type": "error", "description": "a session is already being configured"})
|
|
# cancel_session → success
|
|
mock.expect({"type": "success"})
|
|
# Second create_session → auth_message (retry succeeds)
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
|
|
# post_auth_response → success
|
|
mock.expect({"type": "success"})
|
|
# start_session → success
|
|
mock.expect({"type": "success"})
|
|
mock.start()
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(str(sock_path))
|
|
|
|
# Step 1: Create session fails
|
|
response = create_session(sock, "dominik")
|
|
assert response["type"] == "error"
|
|
|
|
# Step 2: Cancel stale session
|
|
response = cancel_session(sock)
|
|
assert response["type"] == "success"
|
|
|
|
# Step 3: Retry create session
|
|
response = create_session(sock, "dominik")
|
|
assert response["type"] == "auth_message"
|
|
|
|
# Step 4: Send password
|
|
response = post_auth_response(sock, "geheim")
|
|
assert response["type"] == "success"
|
|
|
|
# Step 5: Start session
|
|
response = start_session(sock, ["niri-session"])
|
|
assert response["type"] == "success"
|
|
|
|
sock.close()
|
|
finally:
|
|
mock.stop()
|
|
|
|
assert mock.received[0] == {"type": "create_session", "username": "dominik"}
|
|
assert mock.received[1] == {"type": "cancel_session"}
|
|
assert mock.received[2] == {"type": "create_session", "username": "dominik"}
|
|
|
|
def test_multi_stage_auth_sends_cancel(self, tmp_path: Path) -> None:
|
|
"""When greetd sends a second auth_message after password, cancel the session."""
|
|
sock_path = tmp_path / "greetd.sock"
|
|
mock = MockGreetd(sock_path)
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "TOTP:"})
|
|
mock.expect({"type": "success"}) # Response to cancel_session
|
|
mock.start()
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(str(sock_path))
|
|
|
|
# Step 1: Create session
|
|
response = create_session(sock, "dominik")
|
|
assert response["type"] == "auth_message"
|
|
|
|
# Step 2: Send password — greetd responds with another auth_message
|
|
response = post_auth_response(sock, "geheim")
|
|
assert response["type"] == "auth_message"
|
|
|
|
# Step 3: Cancel because multi-stage auth is not supported
|
|
response = cancel_session(sock)
|
|
assert response["type"] == "success"
|
|
|
|
sock.close()
|
|
finally:
|
|
mock.stop()
|
|
|
|
# Verify cancel was sent
|
|
assert mock.received[2] == {"type": "cancel_session"}
|
|
|
|
def test_cancel_session(self, tmp_path: Path) -> None:
|
|
"""Simulate cancelling a session after create."""
|
|
sock_path = tmp_path / "greetd.sock"
|
|
mock = MockGreetd(sock_path)
|
|
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
|
|
mock.expect({"type": "success"})
|
|
mock.start()
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(str(sock_path))
|
|
|
|
create_session(sock, "dominik")
|
|
response = cancel_session(sock)
|
|
assert response["type"] == "success"
|
|
|
|
sock.close()
|
|
finally:
|
|
mock.stop()
|
|
|
|
assert mock.received[1] == {"type": "cancel_session"}
|
|
|
|
|
|
class TestSessionCancellation:
|
|
"""Tests for cancelling an in-progress greetd session during user switch."""
|
|
|
|
def test_cancel_closes_socket_and_sets_event(self, tmp_path: Path) -> None:
|
|
"""_cancel_pending_session should close the socket and set the cancelled event."""
|
|
from moongreet.greeter import GreeterWindow
|
|
|
|
win = GreeterWindow.__new__(GreeterWindow)
|
|
win._greetd_sock_lock = threading.Lock()
|
|
win._login_cancelled = threading.Event()
|
|
|
|
# Create a real socket pair to verify close
|
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock_path = tmp_path / "test.sock"
|
|
server.bind(str(sock_path))
|
|
server.listen(1)
|
|
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
client.connect(str(sock_path))
|
|
server.close()
|
|
|
|
win._greetd_sock = client
|
|
win._cancel_pending_session()
|
|
|
|
assert win._login_cancelled.is_set()
|
|
assert win._greetd_sock is None
|
|
|
|
def test_cancel_is_noop_without_socket(self) -> None:
|
|
"""_cancel_pending_session should be safe to call when no socket exists."""
|
|
from moongreet.greeter import GreeterWindow
|
|
|
|
win = GreeterWindow.__new__(GreeterWindow)
|
|
win._greetd_sock_lock = threading.Lock()
|
|
win._login_cancelled = threading.Event()
|
|
win._greetd_sock = None
|
|
|
|
win._cancel_pending_session()
|
|
|
|
assert win._login_cancelled.is_set()
|
|
assert win._greetd_sock is None
|
|
|
|
def test_cancel_does_not_block_main_thread(self, tmp_path: Path) -> None:
|
|
"""_cancel_pending_session must not do blocking I/O — only close the socket."""
|
|
from moongreet.greeter import GreeterWindow
|
|
|
|
win = GreeterWindow.__new__(GreeterWindow)
|
|
win._greetd_sock_lock = threading.Lock()
|
|
win._login_cancelled = threading.Event()
|
|
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
win._greetd_sock = sock
|
|
|
|
# Should complete nearly instantly (no IPC calls)
|
|
import time
|
|
start = time.monotonic()
|
|
win._cancel_pending_session()
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 0.1 # No blocking I/O
|
|
|
|
def test_worker_exits_silently_when_cancelled(self, tmp_path: Path) -> None:
|
|
"""_login_worker should exit without showing an error when cancelled mid-flight."""
|
|
from unittest.mock import MagicMock, patch
|
|
from moongreet.greeter import GreeterWindow
|
|
from moongreet.users import User
|
|
|
|
win = GreeterWindow.__new__(GreeterWindow)
|
|
win._greetd_sock_lock = threading.Lock()
|
|
win._login_cancelled = threading.Event()
|
|
win._greetd_sock = None
|
|
win._failed_attempts = {}
|
|
win._strings = MagicMock()
|
|
|
|
# Set cancelled before the worker runs
|
|
win._login_cancelled.set()
|
|
|
|
# Create a socket that will fail (simulating closed socket)
|
|
sock_path = tmp_path / "greetd.sock"
|
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
server.bind(str(sock_path))
|
|
server.listen(1)
|
|
|
|
user = User(username="dom", uid=1000, gecos="Dominik", home=Path("/home/dom"), shell="/bin/zsh")
|
|
|
|
with patch("moongreet.greeter.GLib.idle_add") as mock_idle:
|
|
win._login_worker(user, "pw", MagicMock(exec_cmd="niri-session"), str(sock_path))
|
|
|
|
# Should NOT have scheduled any error callback
|
|
for call in mock_idle.call_args_list:
|
|
func = call[0][0]
|
|
assert func != win._on_login_error, "Worker should not show error when cancelled"
|
|
assert func != win._on_login_auth_error, "Worker should not show auth error when cancelled"
|
|
|
|
server.close()
|
|
|
|
|
|
class TestFaillockWarning:
|
|
"""Tests for the faillock warning message logic."""
|
|
|
|
def test_no_warning_on_zero_attempts(self) -> None:
|
|
strings = load_strings("de")
|
|
assert faillock_warning(0, strings) is None
|
|
|
|
def test_no_warning_on_first_attempt(self) -> None:
|
|
strings = load_strings("de")
|
|
assert faillock_warning(1, strings) is None
|
|
|
|
def test_warning_on_second_attempt(self) -> None:
|
|
strings = load_strings("de")
|
|
warning = faillock_warning(2, strings)
|
|
assert warning is not None
|
|
assert "1" in warning # 1 Versuch übrig
|
|
|
|
def test_warning_on_third_attempt(self) -> None:
|
|
strings = load_strings("de")
|
|
warning = faillock_warning(3, strings)
|
|
assert warning is not None
|
|
assert warning == strings.faillock_locked
|
|
|
|
def test_warning_beyond_max_attempts(self) -> None:
|
|
strings = load_strings("de")
|
|
warning = faillock_warning(4, strings)
|
|
assert warning is not None
|
|
assert warning == strings.faillock_locked
|
|
|
|
def test_max_attempts_constant_is_three(self) -> None:
|
|
assert FAILLOCK_MAX_ATTEMPTS == 3
|
|
|
|
|
|
class TestLastUser:
|
|
"""Tests for saving and loading the last logged-in user."""
|
|
|
|
def test_save_and_load_last_user(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
cache_path = tmp_path / "cache" / "moongreet" / "last-user"
|
|
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
GreeterWindow._save_last_user("dominik")
|
|
|
|
assert cache_path.exists()
|
|
assert cache_path.read_text() == "dominik"
|
|
|
|
result = GreeterWindow._load_last_user()
|
|
assert result == "dominik"
|
|
|
|
def test_load_last_user_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
cache_path = tmp_path / "nonexistent" / "last-user"
|
|
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
result = GreeterWindow._load_last_user()
|
|
assert result is None
|
|
|
|
def test_load_last_user_rejects_oversized_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
cache_path = tmp_path / "last-user"
|
|
cache_path.write_text("a" * 300)
|
|
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
result = GreeterWindow._load_last_user()
|
|
assert result is None
|
|
|
|
def test_load_last_user_rejects_invalid_characters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
cache_path = tmp_path / "last-user"
|
|
cache_path.write_text("../../etc/passwd")
|
|
monkeypatch.setattr("moongreet.greeter.LAST_USER_PATH", cache_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
result = GreeterWindow._load_last_user()
|
|
assert result is None
|
|
|
|
|
|
class TestLastSession:
|
|
"""Tests for saving and loading the last session per user."""
|
|
|
|
def test_save_and_load_last_session(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
GreeterWindow._save_last_session("dominik", "Niri")
|
|
|
|
session_file = tmp_path / "dominik"
|
|
assert session_file.exists()
|
|
assert session_file.read_text() == "Niri"
|
|
|
|
result = GreeterWindow._load_last_session("dominik")
|
|
assert result == "Niri"
|
|
|
|
def test_load_last_session_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
result = GreeterWindow._load_last_session("nobody")
|
|
assert result is None
|
|
|
|
def test_load_last_session_rejects_oversized_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
|
|
(tmp_path / "dominik").write_text("A" * 300)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
result = GreeterWindow._load_last_session("dominik")
|
|
assert result is None
|
|
|
|
def test_save_last_session_validates_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Usernames with path traversal should not create files outside the cache dir."""
|
|
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
|
|
|
|
from moongreet.greeter import GreeterWindow
|
|
GreeterWindow._save_last_session("../../etc/evil", "Niri")
|
|
|
|
# Should not have created any file
|
|
assert not (tmp_path / "../../etc/evil").exists()
|
|
|
|
def test_regex_rejects_dot_dot_username(self) -> None:
|
|
"""Username '..' must not pass VALID_USERNAME validation."""
|
|
from moongreet.greeter import VALID_USERNAME
|
|
assert VALID_USERNAME.match("..") is None
|
|
|
|
def test_regex_rejects_dot_username(self) -> None:
|
|
"""Username '.' must not pass VALID_USERNAME validation."""
|
|
from moongreet.greeter import VALID_USERNAME
|
|
assert VALID_USERNAME.match(".") is None
|
|
|
|
def test_regex_allows_dot_in_middle(self) -> None:
|
|
"""Usernames like 'first.last' must still be valid."""
|
|
from moongreet.greeter import VALID_USERNAME
|
|
assert VALID_USERNAME.match("first.last") is not None
|
|
|
|
def test_regex_rejects_leading_dot(self) -> None:
|
|
"""Usernames starting with '.' are rejected (hidden files)."""
|
|
from moongreet.greeter import VALID_USERNAME
|
|
assert VALID_USERNAME.match(".hidden") is None
|