"""Tests for overdue leaver detection, transition, and access blocking. Covers: - Auth layer blocks overdue leaver_pending users (login, refresh, JWT middleware) - HR service: list_overdue_leavers / transition_overdue_leavers - Admin API endpoints: GET /overdue-leavers, POST /overdue-leavers/transition """ from datetime import UTC, date, datetime, timedelta from uuid import uuid4 from zoneinfo import ZoneInfo import pytest import respx from httpx import Response from sqlalchemy import select from app.audit.models import AuditLog from app.auth.service import is_effectively_departed from app.core.config import settings from app.core.security import create_access_token, create_refresh_token from app.identity.person_models import IdentityPerson # Asia/Shanghai matches the default ``_FakeUser.country = "CN"`` below # and the default ``resolve_timezone`` fallback used by the # under-test service when ``IdentityPerson.country`` is NULL. Use this # helper instead of ``date.today()`` whenever the assertion afterwards # checks ``days_overdue == N`` — ``date.today()`` returns the UTC date # on CI but the service computes ``local_today`` from the person's tz # (Asia/Shanghai by default), so a CI run that straddles CN midnight # produces an off-by-one day count and fails an exact-match assertion. _TEST_LOCAL_TZ = ZoneInfo("Asia/Shanghai") def _local_today() -> date: return datetime.now(UTC).astimezone(_TEST_LOCAL_TZ).date() # --------------------------------------------------------------------------- # Unit tests: is_effectively_departed helper # --------------------------------------------------------------------------- class _FakeUser: """Minimal stand-in for testing is_effectively_departed. ``country`` defaults to ``"CN"`` so the local-tz gate (resolve_timezone) lands on Asia/Shanghai — these legacy tests were written before the local-tz refactor and want the simple "today" semantics that hold in HQ time.""" def __init__( self, employment_status: str, termination_date: date | None = None, country: str | None = "CN", city: str | None = None, hris_record_state: str = "present", ): self.employment_status = employment_status self.termination_date = termination_date self.country = country # Mirror the real IdentityPerson column so resolve_timezone's # city-refinement path (multi-zone countries) can read it. self.city = city # Mirror the real IdentityPerson column (NOT NULL, default # 'present'); is_effectively_departed now also blocks 'purged'. self.hris_record_state = hris_record_state class TestIsEffectivelyDeparted: def test_left_status_always_departed(self): assert is_effectively_departed(_FakeUser("left")) is True def test_active_not_departed(self): assert is_effectively_departed(_FakeUser("active")) is False def test_leaver_pending_future_date_not_departed(self): future = date.today() + timedelta(days=30) assert is_effectively_departed(_FakeUser("leaver_pending", future)) is False def test_leaver_pending_today_NOT_departed(self): """Updated 2026-05-21: under the local-tz gate, the leaver stays active for their entire last working day. The previous assertion (termination_date == today → blocked) cut Asia/SHA employees off at 00:00 local before they could send their farewell email. See [[feedback-cron-must-respect-local-tz]].""" # Use CN local today: _FakeUser defaults to country='CN' so # the gate computes against Asia/Shanghai, while raw # date.today() on CI uses UTC — those disagree across CN # midnight, flaking this test on late-UTC CI runs. assert ( is_effectively_departed( _FakeUser("leaver_pending", _local_today()) ) is False ) def test_leaver_pending_past_date_is_departed(self): past = date.today() - timedelta(days=5) assert is_effectively_departed(_FakeUser("leaver_pending", past)) is True def test_leaver_pending_no_termination_date_not_departed(self): assert is_effectively_departed(_FakeUser("leaver_pending", None)) is False def test_purged_active_is_departed(self): """hris_record_state='purged' blocks auth even when employment_status was never flipped (orphaned active row).""" assert is_effectively_departed( _FakeUser("active", hris_record_state="purged") ) is True # --------------------------------------------------------------------------- # Integration tests: auth blocks overdue leaver_pending on Google SSO login # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_google_login_blocked_for_overdue_leaver_pending(client, db_session): """A leaver_pending user past termination_date cannot log in via Google SSO.""" from sqlalchemy import text from app.auth.models import AuthUserRole user_id = uuid4() past_date = date.today() - timedelta(days=3) user = IdentityPerson( id=user_id, work_email="overdue-leaver@company.com", display_name="Overdue Leaver", employment_status="leaver_pending", termination_date=past_date, source_system="successfactors", hris_person_id="sf-overdue-001", ) db_session.add(user) await db_session.flush() role_result = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_result.scalar_one() import uuid as _uuid if isinstance(role_id, str): role_id = _uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.commit() with respx.mock: respx.get("https://oauth2.googleapis.com/tokeninfo").mock( return_value=Response( 200, json={ "sub": "google-sub-overdue", "email": "overdue-leaver@company.com", "email_verified": "true", "name": "Overdue Leaver", "aud": settings.GOOGLE_CLIENT_ID, "iss": "accounts.google.com", }, ) ) resp = await client.post( "/api/v1/auth/google/verify", json={"id_token": "fake-google-token"}, ) assert resp.status_code == 401 assert resp.json()["errors"][0]["code"] == "LOGIN_FAILED" @pytest.mark.asyncio async def test_google_login_allowed_for_future_leaver_pending(client, db_session): """A leaver_pending user with future termination_date CAN still log in.""" from sqlalchemy import text from app.auth.models import AuthUserRole user_id = uuid4() future_date = date.today() + timedelta(days=30) user = IdentityPerson( id=user_id, work_email="future-leaver@company.com", display_name="Future Leaver", employment_status="leaver_pending", termination_date=future_date, source_system="successfactors", hris_person_id="sf-future-001", ) db_session.add(user) await db_session.flush() role_result = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_result.scalar_one() import uuid as _uuid if isinstance(role_id, str): role_id = _uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.commit() with respx.mock: respx.get("https://oauth2.googleapis.com/tokeninfo").mock( return_value=Response( 200, json={ "sub": "google-sub-future", "email": "future-leaver@company.com", "email_verified": "true", "name": "Future Leaver", "aud": settings.GOOGLE_CLIENT_ID, "iss": "accounts.google.com", }, ) ) resp = await client.post( "/api/v1/auth/google/verify", json={"id_token": "fake-google-token"}, ) assert resp.status_code == 200 assert resp.json()["success"] is True # --------------------------------------------------------------------------- # Integration tests: JWT middleware blocks overdue leaver_pending # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_jwt_middleware_blocks_overdue_leaver_pending(client, db_session): """An existing JWT for a leaver_pending user past termination_date is rejected.""" from sqlalchemy import text from app.auth.models import AuthUserRole user_id = uuid4() past_date = date.today() - timedelta(days=1) user = IdentityPerson( id=user_id, work_email="jwt-overdue@company.com", display_name="JWT Overdue", employment_status="leaver_pending", termination_date=past_date, source_system="successfactors", hris_person_id="sf-jwt-overdue-001", ) db_session.add(user) await db_session.flush() role_result = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_result.scalar_one() import uuid as _uuid if isinstance(role_id, str): role_id = _uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.commit() token = create_access_token(str(user_id), ["employee"]) resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_jwt_middleware_allows_future_leaver_pending(client, db_session): """An existing JWT for a leaver_pending user with future date still works.""" from sqlalchemy import text from app.auth.models import AuthUserRole user_id = uuid4() future_date = date.today() + timedelta(days=14) user = IdentityPerson( id=user_id, work_email="jwt-future@company.com", display_name="JWT Future", employment_status="leaver_pending", termination_date=future_date, source_system="successfactors", hris_person_id="sf-jwt-future-001", ) db_session.add(user) await db_session.flush() role_result = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_result.scalar_one() import uuid as _uuid if isinstance(role_id, str): role_id = _uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.commit() token = create_access_token(str(user_id), ["employee"]) resp = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["data"]["email"] == "jwt-future@company.com" # --------------------------------------------------------------------------- # Integration tests: token refresh blocked for overdue leaver_pending # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_token_refresh_blocked_for_overdue_leaver_pending(client, db_session): """Token refresh fails for a leaver_pending user past termination_date.""" from sqlalchemy import text from app.auth.models import AuthUserRole user_id = uuid4() past_date = date.today() - timedelta(days=2) user = IdentityPerson( id=user_id, work_email="refresh-overdue@company.com", display_name="Refresh Overdue", employment_status="leaver_pending", termination_date=past_date, source_system="successfactors", hris_person_id="sf-refresh-overdue-001", ) db_session.add(user) await db_session.flush() role_result = await db_session.execute( text("SELECT id FROM auth_roles WHERE name = 'employee'") ) role_id = role_result.scalar_one() import uuid as _uuid if isinstance(role_id, str): role_id = _uuid.UUID(role_id) db_session.add(AuthUserRole(user_id=user_id, role_id=role_id)) await db_session.commit() from app.auth.service import _store_refresh_jti refresh_token = create_refresh_token(str(user_id)) # JTI must exist in Redis or _consume_refresh_jti raises 503 on Redis # exceptions / returns False on missing JTI (which would also raise). The # real failure mode we want to exercise is the leaver_pending block, so # plant the JTI first. await _store_refresh_jti(refresh_token, str(user_id)) # Set the refresh-token cookie on the client jar (rather than the # deprecated per-request ``cookies=`` kwarg, which httpx 0.28+ # warns about). The refresh endpoint reads it from the request # cookies the same way either path would deliver it. client.cookies.set("refresh_token", refresh_token) try: resp = await client.post("/api/v1/auth/refresh") finally: client.cookies.delete("refresh_token") assert resp.status_code == 401 # --------------------------------------------------------------------------- # Integration tests: HR service — list & transition overdue leavers # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_list_overdue_leavers_returns_only_past_termination(db_session): """list_overdue_leavers returns only leaver_pending with past termination_date.""" from app.domains.hr.service import SuccessFactorsLifecycleService # Use CN-local "today" — the seeded user has NULL country which # falls back to Asia/Shanghai in ``resolve_timezone``, so the # service computes days_overdue against CN-local today, not UTC # today. Mixing those gives off-by-one on CI runs across CN # midnight (e.g. 18:00 UTC = 02:00 CN next day). past_date = _local_today() - timedelta(days=5) future_date = _local_today() + timedelta(days=10) overdue_user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-overdue-list-001", employee_no="EMP-OL001", work_email="overdue-list@company.com", display_name="Overdue List User", employment_status="leaver_pending", termination_date=past_date, ) future_user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-future-list-001", employee_no="EMP-FL001", work_email="future-list@company.com", display_name="Future List User", employment_status="leaver_pending", termination_date=future_date, ) already_left = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-left-list-001", employee_no="EMP-LL001", work_email="left-list@company.com", display_name="Left List User", employment_status="left", termination_date=past_date, ) db_session.add_all([overdue_user, future_user, already_left]) await db_session.commit() service = SuccessFactorsLifecycleService(db_session) result = await service.list_overdue_leavers() assert len(result) == 1 assert result[0]["user_id"] == str(overdue_user.id) assert result[0]["days_overdue"] == 5 @pytest.mark.asyncio async def test_transition_overdue_leavers_updates_status(db_session): """transition_overdue_leavers changes leaver_pending → left for overdue users.""" from app.domains.hr.service import SuccessFactorsLifecycleService past_date = date.today() - timedelta(days=3) future_date = date.today() + timedelta(days=10) overdue_user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-transition-001", employee_no="EMP-T001", work_email="transition@company.com", display_name="Transition User", employment_status="leaver_pending", termination_date=past_date, ) future_user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-transition-002", employee_no="EMP-T002", work_email="no-transition@company.com", display_name="No Transition User", employment_status="leaver_pending", termination_date=future_date, ) db_session.add_all([overdue_user, future_user]) await db_session.commit() service = SuccessFactorsLifecycleService(db_session) summary = await service.transition_overdue_leavers() await db_session.commit() assert summary["transitioned_count"] == 1 assert str(overdue_user.id) in summary["transitioned_user_ids"] # Verify DB state refreshed = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.id == overdue_user.id) ) assert refreshed is not None assert refreshed.employment_status == "left" # Future user unchanged refreshed_future = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.id == future_user.id) ) assert refreshed_future is not None assert refreshed_future.employment_status == "leaver_pending" @pytest.mark.asyncio async def test_transition_overdue_leavers_noop_when_none(db_session): """transition_overdue_leavers returns 0 when no overdue users exist.""" from app.domains.hr.service import SuccessFactorsLifecycleService service = SuccessFactorsLifecycleService(db_session) summary = await service.transition_overdue_leavers() assert summary["transitioned_count"] == 0 assert summary["transitioned_user_ids"] == [] # --------------------------------------------------------------------------- # Integration tests: Admin API endpoints for overdue leavers # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_api_list_overdue_leavers(client, admin_user, db_session): """GET /api/v1/hr/overdue-leavers returns overdue leaver_pending users.""" _, token = admin_user # CN-local "today" so the days_overdue==7 assertion below holds # regardless of UTC↔CN date boundary at CI run time (see helper). past_date = _local_today() - timedelta(days=7) user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-api-overdue-001", employee_no="EMP-AO001", work_email="api-overdue@company.com", display_name="API Overdue User", employment_status="leaver_pending", termination_date=past_date, ) db_session.add(user) await db_session.commit() resp = await client.get( "/api/v1/hr/overdue-leavers", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert len(data) == 1 assert data[0]["email"] == "api-overdue@company.com" assert data[0]["days_overdue"] == 7 @pytest.mark.asyncio async def test_api_list_overdue_leavers_requires_admin(client, employee_user): """GET /api/v1/hr/overdue-leavers is admin-only.""" _, token = employee_user resp = await client.get( "/api/v1/hr/overdue-leavers", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_api_transition_overdue_leavers(client, admin_user, db_session): """POST /api/v1/hr/overdue-leavers/transition transitions overdue users and audits.""" admin, token = admin_user past_date = date.today() - timedelta(days=2) user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-api-trans-001", employee_no="EMP-AT001", work_email="api-transition@company.com", display_name="API Transition User", employment_status="leaver_pending", termination_date=past_date, ) db_session.add(user) await db_session.commit() resp = await client.post( "/api/v1/hr/overdue-leavers/transition", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json()["data"] assert data["transitioned_count"] == 1 # Verify user status changed refreshed = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.id == user.id) ) assert refreshed is not None assert refreshed.employment_status == "left" # Verify audit log was created audit = await db_session.scalar( select(AuditLog).where(AuditLog.action == "hr.overdue_leavers.transition") ) assert audit is not None assert audit.status == "success" assert audit.detail["transitioned_count"] == 1 @pytest.mark.asyncio async def test_api_transition_overdue_leavers_requires_admin(client, employee_user): """POST /api/v1/hr/overdue-leavers/transition is admin-only.""" _, token = employee_user resp = await client.post( "/api/v1/hr/overdue-leavers/transition", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_api_transition_no_overdue_skips_audit(client, admin_user, db_session): """POST transition with 0 overdue users does not create audit log.""" _, token = admin_user resp = await client.post( "/api/v1/hr/overdue-leavers/transition", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["data"]["transitioned_count"] == 0 audit = await db_session.scalar( select(AuditLog).where(AuditLog.action == "hr.overdue_leavers.transition") ) assert audit is None