"""Tests for RBAC: role enforcement, leader scope, and admin domains.""" import pytest @pytest.mark.asyncio async def test_employee_cannot_access_admin_apis(client, employee_user): """Employee cannot call admin-only APIs (403).""" _, token = employee_user # Try to list users (admin only) resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 assert resp.json()["errors"][0]["code"] == "FORBIDDEN" # Try to list roles (admin only) resp = await client.get( "/api/v1/roles", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_admin_can_list_users(client, admin_user): """Global admin can list users.""" _, token = admin_user resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_admin_can_list_roles(client, admin_user): """Global admin can list RBAC roles.""" _, token = admin_user resp = await client.get( "/api/v1/roles", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True role_names = [r["name"] for r in data["data"]] assert "global_admin" in role_names assert "employee" in role_names assert "auditor" in role_names assert "asset_admin" in role_names # ``cloud_admin`` REVIVED 2026-04-29 (migration 080) with a new # semantic: not "cloud-only IT admin" (that was the deprecated # 040 meaning) but "perpetual cloud-grant marker" — a holder's # cmdb_cloud_access_grants are issued with ``expires_at = NULL`` # and the expiry sweeper skips them. cloud_admin alone does NOT # grant any extra read/write authority. See rbac.py docstring. assert "cloud_admin" in role_names @pytest.mark.asyncio async def test_employee_cannot_access_hr_admin_apis(client, employee_user): """Employee cannot access HR admin APIs.""" _, token = employee_user resp = await client.get( "/api/v1/hr/offboarding-cases", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_employee_cannot_access_audit(client, employee_user): """Employee cannot access audit logs.""" _, token = employee_user resp = await client.get( "/api/v1/audit/logs", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_manager_auto_derived(client, manager_and_subordinate): """Manager status is auto-derived from org structure.""" manager, subordinate, token = manager_and_subordinate # Manager's /auth/me should show is_manager = true resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert data["is_manager"] is True @pytest.mark.asyncio async def test_admin_can_assign_role(client, admin_user, employee_user): """Global admin can assign roles to users. Uses ``hr`` (not ``asset_admin``) because the latter is fully auto-derived from ``asset_office_admins`` since migration 220 and the API rejects manual grants with ``ROLE_AUTO_DERIVED``. """ _, admin_token = admin_user emp, _ = employee_user resp = await client.post( f"/api/v1/users/{emp.id}/roles", headers={"Authorization": f"Bearer {admin_token}"}, json={"role_name": "hr"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_admin_can_remove_role_by_name(client, admin_user, employee_user): """Global admin can remove a role from a user by role name. Uses ``hr`` (not ``asset_admin``) for the same reason as ``test_admin_can_assign_role`` — asset_admin is auto-derived and the API rejects manual mutations. """ _, admin_token = admin_user emp, _ = employee_user # First assign hr resp = await client.post( f"/api/v1/users/{emp.id}/roles", headers={"Authorization": f"Bearer {admin_token}"}, json={"role_name": "hr"}, ) assert resp.status_code == 200 # Then remove it by name resp = await client.delete( f"/api/v1/users/{emp.id}/roles/by-name/hr", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_admin_cannot_assign_duplicate_role(client, admin_user, employee_user): """Assigning the same role twice should fail.""" admin, admin_token = admin_user emp, _ = employee_user # employee already has 'employee' role resp = await client.post( f"/api/v1/users/{emp.id}/roles", headers={"Authorization": f"Bearer {admin_token}"}, json={"role_name": "employee"}, ) assert resp.status_code == 400 assert resp.json()["errors"][0]["code"] == "ROLE_ASSIGN_FAILED" @pytest.mark.asyncio async def test_admin_can_get_user_roles(client, admin_user, employee_user): """Global admin can list the roles assigned to a specific user.""" _, admin_token = admin_user emp, _ = employee_user resp = await client.get( f"/api/v1/users/{emp.id}/roles", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["success"] is True role_names = [r["name"] for r in data["data"]] assert "employee" in role_names @pytest.mark.asyncio async def test_employee_cannot_assign_roles(client, employee_user, admin_user): """Employee cannot assign roles (admin-only operation).""" _, emp_token = employee_user admin, _ = admin_user resp = await client.post( f"/api/v1/users/{admin.id}/roles", headers={"Authorization": f"Bearer {emp_token}"}, json={"role_name": "global_admin"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_employee_cannot_remove_roles(client, employee_user, admin_user): """Employee cannot remove roles (admin-only operation).""" _, emp_token = employee_user admin, _ = admin_user resp = await client.delete( f"/api/v1/users/{admin.id}/roles/by-name/global_admin", headers={"Authorization": f"Bearer {emp_token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_auditor_can_access_audit_logs(client, auditor_user): """Auditor can access audit logs.""" _, token = auditor_user resp = await client.get( "/api/v1/audit/logs", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_auditor_can_access_audit_stats(client, auditor_user): """Auditor can access audit stats.""" _, token = auditor_user resp = await client.get( "/api/v1/audit/stats", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_auditor_can_read_admin_user_management(client, auditor_user): """Auditor can READ the admin user list. Per the /help permission matrix row 1 (用户 / 组织 / RBAC / 关系 图谱) auditor = 读. The endpoint moved from ``require_hr_admin`` to ``require_hr_read`` (which includes auditor + management) as part of the 2026-05-20 management-role read-coverage rollout. Mutating user endpoints (PATCH /users/{id}, role assign / remove, PII reveal) stay on the write gates and 403 for auditor — those are covered by the other tests in this module. """ _, token = auditor_user resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_auditor_cannot_create_assets(client, auditor_user): """Auditor cannot create assets (read-only access).""" _, token = auditor_user resp = await client.post( "/api/v1/assets", headers={"Authorization": f"Bearer {token}"}, json={ "asset_tag": "TEST-001", "category": "laptop", "brand": "Test", "model": "Test", "serial_number": "SN001", }, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_employee_me_returns_roles(client, employee_user): """Employee's /auth/me returns correct roles array.""" _, token = employee_user resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert "employee" in data["roles"] assert data["is_manager"] is False @pytest.mark.asyncio async def test_admin_me_returns_roles(client, admin_user): """Global admin /auth/me returns correct roles array.""" _, token = admin_user resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert "global_admin" in data["roles"] @pytest.mark.asyncio async def test_unauthenticated_request_returns_401(client): """Requests without token get 401.""" resp = await client.get("/api/v1/auth/me") assert resp.status_code == 401 @pytest.mark.asyncio async def test_invalid_token_returns_401(client): """Requests with invalid token get 401.""" resp = await client.get( "/api/v1/auth/me", headers={"Authorization": "Bearer invalid.token.here"}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_manager_can_see_own_me(client, manager_and_subordinate): """Leader /auth/me returns is_manager=true and has base roles only.""" _, _, token = manager_and_subordinate resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert data["is_manager"] is True assert "employee" in data["roles"] # Leader is NOT a DB role assert "leader" not in data["roles"] @pytest.mark.asyncio async def test_asset_admin_can_create_assets(client, asset_admin_user): """Asset admin can manage asset-domain write APIs. From migration 220 onwards asset_admin is region-scoped — the ``asset_admin_user`` fixture provisions a TestCity office with the user as primary admin, so passing ``location="TestCity"`` satisfies ``_enforce_intake_region``. """ _, token = asset_admin_user resp = await client.post( "/api/v1/assets", headers={"Authorization": f"Bearer {token}"}, json={ "asset_tag": "TEST-001", "category": "laptop", "brand": "Test", "model": "Test", "serial_number": "SN001", "location": "TestCity", }, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_legacy_cloud_admin_token_without_db_role_is_not_admin( client, legacy_cloud_admin_token_user, ): """A token carrying ``cloud_admin`` in its role claims does NOT by itself grant admin authority over assets. Two reasons: * Authorization pulls ``role_names`` from the DB at request time, not from the JWT claims — a forged/stale token can't elevate. * After migration 080 (2026-04-29) ``cloud_admin`` is back as a role, but it's a perpetual-grant marker for cmdb_cloud_access_grants and explicitly does NOT confer asset-admin authority (``can_manage_assets`` excludes it). This test fixture creates a user with ONLY the ``employee`` DB role and a token that claims cloud_admin — the correct outcome is 403. """ _, token = legacy_cloud_admin_token_user resp = await client.post( "/api/v1/assets", headers={"Authorization": f"Bearer {token}"}, json={ "asset_tag": "TEST-002", "category": "laptop", "brand": "Test", "model": "Test", "serial_number": "SN002", }, ) # 403 enforces the "DB is authoritative for role" invariant. assert resp.status_code == 403 @pytest.mark.asyncio async def test_non_manager_cannot_view_team_assets(client, employee_user): """Non-manager employees cannot access team assets endpoint.""" _, token = employee_user resp = await client.get( "/api/v1/assets/team", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_manager_can_view_team_assets(client, manager_and_subordinate): """Manager can access team assets endpoint.""" _, _, token = manager_and_subordinate resp = await client.get( "/api/v1/assets/team", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True @pytest.mark.asyncio async def test_employee_can_view_own_assets(client, employee_user): """Employee can access their own assets endpoint.""" _, token = employee_user resp = await client.get( "/api/v1/assets/my", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True