# 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