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:
Jens
2026-07-01 07:59:25 +02:00
committed by GitHub
parent ce0c5fa5b5
commit f2da0355d5
2 changed files with 123 additions and 0 deletions

View File

@@ -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).

View File

@@ -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."""