"""HR-Tools history privacy gate (two-pronged self-scope). A row is visible to a user iff:: row.person_id == user.id OR row.calculated_by_user_id == user.id — i.e. the caller is either the subject of the calculation OR the person who ran it. Applies to **every** caller including HR + global_admin; there is no role-based widening. Sandbox rows (``person_id`` IS NULL) pass only via the calculator branch. Iteration history (one PR per row to keep the bisect clear): - PR #305: HR / global_admin had cross-person read. - PR #306: tightened to strict ``person_id == self`` for every role. Side effect: HR who only ran calculations for OTHERS saw 0 rows in their history feed — couldn't audit their own work. - This PR: settled on the OR'd shape. Each row has AT MOST two visible users (subject + calculator), so the leak surface is bounded, and HR retains visibility on their own work. These tests pin the two-pronged shape so a future refactor that reverts to subject-only OR opens to cross-role can't slip past CI. """ from __future__ import annotations from datetime import date from decimal import Decimal from uuid import uuid4 import pytest import pytest_asyncio from app.auth.models import AuthUserRole from app.core.security import create_access_token from app.domains.hr_tools.models import HrToolsAnnualLeaveCalculation from app.identity.person_models import IdentityPerson def _bearer(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} async def _seed_history_row( db_session, *, person_id, calculated_by_user_id, employee_no: str = "P9999", employee_name: str = "Subject", ) -> HrToolsAnnualLeaveCalculation: """Insert one persisted annual-leave row about ``person_id`` and return it. The numbers don't matter for these tests — what matters is the (person_id, record_id) tuple.""" row = HrToolsAnnualLeaveCalculation( person_id=str(person_id) if person_id is not None else None, employee_no=employee_no, employee_name=employee_name, work_start_date=date(2016, 7, 1), hire_date=date(2020, 10, 21), termination_date=date(2026, 5, 28), fiscal_year_start=date(2026, 4, 1), fiscal_year_end=date(2027, 3, 31), carry_legal_days=Decimal("1.0"), carry_welfare_days=Decimal("1.0"), used_legal_days=Decimal("0.0"), used_welfare_days=Decimal("0.0"), full_year_segments=[], full_year_legal_raw=Decimal("9.0"), full_year_welfare_raw=Decimal("4.0"), full_year_legal_rounded=Decimal("9.0"), full_year_welfare_rounded=Decimal("4.0"), full_year_legal_capped=Decimal("9.0"), full_year_welfare_capped=Decimal("4.0"), settlement_segments=[], settlement_legal_raw=Decimal("1.0"), settlement_welfare_raw=Decimal("1.5"), settlement_legal_rounded=Decimal("1.0"), settlement_welfare_rounded=Decimal("1.5"), settlement_legal_capped=Decimal("1.0"), settlement_welfare_capped=Decimal("1.5"), balance_legal_days=Decimal("1.0"), balance_welfare_days=Decimal("1.5"), cap_applied_full_year=False, cap_applied_settlement=False, calculated_by_user_id=str(calculated_by_user_id), calculated_by_name="Calculator HR", notes=None, ) db_session.add(row) await db_session.flush() await db_session.commit() # Refresh so DB-side defaults (``created_at`` via # ``server_default=func.now()``) populate on the Python object; # otherwise the FastAPI handler's identity-mapped fetch would try # to lazy-load ``created_at`` synchronously inside an async # request → ``MissingGreenlet``. await db_session.refresh(row) return row @pytest_asyncio.fixture async def hr_user(db_session): """A user with the ``hr`` role only (no global_admin).""" user_id = uuid4() user = IdentityPerson( id=user_id, work_email="hr-admin@test.com", display_name="HR Admin", employment_status="active", source_system="successfactors", hris_person_id="sf-hr-001", ) db_session.add(user) await db_session.flush() from sqlalchemy import text as _text role_id = ( await db_session.execute( _text("SELECT id FROM auth_roles WHERE name = 'hr'") ) ).scalar_one() db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.flush() await db_session.commit() token = create_access_token(str(user_id), ["hr"]) return user, token @pytest_asyncio.fixture async def another_employee(db_session): """A second employee whose history rows ``employee_user`` must never see — colleague-isolation reference person.""" user_id = uuid4() user = IdentityPerson( id=user_id, work_email="other-employee@test.com", display_name="Other Employee", employment_status="active", source_system="successfactors", hris_person_id="sf-other-001", ) db_session.add(user) await db_session.flush() from sqlalchemy import text as _text role_id = ( await db_session.execute( _text("SELECT id FROM auth_roles WHERE name = 'employee'") ) ).scalar_one() db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.flush() await db_session.commit() return user @pytest.mark.asyncio async def test_employee_history_list_filtered_to_self( client, employee_user, admin_user, another_employee, db_session, ): """Employee with no calculator footprint: only rows about them show up (subject branch). Rows about others (whether admin or another employee calculated them) AND admin-created sandbox rows stay hidden. """ emp, emp_token = employee_user admin, _admin_token = admin_user await _seed_history_row( db_session, person_id=emp.id, calculated_by_user_id=admin.id, employee_no="EMP-SELF", employee_name=emp.display_name, ) await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=admin.id, employee_no="EMP-OTHER", employee_name=another_employee.display_name, ) await _seed_history_row( db_session, person_id=None, calculated_by_user_id=admin.id, employee_no="SANDBOX", employee_name="No subject", ) # Even with explicit other-person_id query, the param is ignored # and the OR visibility clause runs unchanged. r = await client.get( "/api/v1/hr-tools/annual-leave/history", params={"person_id": str(another_employee.id), "page": 1, "page_size": 50}, headers=_bearer(emp_token), ) assert r.status_code == 200, r.text body = r.json() items = body["data"] assert len(items) == 1, f"expected 1 own row, got {len(items)}: {items}" assert items[0]["employee_no"] == "EMP-SELF" assert items[0]["person_id"] == str(emp.id) @pytest.mark.asyncio async def test_employee_history_detail_other_person_404s( client, employee_user, admin_user, another_employee, db_session, ): """Direct-by-id detail for someone else's row → 404 (not 403): we don't leak that the record exists. Sandbox rows likewise opaque to non-privileged callers.""" emp, emp_token = employee_user admin, _ = admin_user other_row = await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=admin.id, ) sandbox_row = await _seed_history_row( db_session, person_id=None, calculated_by_user_id=admin.id, employee_no="SANDBOX", ) r_other = await client.get( f"/api/v1/hr-tools/annual-leave/history/{other_row.id}", headers=_bearer(emp_token), ) assert r_other.status_code == 404, r_other.text r_sandbox = await client.get( f"/api/v1/hr-tools/annual-leave/history/{sandbox_row.id}", headers=_bearer(emp_token), ) assert r_sandbox.status_code == 404, r_sandbox.text @pytest.mark.asyncio async def test_employee_history_detail_own_row_ok( client, employee_user, admin_user, db_session, ): emp, emp_token = employee_user admin, _ = admin_user own = await _seed_history_row( db_session, person_id=emp.id, calculated_by_user_id=admin.id, ) r = await client.get( f"/api/v1/hr-tools/annual-leave/history/{own.id}", headers=_bearer(emp_token), ) assert r.status_code == 200, r.text assert r.json()["data"]["id"] == str(own.id) @pytest.mark.asyncio async def test_hr_sees_own_calcs_and_rows_about_self( client, hr_user, admin_user, employee_user, another_employee, db_session, ): """HR's history feed = their own footprint: - rows about HR personally (subject branch); - rows HR has personally calculated for anyone, including sandbox rows (calculator branch). Rows about other people that HR didn't run are NOT visible to HR. Sandbox rows another admin made are NOT visible to HR. """ hr, hr_token = hr_user admin, _ = admin_user emp, _ = employee_user # Visible: HR's self-calc (subject + calculator both match) await _seed_history_row( db_session, person_id=hr.id, calculated_by_user_id=hr.id, employee_no="HR-SELF", ) # Visible: rows HR ran for others (calculator branch) await _seed_history_row( db_session, person_id=emp.id, calculated_by_user_id=hr.id, employee_no="EMP-VIA-HR", ) await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=hr.id, employee_no="OTHER-VIA-HR", ) # Visible: HR's own sandbox calc (calculator branch on a NULL subject) await _seed_history_row( db_session, person_id=None, calculated_by_user_id=hr.id, employee_no="SANDBOX-VIA-HR", ) # NOT visible: admin's calc about someone else await _seed_history_row( db_session, person_id=emp.id, calculated_by_user_id=admin.id, employee_no="EMP-VIA-ADMIN", ) # NOT visible: admin's sandbox calc await _seed_history_row( db_session, person_id=None, calculated_by_user_id=admin.id, employee_no="SANDBOX-VIA-ADMIN", ) r = await client.get( "/api/v1/hr-tools/annual-leave/history", params={"page": 1, "page_size": 50}, headers=_bearer(hr_token), ) assert r.status_code == 200, r.text items = r.json()["data"] employee_nos = {i["employee_no"] for i in items} assert employee_nos == {"HR-SELF", "EMP-VIA-HR", "OTHER-VIA-HR", "SANDBOX-VIA-HR"}, ( f"expected HR's own footprint, got {employee_nos}" ) @pytest.mark.asyncio async def test_admin_detail_other_persons_calc_is_404_when_not_calculator( client, admin_user, hr_user, another_employee, db_session, ): """Admin who did NOT calculate the row AND is NOT the subject cannot read its detail — 404, not 403.""" admin, admin_token = admin_user hr, _ = hr_user # HR calculated for another employee; admin neither subject nor calculator row = await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=hr.id, ) r = await client.get( f"/api/v1/hr-tools/annual-leave/history/{row.id}", headers=_bearer(admin_token), ) assert r.status_code == 404, r.text @pytest.mark.asyncio async def test_admin_detail_own_calc_for_someone_else_is_visible( client, admin_user, another_employee, db_session, ): """Calculator branch on detail: admin DID run this calc → can fetch detail. (Subject is someone else; visibility comes from ``calculated_by_user_id == admin.id``.)""" admin, admin_token = admin_user row = await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=admin.id, ) r = await client.get( f"/api/v1/hr-tools/annual-leave/history/{row.id}", headers=_bearer(admin_token), ) assert r.status_code == 200, r.text assert r.json()["data"]["id"] == str(row.id) @pytest.mark.asyncio async def test_subject_can_see_calc_run_by_someone_else( client, employee_user, hr_user, db_session, ): """Subject branch: employee can see what HR calculated about them on the detail endpoint (e.g. settlement HR generated for them). Without this, the employee has no way to verify HR's numbers. """ emp, emp_token = employee_user hr, _ = hr_user row = await _seed_history_row( db_session, person_id=emp.id, calculated_by_user_id=hr.id, ) r = await client.get( f"/api/v1/hr-tools/annual-leave/history/{row.id}", headers=_bearer(emp_token), ) assert r.status_code == 200, r.text @pytest.mark.asyncio async def test_admin_query_person_id_param_ignored( client, admin_user, another_employee, db_session, ): """The ``person_id`` query param is accepted for API back-compat but unconditionally ignored — passing a colleague's id MUST NOT bypass the OR'd visibility clause.""" admin, admin_token = admin_user # Visible: admin's own row (subject branch) await _seed_history_row( db_session, person_id=admin.id, calculated_by_user_id=admin.id, employee_no="ADMIN-SELF", ) # Visible: admin's calc for another (calculator branch) await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=admin.id, employee_no="OTHER-VIA-ADMIN", ) # Not visible to admin: another's self-calc — neither subject nor calculator await _seed_history_row( db_session, person_id=another_employee.id, calculated_by_user_id=another_employee.id, employee_no="OTHER-SELF", ) # Passing colleague's id MUST NOT widen visibility. r = await client.get( "/api/v1/hr-tools/annual-leave/history", params={"person_id": str(another_employee.id), "page": 1, "page_size": 50}, headers=_bearer(admin_token), ) assert r.status_code == 200, r.text items = r.json()["data"] employee_nos = {i["employee_no"] for i in items} assert employee_nos == {"ADMIN-SELF", "OTHER-VIA-ADMIN"}, employee_nos @pytest.mark.asyncio async def test_employee_cannot_call_calculate( client, employee_user, ): """POST /calculate stays HR/admin-only — defense in depth so the history-privacy change doesn't accidentally open the trigger endpoint along with the read endpoints.""" _, emp_token = employee_user payload = { "work_start_date": "2016-07-01", "hire_date": "2020-10-21", "termination_date": "2026-05-28", "carry_legal_days": "1.0", "carry_welfare_days": "1.0", "used_legal_days": "0", "used_welfare_days": "0", } r = await client.post( "/api/v1/hr-tools/annual-leave/calculate", json=payload, headers=_bearer(emp_token), ) assert r.status_code in (401, 403), r.text @pytest.mark.asyncio async def test_unauthenticated_history_rejected(client): """Sanity: no token → unauthorized. Open-but-scoped is not the same as anonymous-readable.""" r = await client.get("/api/v1/hr-tools/annual-leave/history") assert r.status_code in (401, 403)