"""RBAC tests for the people-picker / department endpoints. Background: PR 2026-06-01 — regional asset admins (and finance / cloud_admin holders) reported that the admin-view filter dropdowns were silently empty. Backend was returning 403 on ``/users`` and ``/auth/departments``; the frontend ``UserSelect`` swallowed the error via ``catch { /* silent */ }`` and the user just saw an empty picker. The fix split the surface in two: * ``GET /api/v1/directory/people`` — minimal-field picker, gated to every admin-tier role (global_admin / hr / asset_admin / finance / cloud_admin / auditor / management). * ``GET /api/v1/auth/departments`` — gate widened from ``require_hr_read`` to ``require_directory_read`` (same admin tier — department names are public org structure, no PII). * ``GET /api/v1/users`` — UNCHANGED, still ``require_hr_read``; HR-only governance surface with manager hierarchy + last_login_at + hris_record_state + roles[]. These tests pin the positive paths (every admin-tier role can call the picker endpoints) AND the negative path (employee still 403s on both the picker and the HR-only ``/users``). Without these, a future RBAC refactor that accidentally re-tightens ``require_directory_read`` or accidentally widens ``require_hr_read`` would re-introduce the silent-empty-dropdown bug. """ from __future__ import annotations import uuid import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession # ── Helpers ───────────────────────────────────────────────────────── async def _seed_test_people(db_session: AsyncSession, count: int = 3) -> list: """Create a handful of active people so the picker has rows to return. Without them, every test would assert against an empty list and we couldn't distinguish "endpoint works but empty table" from "endpoint silently failed". """ from app.auth.models import AuthUserRole from app.identity.person_models import IdentityPerson employee_role = await _role_id(db_session, "employee") people = [] for i in range(count): pid = uuid.uuid4() person = IdentityPerson( id=pid, work_email=f"picker-target-{i}@test.com", display_name=f"Picker Target {i}", employee_no=f"PT{i:03d}", employment_status="active", source_system="successfactors", hris_person_id=f"sf-picker-{i}", ) db_session.add(person) await db_session.flush() db_session.add(AuthUserRole(user_id=pid, role_id=employee_role)) people.append(person) await db_session.flush() await db_session.commit() return people async def _role_id(db_session: AsyncSession, name: str) -> uuid.UUID: from sqlalchemy import text row = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = :n").bindparams(n=name) ) rid = row.scalar_one() return uuid.UUID(rid) if isinstance(rid, str) else rid # ── /directory/people — positive paths ────────────────────────────── @pytest.mark.asyncio async def test_directory_people_asset_admin_can_list( client: AsyncClient, db_session: AsyncSession, asset_admin_user ): """The original bug: asset_admin's UserSelect dropdown was empty because /users 403'd. /directory/people must return 200 with the seeded rows. """ await _seed_test_people(db_session, count=3) _, token = asset_admin_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True # At minimum the 3 seeded picker targets are present (the # asset_admin fixture also creates its own user, so total ≥ 3). assert body["meta"]["total"] >= 3 fields = set(body["data"][0].keys()) if body["data"] else set() # Sensitive HR governance fields MUST be absent from the picker # response — they live on the HR-gated /users endpoint. assert "roles" not in fields assert "last_login_at" not in fields assert "hris_record_state" not in fields assert "manager_name" not in fields # Required picker fields MUST be present. if body["data"]: for f in ("id", "display_name", "work_email", "department", "employee_no", "employment_status"): assert f in body["data"][0], f"missing field {f!r}" @pytest.mark.asyncio async def test_directory_people_finance_can_list( client: AsyncClient, db_session: AsyncSession, finance_user ): await _seed_test_people(db_session, count=2) _, token = finance_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_cloud_admin_can_list( client: AsyncClient, db_session: AsyncSession, cloud_admin_user ): await _seed_test_people(db_session, count=2) _, token = cloud_admin_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_hr_can_list( client: AsyncClient, db_session: AsyncSession, hr_user ): await _seed_test_people(db_session, count=2) _, token = hr_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_auditor_can_list( client: AsyncClient, db_session: AsyncSession, auditor_user ): await _seed_test_people(db_session, count=2) _, token = auditor_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_global_admin_can_list( client: AsyncClient, db_session: AsyncSession, admin_user ): await _seed_test_people(db_session, count=2) _, token = admin_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_management_can_list( client: AsyncClient, db_session: AsyncSession, ): """``management`` is the read-only leadership view; it shares the full admin-tier read surface and the picker is part of that. ``conftest.py``'s ``_SYSTEM_ROLES`` has seeded ``management`` since 2026-06-12 (impact-scorecard tests need it); this local insert is kept as an idempotent self-heal — same ``ON DUPLICATE KEY UPDATE`` pattern conftest uses, so re-runs are safe either way. """ from sqlalchemy import text from app.auth.models import AuthUserRole from app.core.security import create_access_token from app.identity.person_models import IdentityPerson await db_session.execute( text( "INSERT INTO auth_roles (id, name, description, is_system) " "VALUES (:id, 'management', " "'Management read-only leadership view', 1) " "ON DUPLICATE KEY UPDATE description = VALUES(description)" ), {"id": str(uuid.uuid4())}, ) await db_session.commit() user_id = uuid.uuid4() user = IdentityPerson( id=user_id, work_email="mgmt-picker@test.com", display_name="Management Picker", employment_status="active", source_system="successfactors", hris_person_id="sf-mgmt-picker-001", ) db_session.add(user) await db_session.flush() emp_role = await _role_id(db_session, "employee") mgmt_role = await _role_id(db_session, "management") db_session.add(AuthUserRole(user_id=user_id, role_id=emp_role)) db_session.add(AuthUserRole(user_id=user_id, role_id=mgmt_role)) await db_session.flush() await db_session.commit() await _seed_test_people(db_session, count=2) token = create_access_token(str(user_id), ["employee", "management"]) resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text # ── /directory/people — search filter ─────────────────────────────── @pytest.mark.asyncio async def test_directory_people_role_filter_rejects_unknown_value( client: AsyncClient, asset_admin_user ): """Unknown ``role`` values should 400 at the boundary, not silently return an empty list. Defends against behaviour-probing and codifies the strict-input contract for every future picker caller. """ _, token = asset_admin_user resp = await client.get( "/api/v1/directory/people", params={"role": "definitely_not_a_role"}, headers={"Authorization": f"Bearer {token}"}, ) # ``ValidationError`` maps to 422 (see app/core/exceptions.py). assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_directory_people_role_filter_accepts_legacy_alias( client: AsyncClient, db_session: AsyncSession, asset_admin_user ): """Legacy role aliases (``it_admin``→``global_admin``, ``security_auditor``→``auditor``) canonicalise transparently — the role validator must accept them, since older clients / stored bookmarks may carry the legacy form. """ await _seed_test_people(db_session, count=1) _, token = asset_admin_user resp = await client.get( "/api/v1/directory/people", params={"role": "security_auditor"}, # legacy alias of 'auditor' headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_directory_people_search_filter_works_for_asset_admin( client: AsyncClient, db_session: AsyncSession, asset_admin_user ): """Search by name fragment — the 持有人 picker uses this when the user types into the dropdown. Without working search, the picker effectively only shows page-1 results. """ await _seed_test_people(db_session, count=5) _, token = asset_admin_user resp = await client.get( "/api/v1/directory/people", params={"search": "Target 2", "page_size": 10}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() names = [row["display_name"] for row in body["data"]] assert any("Target 2" in (n or "") for n in names), names # ── /directory/people — negative paths ────────────────────────────── @pytest.mark.asyncio async def test_directory_people_employee_forbidden( client: AsyncClient, db_session: AsyncSession, employee_user ): """Regular employees must NOT be able to scrape the directory — the picker is an admin-tier internal tool. ``can_view_directory`` intentionally excludes ``employee``. """ _, token = employee_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_directory_people_unauthenticated_rejected(client: AsyncClient): resp = await client.get("/api/v1/directory/people") assert resp.status_code in (401, 403) # ── /directory/people — view-role narrowing semantics ─────────────── @pytest.mark.asyncio async def test_directory_people_narrowing_to_employee_blocks( client: AsyncClient, db_session: AsyncSession, admin_user ): """When a global_admin sets ``X-View-Role: employee`` to simulate an employee experience, ``apply_view_role_narrow`` collapses their roles to ``[employee]`` — and an employee is NOT in the directory-read allowlist. Asserts that narrowing actually propagates through to the picker endpoint, otherwise "viewing as employee" would silently leak the directory. """ _, token = admin_user resp = await client.get( "/api/v1/directory/people", headers={ "Authorization": f"Bearer {token}", "X-View-Role": "employee", }, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_directory_people_narrowing_to_asset_admin_works( client: AsyncClient, db_session: AsyncSession, admin_user ): """A global_admin narrowed to asset_admin via X-View-Role can still call the picker — asset_admin is in the allowlist. This is the production code path when an IT admin tests the regional asset admin experience via the role-switcher chip. """ from app.auth.models import AuthUserRole # global_admin doesn't hold asset_admin by default; narrowing # only works for roles the user already holds. user, token = admin_user asset_admin_role = await _role_id(db_session, "asset_admin") db_session.add(AuthUserRole(user_id=user.id, role_id=asset_admin_role)) await db_session.commit() resp = await client.get( "/api/v1/directory/people", headers={ "Authorization": f"Bearer {token}", "X-View-Role": "asset_admin", }, ) assert resp.status_code == 200, resp.text # ── /auth/departments — gate widening ─────────────────────────────── @pytest.mark.asyncio async def test_auth_departments_asset_admin_can_list( client: AsyncClient, asset_admin_user ): """Original bug: AssetListPage:454 + AssetDetailPage:184 hit /auth/departments. asset_admin used to 403 silently; widening to require_directory_read should now return 200. """ _, token = asset_admin_user resp = await client.get( "/api/v1/auth/departments", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_auth_departments_finance_can_list( client: AsyncClient, finance_user ): _, token = finance_user resp = await client.get( "/api/v1/auth/departments", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_auth_departments_cloud_admin_can_list( client: AsyncClient, cloud_admin_user ): _, token = cloud_admin_user resp = await client.get( "/api/v1/auth/departments", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_auth_departments_employee_forbidden( client: AsyncClient, employee_user ): """Employees never had access — directory-tier remains closed to them per ``can_view_directory``.""" _, token = employee_user resp = await client.get( "/api/v1/auth/departments", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403, resp.text # ── /users — UNCHANGED: still HR-gated ────────────────────────────── @pytest.mark.asyncio async def test_users_endpoint_still_hr_gated_for_asset_admin( client: AsyncClient, asset_admin_user ): """The HR-only ``/users`` endpoint MUST still 403 asset_admin — that's the whole reason we introduced /directory/people as a separate surface. If this test ever starts passing as 200, someone widened the HR-governance gate by accident and asset_admin can now see ``roles[] + last_login_at + hris_record_state`` for every employee. """ _, token = asset_admin_user resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_users_endpoint_still_hr_gated_for_finance( client: AsyncClient, finance_user ): _, token = finance_user resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_users_endpoint_works_for_hr( client: AsyncClient, hr_user ): _, token = hr_user resp = await client.get( "/api/v1/users", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text # ── Response-shape contract ───────────────────────────────────────── @pytest.mark.asyncio async def test_directory_people_response_omits_sensitive_fields( client: AsyncClient, db_session: AsyncSession, asset_admin_user ): """Defense-in-depth: even if a future endpoint refactor moves /directory/people to use the same response model as /users, this test fails as a tripwire if HR-only PII leaks into the picker response. """ await _seed_test_people(db_session, count=1) _, token = asset_admin_user resp = await client.get( "/api/v1/directory/people", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() if not body["data"]: pytest.skip("seed produced no data — picker shape can't be asserted") sample = body["data"][0] # Whitelist — these are the ONLY fields the picker should return. allowed = { "id", "display_name", "work_email", "employee_no", "department", "employment_status", } actual = set(sample.keys()) extra = actual - allowed assert not extra, ( f"unexpected fields leaked into /directory/people response: {extra}. " f"If you intentionally added a field, update both this test and the " f"DirectoryPersonResponse schema in app/auth/schemas.py." ) # ── /directory/people/lite — self-service picker ──────────────────── @pytest.mark.asyncio async def test_directory_lite_employee_can_search( client: AsyncClient, db_session: AsyncSession, employee_user ): """The original bug: an employee on /my/cloud/dns typed "孟" into the bulk-transfer modal and the picker stayed empty because UserSelect was calling the admin-only ``/directory/people`` endpoint, which 403s a non-admin. The lite variant MUST return 200 with the matching row. """ await _seed_test_people(db_session, count=3) _, token = employee_user resp = await client.get( "/api/v1/directory/people/lite", params={"q": "Target 1"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True names = [row["display_name"] for row in body["data"]] assert any("Target 1" in (n or "") for n in names), names @pytest.mark.asyncio async def test_directory_lite_empty_query_returns_empty( client: AsyncClient, db_session: AsyncSession, employee_user ): """Anti-scrape: an empty search MUST return an empty list, not the first 20 rows of the roster. The lite endpoint is a picker, not a directory dump. """ await _seed_test_people(db_session, count=5) _, token = employee_user resp = await client.get( "/api/v1/directory/people/lite", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["data"] == [] @pytest.mark.asyncio async def test_directory_lite_unauthenticated_rejected(client: AsyncClient): resp = await client.get( "/api/v1/directory/people/lite", params={"q": "x"}, ) assert resp.status_code in (401, 403) @pytest.mark.asyncio async def test_directory_lite_response_shape( client: AsyncClient, db_session: AsyncSession, employee_user ): """The lite endpoint MUST NOT leak employee_no (HR-internal) or any HR-governance fields. Whitelist guards future refactors. """ await _seed_test_people(db_session, count=2) _, token = employee_user resp = await client.get( "/api/v1/directory/people/lite", params={"q": "Target"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["data"], "seed produced rows but picker returned none" allowed = { "id", "display_name", "work_email", "department", "employment_status", } for row in body["data"]: extra = set(row.keys()) - allowed assert not extra, ( f"unexpected fields leaked into /directory/people/lite: " f"{extra}. employee_no is intentionally OMITTED at the " f"any-employee tier; update this whitelist if you " f"intentionally widened the lite response." ) @pytest.mark.asyncio async def test_directory_lite_limit_capped_at_30( client: AsyncClient, db_session: AsyncSession, employee_user ): """``limit`` is capped at 30 to keep the lite endpoint a picker (not a paginator). Requesting more should 422 at the boundary. """ _, token = employee_user resp = await client.get( "/api/v1/directory/people/lite", params={"q": "x", "limit": 100}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 422, resp.text