import hashlib import hmac import json import logging import time as _time from datetime import date from uuid import uuid4 import pytest from sqlalchemy import select from app.audit.models import AuditLog from app.domains.asset.models import AssetItem from app.domains.hr.models import HrLifecycleEvent from app.identity.person_models import IdentityPerson # Autouse fixture: stop the SF apply path from hitting fake.sapsf.cn for # master-data / employments (each otherwise burns the 30s httpx timeout). from tests.test_hr._sf_no_network import ( # noqa: F401 sf_no_network_master_data, ) def _signed_webhook_post(body_dict: dict, *, secret: str, ts: str | None = None): """Build the (raw_body, headers) pair the webhook expects. Mirrors what an SF webhook integration sends post-2026-05-08 (eleventh pre-launch audit): timestamp + HMAC-SHA256 over ``ts + "." + raw_body`` keyed with the shared ``webhook_hmac_secret``. Returns the raw bytes so callers pass them through ``content=`` (not ``json=``) — encoding the body twice would change the byte sequence and break the signature. """ raw = json.dumps(body_dict, separators=(",", ":")).encode() if ts is None: ts = str(int(_time.time())) sig = hmac.new( secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256, ).hexdigest() headers = { "X-Helix-Webhook-Token": secret, "X-SF-Timestamp": ts, "X-SF-Signature": sig, "Content-Type": "application/json", } return raw, headers def _stub_sf_config( monkeypatch, *, webhook_secret: str = "", base_url: str = "https://fake.sapsf.cn/odata/v2", username: str = "apiuser@MYCO", password: str = "pw", ): """Replace platform_configs lookups + the client factory with in-memory stubs so HR tests don't depend on the config table. """ async def _fake_get_config(**_kwargs): return { "auth_method": "basic", "base_url": base_url, "username": username, "password": password, "webhook_hmac_secret": webhook_secret, } async def _fake_from_config(cls, **_kwargs): from app.identity.successfactors_auth import BasicAuth from app.identity.successfactors_client import SuccessFactorsClient return SuccessFactorsClient( base_url=base_url, auth=BasicAuth(username, password), ) monkeypatch.setattr( "app.integrations.compat.get_successfactors_config", _fake_get_config, ) monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.from_config", classmethod(_fake_from_config), ) @pytest.fixture def captured_spawn(monkeypatch): """Neutralise the webhook's background dispatch (504 fix): the endpoint now authenticates + fast-ACKs, handing the apply to a spawned task. The coroutine is closed unrun so HTTP-layer assertions stay deterministic. Returns the list of spawned task names.""" names: list[str] = [] def _fake_spawn(coro, *, name=None): names.append(name) coro.close() return None monkeypatch.setattr("app.domains.hr.router._spawn_background", _fake_spawn) return names @pytest.mark.asyncio async def test_successfactors_webhook_requires_token_when_configured( client, monkeypatch, ): _stub_sf_config(monkeypatch, webhook_secret="expected-token") response = await client.post( "/api/v1/hr/successfactors/webhook", json={"events": [{"event_type": "onboard", "user_id": "P001", "effective_date": "2026-03-10"}]}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_successfactors_webhook_fast_acks_and_audits_receipt( client, db_session, monkeypatch, captured_spawn, ): """504 fix: the webhook authenticates, writes a receipt audit row, and fast-ACKs with ``{accepted, events_received}`` — handing the apply to a background task instead of blocking the request on the SF round-trips (which caused SF's ``504 Gateway Time-out``).""" _stub_sf_config(monkeypatch, webhook_secret="expected-token") raw, headers = _signed_webhook_post( { "events": [ {"event_type": "onboard", "user_id": "P001", "effective_date": "2026-03-10"}, {"event_type": "offboard", "user_id": "P002", "effective_date": "2026-03-12"}, ] }, secret="expected-token", ) response = await client.post( "/api/v1/hr/successfactors/webhook", headers=headers, content=raw, ) assert response.status_code == 200 data = response.json()["data"] assert data["accepted"] is True assert data["events_received"] == 2 # Exactly one background apply dispatched — never run inline. assert len(captured_spawn) == 1 audit = await db_session.scalar( select(AuditLog).where(AuditLog.action == "hr.successfactors_webhook.receive") ) assert audit is not None assert audit.status == "success" assert audit.detail["events_received"] == 2 assert audit.detail["processing"] == "async" @pytest.mark.asyncio async def test_successfactors_webhook_logs_only_safe_body_metadata( client, db_session, monkeypatch, captured_spawn, caplog, ): """The cloud-log receipt must never duplicate contact PII from the recovery payload kept in the access-controlled audit row.""" _stub_sf_config(monkeypatch, webhook_secret="expected-token") private_email = "private-pii-2271@example.net" private_phone = "+8613800227111" body = { "events": [{ "event_type": "onboard", "user_id": "P02271", "effective_date": "2026-08-01", "personalEmail": private_email, "phoneNumber": private_phone, }], } raw, headers = _signed_webhook_post(body, secret="expected-token") expected_hash = hashlib.sha256(raw).hexdigest() caplog.set_level(logging.INFO, logger="app.domains.hr.router") response = await client.post( "/api/v1/hr/successfactors/webhook", headers=headers, content=raw, ) assert response.status_code == 200 receipt_logs = [ record.getMessage() for record in caplog.records if "HRIS_WEBHOOK_RECEIVED" in record.getMessage() ] assert len(receipt_logs) == 1 receipt = receipt_logs[0] for field_name in ( "body_bytes", "body_sha256", "event_count", "event_types", "slug", "trace_id", ): assert f"{field_name}=" in receipt assert "primary" not in receipt assert "onboard" not in receipt assert expected_hash not in receipt assert private_email not in caplog.text assert private_phone not in caplog.text assert raw.decode() not in caplog.text # The restricted audit payload remains the durable recovery source. audit = await db_session.scalar( select(AuditLog).where( AuditLog.action == "hr.successfactors_webhook.receive" ) ) assert audit is not None assert audit.detail["events"][0]["personal_email"] == private_email assert audit.detail["events"][0]["mobile_phone"] == private_phone @pytest.mark.asyncio async def test_successfactors_webhook_body_read_error_does_not_log_exception_pii( caplog, ): """A body-read exception can itself echo request data; log fixed sentinels.""" from app.domains.hr.router import _log_sf_webhook_receipt from app.domains.hr.schemas import SuccessFactorsWebhookRequest private_email = "exception-pii-2271@example.net" class BrokenBodyRequest: class state: # noqa: N801 — mirrors Starlette's request.state shape trace_id = "trace-2271" async def body(self): raise RuntimeError(f"failed to read {private_email}") body = SuccessFactorsWebhookRequest.model_validate({ "events": [{ "event_type": "onboard", "user_id": "P02271", "effective_date": "2026-08-01", "personalEmail": private_email, }], }) caplog.set_level(logging.INFO, logger="app.domains.hr.router") await _log_sf_webhook_receipt( BrokenBodyRequest(), # type: ignore[arg-type] slug="primary", body=body, ) assert "HRIS_WEBHOOK_RECEIVED" in caplog.text assert "body_bytes=" in caplog.text assert "body_sha256=" in caplog.text assert "body_bytes=-1" not in caplog.text assert "body_sha256=unavailable" not in caplog.text assert private_email not in caplog.text assert "failed to read" not in caplog.text @pytest.mark.asyncio async def test_successfactors_webhook_persists_person_and_lifecycle_event( client, db_session, monkeypatch, ): async def _fake_get_users(self, user_ids): assert user_ids == ["P001"] return [ { "userId": "P001", "displayName": "Webhook Hire", "email": "hire@example.com", "empId": "EMP-H001", "status": "active", "lastModifiedDateTime": "2026-03-12T00:00:00+00:00", } ] async def _fake_get_jobs_for_users(self, user_ids): assert user_ids == ["P001"] return [ { "userId": "P001", "startDate": "2026-03-12", "event": "1722", "eventReason": "A01", "emplStatus": "2187", "lastModifiedDateTime": "2026-03-12T00:00:00+00:00", } ] monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.get_users", _fake_get_users, ) monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.get_jobs_for_users", _fake_get_jobs_for_users, ) _stub_sf_config(monkeypatch, webhook_secret="expected-token") # The webhook now hands the apply to a background task on its own # session (fast-ACK, 504 fix). Exercise the apply logic directly on # the test session — the deterministic equivalent of what # ``run_webhook_events_in_background`` runs. The endpoint's fast-ACK + # dispatch contract is covered by # ``test_successfactors_webhook_fast_acks_and_audits_receipt``. from app.domains.hr.schemas import SuccessFactorsWebhookEvent from app.domains.hr.service import SuccessFactorsLifecycleService service = SuccessFactorsLifecycleService(db_session) summary = await service.process_webhook_events([ SuccessFactorsWebhookEvent.model_validate({ "event_type": "onboard", "user_id": "P001", "person_id_external": "EMP-H001", "effective_date": "2026-03-12", }) ]) assert summary.events_created == 1 assert summary.hr_employees_upserted == 0 person = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P001") ) assert person is not None assert person.employee_no == "EMP-H001" assert person.hire_date == date(2026, 3, 12) lifecycle_event = await db_session.scalar(select(HrLifecycleEvent)) assert lifecycle_event is not None assert lifecycle_event.person_id == person.id assert lifecycle_event.event_type == "onboard" assert lifecycle_event.detail["person_id_external"] == "EMP-H001" @pytest.mark.asyncio async def test_list_offboarding_cases_returns_last_day_and_unresolved_assets( client, admin_user, db_session, ): admin, token = admin_user user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="L100", employee_no="EMP-L100", work_email="leaver@example.com", display_name="Leaver User", employment_status="leaver_pending", termination_date=date(2026, 3, 20), manager_person_id=admin.id, ) db_session.add(user) await db_session.flush() db_session.add( HrLifecycleEvent( person_id=user.id, event_type="offboard", effective_date=date(2026, 3, 20), detail={"event_reason": "D01"}, ) ) db_session.add( AssetItem( asset_tag="AT-LEAVER-001", category="laptop", status="assigned", assigned_to=user.id, asset_name="MacBook Pro", custom_fields={"offboarding": {"status": "pending"}}, ) ) await db_session.commit() response = await client.get( "/api/v1/hr/offboarding-cases", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 payload = response.json()["data"] assert len(payload) == 1 assert payload[0]["user_id"] == str(user.id) assert payload[0]["last_day"] == "2026-03-20" assert payload[0]["unresolved_asset_count"] == 1 @pytest.mark.asyncio async def test_update_offboarding_asset_status_updates_asset_custom_fields( client, admin_user, db_session, ): _, token = admin_user user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="L101", employee_no="EMP-L101", work_email="leaver2@example.com", display_name="Leaver Two", employment_status="leaver_pending", termination_date=date(2026, 3, 21), ) db_session.add(user) await db_session.flush() asset = AssetItem( id=uuid4(), asset_tag="AT-LEAVER-002", category="laptop", status="assigned", assigned_to=user.id, asset_name="ThinkPad", custom_fields={"offboarding": {"status": "pending"}}, ) db_session.add(asset) await db_session.commit() response = await client.patch( f"/api/v1/hr/offboarding-cases/{user.id}/assets/{asset.id}", headers={"Authorization": f"Bearer {token}"}, json={"status": "returned", "note": "Laptop collected by IT"}, ) assert response.status_code == 200 updated = await db_session.scalar(select(AssetItem).where(AssetItem.id == asset.id)) assert updated is not None assert updated.custom_fields["offboarding"]["status"] == "returned" assert updated.custom_fields["offboarding"]["note"] == "Laptop collected by IT" @pytest.mark.asyncio async def test_complete_offboarding_marks_user_left( client, admin_user, db_session, ): admin, token = admin_user user = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="L102", employee_no="EMP-L102", work_email="leaver3@example.com", display_name="Leaver Three", employment_status="leaver_pending", termination_date=date(2026, 3, 22), ) db_session.add(user) await db_session.flush() lifecycle_event = HrLifecycleEvent( person_id=user.id, event_type="offboard", effective_date=date(2026, 3, 22), detail={"event_reason": "D01"}, ) db_session.add(lifecycle_event) await db_session.commit() response = await client.post( f"/api/v1/hr/offboarding-cases/{user.id}/complete", headers={"Authorization": f"Bearer {token}"}, json={"note": "All assets handled manually"}, ) assert response.status_code == 200 refreshed_user = await db_session.scalar(select(IdentityPerson).where(IdentityPerson.id == user.id)) refreshed_event = await db_session.scalar(select(HrLifecycleEvent).where(HrLifecycleEvent.id == lifecycle_event.id)) assert refreshed_user is not None assert refreshed_user.employment_status == "left" assert refreshed_event is not None assert refreshed_event.detail["completion"]["completed_by"] == str(admin.id) assert refreshed_event.detail["completion"]["note"] == "All assets handled manually" @pytest.mark.asyncio async def test_webhook_offboard_with_termination_before_hire_is_ignored( db_session, monkeypatch, ): """Regression (2026-07-16 review): the impossible-state invariant (termination_date < hire_date) must hold on the WEBHOOK apply path too — it bypasses ``_stage_event`` and lands directly in ``_apply_event`` → ``_upsert_person``. A termination webhook whose effective_date precedes the employee's hire_date must NOT flip them to a leaver.""" from app.domains.hr.schemas import SuccessFactorsWebhookEvent from app.domains.hr.service import SuccessFactorsLifecycleService _stub_sf_config(monkeypatch, webhook_secret="expected-token") # 1) Onboard an active employee, hire_date 2026-03-12. async def _get_users_active(self, user_ids): return [{ "userId": "PWH1", "displayName": "Webhook Person", "email": "pwh1@example.com", "empId": "PWH1", "status": "active", "lastModifiedDateTime": "2026-03-12T00:00:00+00:00", }] async def _get_jobs(self, user_ids): return [{ "userId": "PWH1", "startDate": "2026-03-12", "lastModifiedDateTime": "2026-03-12T00:00:00+00:00", }] monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.get_users", _get_users_active, ) monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.get_jobs_for_users", _get_jobs, ) service = SuccessFactorsLifecycleService(db_session) await service.process_webhook_events([ SuccessFactorsWebhookEvent.model_validate({ "event_type": "onboard", "user_id": "PWH1", "person_id_external": "PWH1", "effective_date": "2026-03-12", }) ]) person = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.hris_person_id == "PWH1") ) assert person is not None and person.hire_date == date(2026, 3, 12) assert person.employment_status == "active" # 2) Offboard webhook whose effective_date PRECEDES the hire_date — an # impossible leaver. SF now reports the user forfeited ('f'). async def _get_users_forfeited(self, user_ids): return [{ "userId": "PWH1", "displayName": "Webhook Person", "email": "pwh1@example.com", "empId": "PWH1", "status": "f", "lastModifiedDateTime": "2026-03-01T00:00:00+00:00", }] monkeypatch.setattr( "app.identity.successfactors_client.SuccessFactorsClient.get_users", _get_users_forfeited, ) await service.process_webhook_events([ SuccessFactorsWebhookEvent.model_validate({ "event_type": "offboard", "user_id": "PWH1", "person_id_external": "PWH1", "effective_date": "2026-03-01", # BEFORE hire 2026-03-12 }) ]) refreshed = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.hris_person_id == "PWH1") ) assert refreshed.employment_status == "active", ( "An offboard whose termination_date precedes hire_date is impossible " f"and must be ignored on the webhook path; got " f"{refreshed.employment_status!r}" ) assert refreshed.termination_date is None, ( f"termination_date must stay unset; got {refreshed.termination_date}" )