from fastapi.testclient import TestClient from typing import Dict, List, Optional import app.container as container from app.core.audit import AuditLogger from app.core.config import Settings from app.core.errors import ApiError from app.factory import create_app from app.core.models import GroupSummary, UserSummary from app.providers.base import SystemProvider from app.services.user_group_service import UserGroupService from app.state import AppState class MockProvider(SystemProvider): def __init__(self): self.users: Dict[str, UserSummary] = {} self.groups: Dict[str, GroupSummary] = {} self.user_group_map: Dict[str, List[str]] = {} def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None: if username in self.users: raise ApiError(409, "resource_conflict", "user exists") self.users[username] = UserSummary(username=username, uid=1000, gid=1000, home_dir=home_dir or f"/home/{username}", shell=shell) self.user_group_map[username] = groups def delete_user(self, username: str) -> None: if username not in self.users: raise ApiError(404, "not_found", "user not found") del self.users[username] self.user_group_map.pop(username, None) def change_user_password(self, username: str, password_hash: str) -> None: if username not in self.users: raise ApiError(404, "not_found", "user not found") def list_users(self) -> List[UserSummary]: return list(self.users.values()) def get_user(self, username: str) -> UserSummary: if username not in self.users: raise ApiError(404, "not_found", "user not found") return self.users[username] def create_group(self, groupname: str) -> None: if groupname in self.groups: raise ApiError(409, "resource_conflict", "group exists") self.groups[groupname] = GroupSummary(groupname=groupname, gid=1000, members=[]) def delete_group(self, groupname: str) -> None: if groupname not in self.groups: raise ApiError(404, "not_found", "group not found") del self.groups[groupname] def list_groups(self) -> List[GroupSummary]: return list(self.groups.values()) def get_group(self, groupname: str) -> GroupSummary: if groupname not in self.groups: raise ApiError(404, "not_found", "group not found") return self.groups[groupname] def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: if username not in self.users: raise ApiError(404, "not_found", "user not found") if replace: self.user_group_map[username] = groups else: self.user_group_map[username] = list(dict.fromkeys(self.user_group_map.get(username, []) + groups)) def remove_user_groups(self, username: str, groups: List[str]) -> None: if username not in self.users: raise ApiError(404, "not_found", "user not found") self.user_group_map[username] = [item for item in self.user_group_map.get(username, []) if item not in set(groups)] def get_user_groups(self, username: str) -> List[str]: if username not in self.users: raise ApiError(404, "not_found", "user not found") return self.user_group_map.get(username, []) def build_client( link_home_dir: Optional[str] = None, hidden_users: Optional[List[str]] = None, hidden_groups: Optional[List[str]] = None, whitelist_users: Optional[List[str]] = None, whitelist_groups: Optional[List[str]] = None, locked_users: Optional[List[str]] = None, user_uid_min: Optional[int] = None, user_uid_max: Optional[int] = None, group_gid_min: Optional[int] = None, group_gid_max: Optional[int] = None, ) -> TestClient: settings = Settings(TOKEN="test-token") service = UserGroupService( provider=MockProvider(), home_base_dir="/home", link_home_dir=link_home_dir, hidden_users=hidden_users, hidden_groups=hidden_groups, whitelist_users=whitelist_users, whitelist_groups=whitelist_groups, locked_users=locked_users, user_uid_min=user_uid_min, user_uid_max=user_uid_max, group_gid_min=group_gid_min, group_gid_max=group_gid_max, ) audit = AuditLogger("./logs/test_audit.log") container.app_state = AppState(settings=settings, service=service, audit=audit) return TestClient(create_app()) def test_user_group_happy_path() -> None: client = build_client() headers = {"Authorization": "Bearer test-token"} response = client.post("/groups", json={"groupname": "dev"}, headers=headers) assert response.status_code == 200 response = client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": ["dev"]}, headers=headers) assert response.status_code == 200 response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers) assert response.status_code == 200 response = client.get("/users/alice/groups", headers=headers) assert response.status_code == 200 assert "dev" in response.json()["groups"] def test_health_returns_server_name_and_online_status() -> None: client = build_client() headers = {"Authorization": "Bearer test-token"} response = client.get("/health", headers=headers) assert response.status_code == 200 assert response.json() == {"server_name": "user-manage-api", "status": "online"} def test_change_user_password() -> None: client = build_client() headers = {"Authorization": "Bearer test-token"} client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers) assert response.status_code == 200 assert response.json()["message"] == "User password updated." def test_locked_user_can_be_read_but_not_modified() -> None: client = build_client(locked_users=["alice"]) headers = {"Authorization": "Bearer test-token"} provider = container.app_state.service.provider provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash") response = client.get("/users/alice", headers=headers) assert response.status_code == 200 response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers) assert response.status_code == 423 response = client.delete("/users/alice", headers=headers) assert response.status_code == 423 response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers) assert response.status_code == 423 def test_conflict_and_not_found_codes() -> None: client = build_client() headers = {"Authorization": "Bearer test-token"} payload = {"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []} response = client.post("/users", json=payload, headers=headers) assert response.status_code == 200 response = client.post("/users", json=payload, headers=headers) assert response.status_code == 409 response = client.delete("/users/bob", headers=headers) assert response.status_code == 404 def test_hidden_users_and_groups_are_filtered_and_blocked() -> None: client = build_client(hidden_users=["root"], hidden_groups=["sudo"]) headers = {"Authorization": "Bearer test-token"} client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) client.post("/groups", json={"groupname": "dev"}, headers=headers) provider = container.app_state.service.provider provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash") provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"]) provider.user_group_map["alice"] = ["dev", "sudo"] response = client.get("/users", headers=headers) assert response.status_code == 200 assert [user["username"] for user in response.json()] == ["alice"] response = client.get("/groups", headers=headers) assert response.status_code == 200 assert [group["groupname"] for group in response.json()] == ["dev"] response = client.get("/users/root", headers=headers) assert response.status_code == 404 response = client.delete("/groups/sudo", headers=headers) assert response.status_code == 404 response = client.get("/users/alice/groups", headers=headers) assert response.status_code == 200 assert response.json()["groups"] == ["dev"] def test_whitelist_overrides_hidden_and_id_ranges() -> None: client = build_client( hidden_users=["root"], hidden_groups=["sudo"], whitelist_users=["root"], whitelist_groups=["sudo"], user_uid_min=1000, user_uid_max=2000, group_gid_min=1000, group_gid_max=2000, ) headers = {"Authorization": "Bearer test-token"} provider = container.app_state.service.provider provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash") provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash") provider.users["system"] = UserSummary(username="system", uid=500, gid=500, home_dir="/home/system", shell="/bin/bash") provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"]) provider.groups["dev"] = GroupSummary(groupname="dev", gid=1000, members=[]) provider.groups["system"] = GroupSummary(groupname="system", gid=500, members=[]) response = client.get("/users", headers=headers) assert response.status_code == 200 assert [user["username"] for user in response.json()] == ["root", "alice"] response = client.get("/groups", headers=headers) assert response.status_code == 200 assert [group["groupname"] for group in response.json()] == ["sudo", "dev"] response = client.get("/users/root", headers=headers) assert response.status_code == 200 response = client.get("/users/system", headers=headers) assert response.status_code == 404