From f2da0355d5a489d547b1589c78c86bbc68ce3e63 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:59:25 +0200 Subject: [PATCH] fix: classify Auth0 IP range block (#1188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description - Link to the related issue(s): Issue #1120 - Classifies Kleinanzeigen IP-range block pages explicitly when Auth0 post-submit verification remains on the password URL. - This makes the failure actionable without changing login/captcha behavior or attempting to bypass the block. ## 📋 Changes Summary - Add `IP_RANGE_BLOCKED` to post-submit Auth0 state classification when the password-page DOM contains the Kleinanzeigen IP-range block heading. - Keep existing classification facts such as `STILL_ON_PASSWORD_PAGE`, so combined states remain visible. - Keep detection scoped to the Auth0 password page and best-effort. - Add focused unit tests for detection, gating, resilience, and coexistence with Auth0 inline errors. - No new dependencies, configuration changes, schema changes, or translation updates. ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [x] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist Before requesting a review, confirm the following: - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test tests/unit/test_login_flow.py tests/unit/test_translations.py`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. ## Summary by CodeRabbit * **Bug Fixes** * Improved sign-in diagnostics to detect when login is stalled on the password step due to an IP-range restriction. * Added support for preserving existing login-state facts when the IP-block page probe fails, and for correctly combining IP-block signals with inline error states. --- src/kleinanzeigen_bot/login_flow.py | 23 +++++++ tests/unit/test_login_flow.py | 100 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/src/kleinanzeigen_bot/login_flow.py b/src/kleinanzeigen_bot/login_flow.py index 6474446..010edcd 100644 --- a/src/kleinanzeigen_bot/login_flow.py +++ b/src/kleinanzeigen_bot/login_flow.py @@ -64,6 +64,11 @@ _AUTH0_POST_SUBMIT_ERROR_SELECTORS:Final[list[tuple[By, str]]] = [ (By.CSS_SELECTOR, "#error-element-password"), ] +# Distinctive German heading text that appears inside div#error when Kleinanzeigen +# returns its IP-range block page instead of the normal password step result. +# Referenced by _classify_post_submit_state() to emit IP_RANGE_BLOCKED. +_IP_RANGE_BLOCKED_TEXT:Final[str] = "IP-Bereich vorübergehend gesperrt" + async def _click_auth0_submit(web:WebScrapingMixin, *, timeout:float | None = None) -> None: """Click the visible Auth0 submit button, avoiding the hidden form-submit button.""" @@ -324,6 +329,7 @@ async def _classify_post_submit_state(web:WebScrapingMixin) -> str: Returns coarse labels only (no raw page text): "STILL_ON_PASSWORD_PAGE" "STILL_ON_PASSWORD_PAGE + AUTH0_INLINE_ERROR" + "STILL_ON_PASSWORD_PAGE + IP_RANGE_BLOCKED" "MFA_DETECTED (ONE_TIME_CODE_INPUT)" "MFA_DETECTED (SMS_VERIFICATION)" "UNKNOWN (url=https://kleinanzeigen.de/u/login/password)" @@ -362,6 +368,23 @@ async def _classify_post_submit_state(web:WebScrapingMixin) -> str: facts.append("AUTH0_INLINE_ERROR") break + # 2b) IP range block detection — gated to password page only. + # When Kleinanzeigen returns its IP-range block page (div#error with + # German text) instead of proceeding after password submit, the URL + # stays on /u/login/password but the DOM is the block page. Probe + # for the distinctive heading text. + if is_password_page: + try: + ip_block_element = await web.web_probe( + By.TEXT, + _IP_RANGE_BLOCKED_TEXT, + timeout = quick_dom, + ) + if ip_block_element is not None: + facts.append("IP_RANGE_BLOCKED") + except Exception: # noqa: S110, BLE001 + pass + # 3) High-confidence MFA/verification probes. # Gate: only run when URL is not a known valid Kleinanzeigen destination # (i.e. still on login/knotenpunkt or an Auth0 challenge page). diff --git a/tests/unit/test_login_flow.py b/tests/unit/test_login_flow.py index bb6a1bd..6829891 100644 --- a/tests/unit/test_login_flow.py +++ b/tests/unit/test_login_flow.py @@ -1141,6 +1141,106 @@ class TestClassifyPostSubmitState: assert "Invalid password" not in result assert " + " in result + # ------------------------------------------------------------------ # + # IP range block detection tests (issue #1120) + # ------------------------------------------------------------------ # + + @staticmethod + async def _ip_probe_return(sel_type:object, sel_value:object, **kwargs:object) -> MagicMock | None: + """Return a mock for the IP-block text probe, ``None`` for everything else.""" + if sel_type is By.TEXT and sel_value == login_flow._IP_RANGE_BLOCKED_TEXT: + return MagicMock(spec = Element) + return None + + @staticmethod + async def _ip_probe_raise(sel_type:object, sel_value:object, **kwargs:object) -> MagicMock | None: + """Raise RuntimeError on the IP-block text probe, ``None`` otherwise.""" + if sel_type is By.TEXT and sel_value == login_flow._IP_RANGE_BLOCKED_TEXT: + raise RuntimeError("boom") + return None + + @staticmethod + async def _ip_probe_with_auth0_alert(sel_type:object, sel_value:object, **kwargs:object) -> MagicMock | None: + """Return a mock for ``[role='alert']`` and the IP-block text; ``None`` otherwise.""" + if sel_type is By.CSS_SELECTOR and sel_value == "[role='alert']": + return MagicMock(spec = Element) + if sel_type is By.TEXT and sel_value == login_flow._IP_RANGE_BLOCKED_TEXT: + return MagicMock(spec = Element) + return None + + @pytest.mark.asyncio + async def test_classify_ip_range_blocked(self, test_bot:KleinanzeigenBot) -> None: + """Should report IP_RANGE_BLOCKED when IP-block heading text is present on password page.""" + with ( + patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/login/password"), + patch.object(test_bot, "timeout", return_value = 5.0), + patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe, + ): + mock_probe.side_effect = self._ip_probe_return + + result = await login_flow._classify_post_submit_state(test_bot) + + assert "STILL_ON_PASSWORD_PAGE" in result + assert "IP_RANGE_BLOCKED" in result + assert "AUTH0_INLINE_ERROR" not in result + + @pytest.mark.asyncio + async def test_classify_ip_range_blocked_probe_exception_preserves_facts( + self, test_bot:KleinanzeigenBot, + ) -> None: + """When IP-block probe raises, password-page fact is preserved.""" + with ( + patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/login/password"), + patch.object(test_bot, "timeout", return_value = 5.0), + patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe, + ): + mock_probe.side_effect = self._ip_probe_raise + + result = await login_flow._classify_post_submit_state(test_bot) + + assert "STILL_ON_PASSWORD_PAGE" in result + assert "IP_RANGE_BLOCKED" not in result + + @pytest.mark.asyncio + async def test_classify_ip_range_blocked_gated_from_non_password_page( + self, test_bot:KleinanzeigenBot, + ) -> None: + """IP_RANGE_BLOCKED must not be reported on non-password pages.""" + with ( + patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/meine-anzeigen"), + patch.object(test_bot, "timeout", return_value = 5.0), + patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe, + ): + # Error selectors are gated to password page; IP-block probe is + # also gated to password page; MFA probes gated to non-destination. + # meine-anzeigen is a valid destination, so no probes run at all. + mock_probe.side_effect = self._ip_probe_return + + result = await login_flow._classify_post_submit_state(test_bot) + + mock_probe.assert_not_awaited() + assert "IP_RANGE_BLOCKED" not in result + assert result.startswith("UNKNOWN (url=") + + @pytest.mark.asyncio + async def test_classify_ip_range_blocked_with_auth0_error( + self, test_bot:KleinanzeigenBot, + ) -> None: + """Both AUTH0_INLINE_ERROR and IP_RANGE_BLOCKED can coexist on password page.""" + with ( + patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/login/password"), + patch.object(test_bot, "timeout", return_value = 5.0), + patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe, + patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = "Falsches Passwort"), + ): + mock_probe.side_effect = self._ip_probe_with_auth0_alert + + result = await login_flow._classify_post_submit_state(test_bot) + + assert "STILL_ON_PASSWORD_PAGE" in result + assert "AUTH0_INLINE_ERROR" in result + assert "IP_RANGE_BLOCKED" in result + @pytest.mark.asyncio async def test_classify_never_raises(self, test_bot:KleinanzeigenBot) -> None: """Should never raise, even when dependencies throw."""