"""Deactivate / upsert must not fail forever on terminal vendor states. Two distinct terminal conditions are covered: 1. **User already absent** — vendor answers the deactivate PATCH with a clean 404 (or a 400 + "user does not exist" detail). The goal of deactivate (user not active on the target) is already met → success. 2. **Email domain not allowed** — the recurring prod Navan failure. ``selva.ganesh@pingcap-c.com`` is out-of-scope for the Navan target (its filter is ``email_suffixes=["pingcap.com"]``), so the sweep tries to deactivate him. He DOES exist on Navan and is already ``active: false``, but Navan's tenant only allows the ``pingcap.com`` domain, so it rejects EVERY write — even a no-op ``active=false`` — with:: HTTP 400 invalidValue "896581f8-... :: -1: User email selva.ganesh@pingcap-c.com does not match any allowed company domains: [pingcap.com]" PR #528 guessed this message was "does not exist" from a truncated (``raw[:200]``) log and never matched the real wording, so the run stayed ``partial`` and retry_count climbed 9 → 22. The string below is captured from a live read-only prod call on 2026-06-08. """ from __future__ import annotations import json import pytest from app.domains.identity_sync.runners.scim import ( ScimRunner, _scim_says_domain_not_allowed, _scim_says_user_absent, ) def _runner() -> ScimRunner: return ScimRunner(base_url="https://navan.example/scim/v2", api_token="t") NAVAN_400_ABSENT = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "scimType": "invalidValue", "detail": ( "7139f892 :: -1: User email selva.ganesh@pingcap-c.com " "does not exist" ), } # Verbatim from prod (read-only PATCH active=false against an already # inactive user, 2026-06-08). Note the detail is duplicated by Navan. NAVAN_400_DOMAIN_NOT_ALLOWED = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "scimType": "invalidValue", "detail": ( "896581f8-03f1-4c8d-a9ba-a6438078ad1c :: -1: User email " "selva.ganesh@pingcap-c.com does not match any allowed company " "domains: [pingcap.com]; User email selva.ganesh@pingcap-c.com " "does not match any allowed company domains: [pingcap.com]" ), } class TestUserAbsentMarker: def test_navan_400_detail_matches(self): assert _scim_says_user_absent(NAVAN_400_ABSENT, "") is True def test_plain_not_found_text_matches(self): assert _scim_says_user_absent(None, "User not found") is True def test_unrelated_400_does_not_match(self): body = {"detail": "Attribute 'region' has an invalid value"} assert _scim_says_user_absent(body, "") is False def test_empty_inputs_safe(self): assert _scim_says_user_absent(None, "") is False def test_domain_rejection_is_not_treated_as_absent(self): # The domain rejection is a DIFFERENT terminal condition — the # user is present, just unmanageable. It must not be mistaken # for "absent" (which would claim external_id is gone). assert _scim_says_user_absent( NAVAN_400_DOMAIN_NOT_ALLOWED, "", ) is False class TestDomainNotAllowedMarker: def test_real_navan_message_matches(self): assert _scim_says_domain_not_allowed( NAVAN_400_DOMAIN_NOT_ALLOWED, "", ) is True def test_matches_via_raw_when_detail_missing(self): raw = json.dumps(NAVAN_400_DOMAIN_NOT_ALLOWED) assert _scim_says_domain_not_allowed(None, raw) is True def test_unrelated_400_does_not_match(self): body = {"detail": "Attribute 'region' has an invalid value"} assert _scim_says_domain_not_allowed(body, "") is False def test_empty_inputs_safe(self): assert _scim_says_domain_not_allowed(None, "") is False class TestDeactivateIdempotent: @pytest.mark.asyncio async def test_navan_400_user_absent_is_success(self, monkeypatch): runner = _runner() async def fake_request(method, path, *, json_body=None, **kw): assert method == "PATCH" return 400, NAVAN_400_ABSENT, json.dumps(NAVAN_400_ABSENT) monkeypatch.setattr(runner, "_request", fake_request) out = await runner.deactivate( person_email="selva.ganesh@pingcap-c.com", prior_external_id="1170521d-3e85-481c-baf5-56db5f65e6e4", ) assert out.status == "deactivated" assert out.error is None @pytest.mark.asyncio async def test_navan_400_domain_not_allowed_is_terminal_success( self, monkeypatch, ): # THE regression: an already-inactive, out-of-scope user whose # email domain Navan rejects must resolve as ``deactivated`` so # the run stops going ``partial`` every night. runner = _runner() async def fake_request(method, path, *, json_body=None, **kw): assert method == "PATCH" return ( 400, NAVAN_400_DOMAIN_NOT_ALLOWED, json.dumps(NAVAN_400_DOMAIN_NOT_ALLOWED), ) monkeypatch.setattr(runner, "_request", fake_request) out = await runner.deactivate( person_email="selva.ganesh@pingcap-c.com", prior_external_id="1170521d-3e85-481c-baf5-56db5f65e6e4", ) assert out.status == "deactivated" assert out.error is None @pytest.mark.asyncio async def test_clean_404_still_success(self, monkeypatch): runner = _runner() async def fake_request(method, path, *, json_body=None, **kw): return 404, None, "not found" monkeypatch.setattr(runner, "_request", fake_request) out = await runner.deactivate( person_email="gone@pingcap.com", prior_external_id="abc", ) assert out.status == "deactivated" @pytest.mark.asyncio async def test_genuine_400_still_fails(self, monkeypatch): runner = _runner() body = {"detail": "rate limited, slow down", "status": "400"} async def fake_request(method, path, *, json_body=None, **kw): return 400, body, json.dumps(body) monkeypatch.setattr(runner, "_request", fake_request) out = await runner.deactivate( person_email="real@pingcap.com", prior_external_id="xyz", ) assert out.status == "failed" assert out.error is not None class TestUpsertDomainNotAllowed: @pytest.mark.asyncio async def test_put_domain_rejection_is_skipped_not_failed( self, monkeypatch, ): # In-scope disallowed-domain user on the PUT (known-id) path: # must skip (visible, non-retried), not fail forever. runner = _runner() async def fake_request(method, path, *, json_body=None, **kw): assert method == "PUT" return ( 400, NAVAN_400_DOMAIN_NOT_ALLOWED, json.dumps(NAVAN_400_DOMAIN_NOT_ALLOWED), ) monkeypatch.setattr(runner, "_request", fake_request) out = await runner.upsert( person_email="selva.ganesh@pingcap-c.com", payload={"name": {"familyName": "Gopal", "givenName": "Selva"}}, prior_external_id="1170521d-3e85-481c-baf5-56db5f65e6e4", ) assert out.status == "skipped" assert out.error and "allow-list" in out.error @pytest.mark.asyncio async def test_post_domain_rejection_is_skipped_not_failed( self, monkeypatch, ): # First-sync POST path (no prior id). runner = _runner() async def fake_request(method, path, *, json_body=None, **kw): assert method == "POST" return ( 400, NAVAN_400_DOMAIN_NOT_ALLOWED, json.dumps(NAVAN_400_DOMAIN_NOT_ALLOWED), ) monkeypatch.setattr(runner, "_request", fake_request) out = await runner.upsert( person_email="selva.ganesh@pingcap-c.com", payload={"name": {"familyName": "Gopal", "givenName": "Selva"}}, prior_external_id=None, ) assert out.status == "skipped" assert out.error and "allow-list" in out.error