greetd-moongreet/tests/test_users.py
nevaforget cab1997dff fix: GTK-Theme-Validierung entfernt — GTK löst Theme-Namen selbst auf
Die Regex VALID_THEME_NAME blockierte Theme-Namen mit '+' (z.B.
catppuccin-mocha-lavender-standard+default). Da GTK den Theme-Namen
intern über Standardverzeichnisse auflöst, ist eigene Validierung
unnötig und kontraproduktiv.
2026-03-26 15:37:02 +01:00

216 lines
7.2 KiB
Python

# ABOUTME: Tests for user detection — parsing /etc/passwd, avatar lookup, GTK theme reading.
# ABOUTME: Uses temporary files and mocking to avoid system dependencies.
from pathlib import Path
from dataclasses import dataclass
import pytest
from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme
class TestGetUsers:
"""Tests for parsing /etc/passwd to find login users."""
def test_returns_users_in_uid_range(self, tmp_path: Path) -> None:
passwd = tmp_path / "passwd"
passwd.write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"nobody:x:65534:65534:Nobody:/:/usr/bin/nologin\n"
"dominik:x:1000:1000:Dominik:/home/dominik:/bin/zsh\n"
"testuser:x:1001:1001:Test User:/home/testuser:/bin/bash\n"
)
users = get_users(passwd_path=passwd)
assert len(users) == 2
assert users[0].username == "dominik"
assert users[0].uid == 1000
assert users[0].gecos == "Dominik"
assert users[0].home == Path("/home/dominik")
assert users[1].username == "testuser"
def test_excludes_nologin_shells(self, tmp_path: Path) -> None:
passwd = tmp_path / "passwd"
passwd.write_text(
"systemuser:x:1000:1000:System:/home/system:/usr/sbin/nologin\n"
"falseuser:x:1001:1001:False:/home/false:/bin/false\n"
"realuser:x:1002:1002:Real:/home/real:/bin/bash\n"
)
users = get_users(passwd_path=passwd)
assert len(users) == 1
assert users[0].username == "realuser"
def test_returns_empty_for_no_matching_users(self, tmp_path: Path) -> None:
passwd = tmp_path / "passwd"
passwd.write_text("root:x:0:0:root:/root:/bin/bash\n")
users = get_users(passwd_path=passwd)
assert users == []
def test_handles_missing_gecos_field(self, tmp_path: Path) -> None:
passwd = tmp_path / "passwd"
passwd.write_text("user:x:1000:1000::/home/user:/bin/bash\n")
users = get_users(passwd_path=passwd)
assert len(users) == 1
assert users[0].gecos == ""
assert users[0].display_name == "user"
def test_skips_invalid_uid(self, tmp_path: Path) -> None:
"""Corrupt /etc/passwd with non-numeric UID should not crash."""
passwd = tmp_path / "passwd"
passwd.write_text(
"corrupt:x:NOTANUMBER:1000:Corrupt:/home/corrupt:/bin/bash\n"
"valid:x:1000:1000:Valid:/home/valid:/bin/bash\n"
)
users = get_users(passwd_path=passwd)
assert len(users) == 1
assert users[0].username == "valid"
def test_skips_username_with_slash(self, tmp_path: Path) -> None:
"""Usernames containing path separators should be rejected."""
passwd = tmp_path / "passwd"
passwd.write_text(
"../evil:x:1000:1000:Evil:/home/evil:/bin/bash\n"
"normal:x:1001:1001:Normal:/home/normal:/bin/bash\n"
)
users = get_users(passwd_path=passwd)
assert len(users) == 1
assert users[0].username == "normal"
class TestGetAvatarPath:
"""Tests for avatar file lookup."""
def test_finds_accountsservice_icon(self, tmp_path: Path) -> None:
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
avatar = icons_dir / "dominik"
avatar.write_bytes(b"PNG")
result = get_avatar_path("dominik", accountsservice_dir=icons_dir)
assert result == avatar
def test_falls_back_to_dot_face(self, tmp_path: Path) -> None:
home = tmp_path / "home" / "dominik"
home.mkdir(parents=True)
face = home / ".face"
face.write_bytes(b"PNG")
empty_icons = tmp_path / "no_icons"
result = get_avatar_path(
"dominik", accountsservice_dir=empty_icons, home_dir=home
)
assert result == face
def test_ignores_symlinked_face(self, tmp_path: Path) -> None:
"""~/.face as symlink should be ignored to prevent traversal."""
home = tmp_path / "home" / "attacker"
home.mkdir(parents=True)
target = tmp_path / "secret.txt"
target.write_text("sensitive data")
face = home / ".face"
face.symlink_to(target)
empty_icons = tmp_path / "no_icons"
result = get_avatar_path(
"attacker", accountsservice_dir=empty_icons, home_dir=home
)
assert result is None
def test_returns_none_when_no_avatar(self, tmp_path: Path) -> None:
empty_icons = tmp_path / "no_icons"
home = tmp_path / "home" / "nobody"
result = get_avatar_path(
"nobody", accountsservice_dir=empty_icons, home_dir=home
)
assert result is None
class TestGetUserGtkTheme:
"""Tests for reading GTK theme from user's settings.ini."""
def test_reads_theme_from_settings(self, tmp_path: Path) -> None:
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text(
"[Settings]\n"
"gtk-theme-name=Adwaita-dark\n"
"gtk-icon-theme-name=Papirus\n"
)
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result == "Adwaita-dark"
def test_returns_none_when_no_settings(self, tmp_path: Path) -> None:
gtk_dir = tmp_path / "nonexistent"
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
def test_returns_none_when_no_theme_key(self, tmp_path: Path) -> None:
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text("[Settings]\ngtk-icon-theme-name=Papirus\n")
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
def test_returns_none_for_corrupt_settings_ini(self, tmp_path: Path) -> None:
"""settings.ini without section header should not crash."""
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text("gtk-theme-name=Adwaita-dark\n")
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
def test_passes_theme_with_special_characters(self, tmp_path: Path) -> None:
"""Theme names with special characters are passed through to GTK."""
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text(
"[Settings]\ngtk-theme-name=catppuccin-mocha-lavender-standard+default\n"
)
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result == "catppuccin-mocha-lavender-standard+default"
def test_ignores_symlinked_accountsservice_icon(self, tmp_path: Path) -> None:
"""AccountsService icon as symlink should be ignored to prevent traversal."""
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
target = tmp_path / "secret.txt"
target.write_text("sensitive data")
icon = icons_dir / "attacker"
icon.symlink_to(target)
result = get_avatar_path(
"attacker", accountsservice_dir=icons_dir
)
assert result is None