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.
216 lines
7.2 KiB
Python
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
|