import unittest import inspect from unittest.mock import AsyncMock, patch from fastapi import HTTPException from pydantic import ValidationError from app.api import rbac from app.api import data_platform from app.contracts import UserCreate, UserUpdate class UserContractTests(unittest.TestCase): def test_only_two_data_permissions_are_accepted(self) -> None: for permission in ("data_manage", "data_view"): body = UserCreate( username="user@example.com", password="Secure-Pass-2026", permission=permission, ) self.assertEqual(body.permission, permission) with self.assertRaises(ValidationError): UserCreate( username="user@example.com", password="Secure-Pass-2026", permission="collector", ) def test_data_view_is_read_only_at_the_api_boundary(self) -> None: for function_name in ("records", "export_records"): parameter = inspect.signature( getattr(data_platform, function_name) ).parameters["_user"] self.assertEqual(parameter.annotation, "DataViewerUser") for function_name in ( "preview_records_import", "import_records", "add_record", "edit_record", "remove_record", ): parameter = inspect.signature( getattr(data_platform, function_name) ).parameters["user"] self.assertEqual(parameter.annotation, "AdminUser") class UserApiTests(unittest.IsolatedAsyncioTestCase): async def test_create_maps_public_permission_to_one_internal_role(self) -> None: create_mock = AsyncMock( return_value={ "id": 8, "username": "viewer@example.com", "full_name": "Viewer", "status": "active", "roles": ["operator"], } ) body = UserCreate( username=" viewer@example.com ", password="Secure-Pass-2026", full_name="Viewer", permission="data_view", ) with ( patch.object(rbac, "create_user", create_mock), patch.object(rbac, "hash_password", return_value="hashed"), ): result = await rbac._create_user( body, {"username": "admin@example.com", "roles": ["admin"]}, ) create_mock.assert_awaited_once() data, roles = create_mock.await_args.args self.assertEqual(data["username"], "viewer@example.com") self.assertEqual(roles, ["operator"]) self.assertEqual(result["permission"], "data_view") self.assertNotIn("roles", result) async def test_list_hides_internal_roles_behind_two_permissions(self) -> None: rows = [ {"id": 1, "username": "manager", "status": "active", "roles": ["admin"]}, {"id": 2, "username": "legacy", "status": "active", "roles": ["collector"]}, ] with patch.object(rbac, "list_users", AsyncMock(return_value=rows)): result = await rbac._list_users( {"username": "manager", "roles": ["admin"]} ) self.assertEqual( [item["permission"] for item in result], ["data_manage", "data_view"], ) self.assertTrue(all("roles" not in item for item in result)) async def test_current_manager_cannot_disable_or_demote_itself(self) -> None: target = { "id": 1, "username": "manager@example.com", "status": "active", "roles": ["admin"], } current = {"username": "manager@example.com", "roles": ["admin"]} update_mock = AsyncMock() for body in ( UserUpdate(status="disabled"), UserUpdate(permission="data_view"), ): with ( patch.object(rbac, "get_user_by_id", AsyncMock(return_value=target)), patch.object(rbac, "update_user", update_mock), ): with self.assertRaises(HTTPException) as raised: await rbac._update_user(1, body, current) self.assertEqual(raised.exception.status_code, 400) update_mock.assert_not_awaited() if __name__ == "__main__": unittest.main()