mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
fix: classify Auth0 IP range block (#1188)
## ℹ️ 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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user