mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
fix: classify Auth0 post-submit uncertainty (#1181)
## ℹ️ Description - Link to the related issue(s): Issue #1120 - Adds focused diagnostics/classification for the post-password-submit Auth0 uncertainty path. This keeps the existing login/captcha/identifier flow stable while making the new failure mode easier to classify from logs. ## 📋 Changes Summary - Classifies final post-submit uncertainty as still on the Auth0 password page, visible Auth0 error text, verification/MFA-like state, or unknown redirect/session inconclusive state. - Logs sanitized diagnostic context and raises clearer classified messages while preserving the existing timeout prefix. - Adds focused unit tests for the new classifications and snippet sanitization. - Updates German translations for new translatable messages. - No new dependencies or configuration changes. ### ⚙️ Type of Change - [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 standards. - [x] I have tested my changes and ensured that all tests pass (pdm run test). - [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 handling when transitioning after Auth0 password submission by using safer, sanitized diagnostic URLs. * Added clearer, classification-aware timeout messages when the next verification step can’t be identified. * Strengthened detection of post-Auth0 states, including inline Auth0 errors and MFA/verification prompts, with more graceful degradation on probing failures. * **Tests** * Expanded unit tests for post-submit state classification, URL sanitization, and improved timeout/error-message expectations, including resilience to probe/URL retrieval failures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -54,6 +54,16 @@ _AUTH0_CAPTCHA_CONTAINER_SELECTOR:Final[str] = (
|
|||||||
".g-recaptcha"
|
".g-recaptcha"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Post-submit diagnostic selectors — Auth0 inline error indicators on the
|
||||||
|
# password page. Probed by _classify_post_submit_state() when the password
|
||||||
|
# submit does not produce a valid post-Auth0 destination.
|
||||||
|
_AUTH0_POST_SUBMIT_ERROR_SELECTORS:Final[list[tuple[By, str]]] = [
|
||||||
|
(By.CSS_SELECTOR, "[role='alert']"),
|
||||||
|
(By.CSS_SELECTOR, ".ulp-input-error-message"),
|
||||||
|
(By.CSS_SELECTOR, ".ulp-error-info"),
|
||||||
|
(By.CSS_SELECTOR, "#error-element-password"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _click_auth0_submit(web:WebScrapingMixin, *, timeout:float | None = None) -> None:
|
async def _click_auth0_submit(web:WebScrapingMixin, *, timeout:float | None = None) -> None:
|
||||||
"""Click the visible Auth0 submit button, avoiding the hidden form-submit button."""
|
"""Click the visible Auth0 submit button, avoiding the hidden form-submit button."""
|
||||||
@@ -286,6 +296,118 @@ async def wait_for_auth0_password_step(web:WebScrapingMixin) -> None:
|
|||||||
raise AssertionError(_("Auth0 password step not reached (url=%s)") % current_url) from ex
|
raise AssertionError(_("Auth0 password step not reached (url=%s)") % current_url) from ex
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic_url(web:WebScrapingMixin) -> str:
|
||||||
|
"""Best-effort URL diagnostic; returns ``'unknown'`` on any exception.
|
||||||
|
|
||||||
|
Relies on ``current_page_url()`` which already strips query, fragment,
|
||||||
|
and userinfo from the raw browser URL.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return current_page_url(web)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _classify_post_submit_state(web:WebScrapingMixin) -> str:
|
||||||
|
"""Best-effort classification of page state after Auth0 password submit.
|
||||||
|
|
||||||
|
Probes URL, Auth0 inline error selectors, and high-confidence MFA/
|
||||||
|
verification signals (gated to non-destination URLs). Never raises —
|
||||||
|
all callers must treat the result as non-fatal diagnostic context.
|
||||||
|
|
||||||
|
Each probe/text-extraction failure uses a local ``try/except`` so the
|
||||||
|
signal is treated as absent without discarding already-detected facts.
|
||||||
|
|
||||||
|
Returns coarse labels only (no raw page text):
|
||||||
|
"STILL_ON_PASSWORD_PAGE"
|
||||||
|
"STILL_ON_PASSWORD_PAGE + AUTH0_INLINE_ERROR"
|
||||||
|
"MFA_DETECTED (ONE_TIME_CODE_INPUT)"
|
||||||
|
"MFA_DETECTED (SMS_VERIFICATION)"
|
||||||
|
"UNKNOWN (url=https://kleinanzeigen.de/u/login/password)"
|
||||||
|
"""
|
||||||
|
url = _diagnostic_url(web)
|
||||||
|
if url == "unknown":
|
||||||
|
return "UNKNOWN (classification_error)"
|
||||||
|
|
||||||
|
facts:list[str] = []
|
||||||
|
|
||||||
|
# 1) Password-page classification
|
||||||
|
is_password_page = "/u/login/password" in url
|
||||||
|
if is_password_page:
|
||||||
|
facts.append("STILL_ON_PASSWORD_PAGE")
|
||||||
|
|
||||||
|
try:
|
||||||
|
quick_dom = web.timeout("quick_dom")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
quick_dom = 5.0
|
||||||
|
|
||||||
|
# 2) Auth0 inline error selectors — gated to password page only because
|
||||||
|
# ``[role='alert']`` is a broad selector that could match unrelated UI.
|
||||||
|
# Appears as the coarse label ``AUTH0_INLINE_ERROR`` (no raw text).
|
||||||
|
if is_password_page:
|
||||||
|
for sel_type, sel_value in _AUTH0_POST_SUBMIT_ERROR_SELECTORS:
|
||||||
|
try:
|
||||||
|
element = await web.web_probe(sel_type, sel_value, timeout = quick_dom)
|
||||||
|
except Exception: # noqa: S112, BLE001
|
||||||
|
continue
|
||||||
|
if element is not None:
|
||||||
|
try:
|
||||||
|
text = await web.extract_visible_text(element)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
text = None
|
||||||
|
if text and text.strip():
|
||||||
|
facts.append("AUTH0_INLINE_ERROR")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
if not is_valid_post_auth0_destination(url):
|
||||||
|
mfa_facts:list[str] = []
|
||||||
|
|
||||||
|
# Locale-independent one-time code input field
|
||||||
|
try:
|
||||||
|
otc_element = await web.web_probe(
|
||||||
|
By.CSS_SELECTOR, "input[autocomplete='one-time-code']",
|
||||||
|
timeout = quick_dom,
|
||||||
|
)
|
||||||
|
if otc_element is not None:
|
||||||
|
mfa_facts.append("ONE_TIME_CODE_INPUT")
|
||||||
|
except Exception: # noqa: S110, BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
# German SMS verification prompt (same text as check_sms_verification)
|
||||||
|
try:
|
||||||
|
sms_element = await web.web_probe(
|
||||||
|
By.TEXT,
|
||||||
|
"Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer",
|
||||||
|
timeout = quick_dom,
|
||||||
|
)
|
||||||
|
if sms_element is not None:
|
||||||
|
mfa_facts.append("SMS_VERIFICATION")
|
||||||
|
except Exception: # noqa: S110, BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
# German email verification prompt (same text as check_email_verification)
|
||||||
|
try:
|
||||||
|
email_element = await web.web_probe(
|
||||||
|
By.TEXT,
|
||||||
|
"Um dein Konto zu schützen haben wir dir eine E-Mail geschickt",
|
||||||
|
timeout = quick_dom,
|
||||||
|
)
|
||||||
|
if email_element is not None:
|
||||||
|
mfa_facts.append("EMAIL_VERIFICATION")
|
||||||
|
except Exception: # noqa: S110, BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
if mfa_facts:
|
||||||
|
facts.append(f"MFA_DETECTED ({', '.join(mfa_facts)})")
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
return " + ".join(facts)
|
||||||
|
return f"UNKNOWN (url={url})"
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, username:str) -> None:
|
async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, username:str) -> None:
|
||||||
post_submit_timeout = web.timeout("login_detection")
|
post_submit_timeout = web.timeout("login_detection")
|
||||||
quick_dom_timeout = web.timeout("quick_dom")
|
quick_dom_timeout = web.timeout("quick_dom")
|
||||||
@@ -294,7 +416,7 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await web.web_await(
|
await web.web_await(
|
||||||
lambda: is_valid_post_auth0_destination(current_page_url(web)),
|
lambda: is_valid_post_auth0_destination(_diagnostic_url(web)),
|
||||||
timeout = post_submit_timeout,
|
timeout = post_submit_timeout,
|
||||||
timeout_error_message = f"Auth0 post-submit transition did not complete within {post_submit_timeout} seconds",
|
timeout_error_message = f"Auth0 post-submit transition did not complete within {post_submit_timeout} seconds",
|
||||||
apply_multiplier = False,
|
apply_multiplier = False,
|
||||||
@@ -313,6 +435,7 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
|
|||||||
return
|
return
|
||||||
|
|
||||||
LOG.debug("Auth0 post-submit verification remained inconclusive; applying bounded fallback pause")
|
LOG.debug("Auth0 post-submit verification remained inconclusive; applying bounded fallback pause")
|
||||||
|
LOG.debug("Post-submit state before fallback sleep: url=%s", _diagnostic_url(web))
|
||||||
await web.web_sleep(min_ms = fallback_min_ms, max_ms = fallback_max_ms)
|
await web.web_sleep(min_ms = fallback_min_ms, max_ms = fallback_max_ms)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -321,8 +444,12 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
|
|||||||
except (TimeoutError, asyncio.TimeoutError):
|
except (TimeoutError, asyncio.TimeoutError):
|
||||||
LOG.debug("Final post-submit login confirmation did not complete within %.1fs", quick_dom_timeout)
|
LOG.debug("Final post-submit login confirmation did not complete within %.1fs", quick_dom_timeout)
|
||||||
|
|
||||||
current_url = current_page_url(web)
|
classification = await _classify_post_submit_state(web)
|
||||||
raise TimeoutError(_("Auth0 post-submit verification remained inconclusive (url=%s)") % current_url)
|
LOG.warning("Auth0 post-submit verification remained inconclusive")
|
||||||
|
raise TimeoutError(
|
||||||
|
_("Auth0 post-submit verification remained inconclusive: %s (url=%s)")
|
||||||
|
% (classification, _diagnostic_url(web))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ kleinanzeigen_bot/login_flow.py:
|
|||||||
"Auth0 password step not reached (url=%s)": "Auth0-Passwortschritt nicht erreicht (URL=%s)"
|
"Auth0 password step not reached (url=%s)": "Auth0-Passwortschritt nicht erreicht (URL=%s)"
|
||||||
|
|
||||||
wait_for_post_auth0_submit_transition:
|
wait_for_post_auth0_submit_transition:
|
||||||
"Auth0 post-submit verification remained inconclusive (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar (URL=%s)"
|
"Auth0 post-submit verification remained inconclusive": "Auth0-Verifikation nach Absenden blieb unklar"
|
||||||
|
"Auth0 post-submit verification remained inconclusive: %s (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar: %s (URL=%s)"
|
||||||
|
|
||||||
fill_login_data_and_send:
|
fill_login_data_and_send:
|
||||||
"Logging in...": "Anmeldung..."
|
"Logging in...": "Anmeldung..."
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
@@ -622,16 +624,26 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||||
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
|
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||||
|
patch(
|
||||||
|
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
|
||||||
|
new_callable = AsyncMock,
|
||||||
|
return_value = "UNKNOWN (url=unknown)",
|
||||||
|
) as mock_classify,
|
||||||
|
pytest.raises(TimeoutError) as exc_info,
|
||||||
):
|
):
|
||||||
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
|
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
|
||||||
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
|
|
||||||
|
|
||||||
mock_wait.assert_awaited_once()
|
# The classified state must surface in the exception message
|
||||||
assert mock_is_logged_in.await_count == 2
|
assert "Auth0 post-submit verification remained inconclusive" in str(exc_info.value)
|
||||||
mock_sleep.assert_awaited_once()
|
assert "UNKNOWN (url=unknown)" in str(exc_info.value)
|
||||||
assert mock_sleep.await_args is not None
|
|
||||||
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
|
mock_wait.assert_awaited_once()
|
||||||
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
|
assert mock_is_logged_in.await_count == 2
|
||||||
|
mock_sleep.assert_awaited_once()
|
||||||
|
assert mock_sleep.await_args is not None
|
||||||
|
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
|
||||||
|
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
|
||||||
|
mock_classify.assert_awaited_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_clicks_found_element(self, test_bot:KleinanzeigenBot) -> None:
|
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_clicks_found_element(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
@@ -969,3 +981,318 @@ class TestKleinanzeigenBotAuthentication:
|
|||||||
assert mock_submit.await_count == 2 # first submit + final attempt
|
assert mock_submit.await_count == 2 # first submit + final attempt
|
||||||
mock_ainput.assert_awaited_once() # prompt was shown
|
mock_ainput.assert_awaited_once() # prompt was shown
|
||||||
mock_sleep.assert_awaited() # grace period sleep happened
|
mock_sleep.assert_awaited() # grace period sleep happened
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyPostSubmitState:
|
||||||
|
"""Tests for _classify_post_submit_state()."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_still_on_password_page(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should classify STILL_ON_PASSWORD_PAGE when URL contains /u/login/password."""
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
|
||||||
|
patch.object(test_bot, "timeout", return_value = 5.0),
|
||||||
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
||||||
|
):
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_auth0_error_selector_alert(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should report AUTH0_INLINE_ERROR when [role='alert'] element has visible text."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://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"),
|
||||||
|
):
|
||||||
|
# Return the mock element only for the FIRST probe ([role='alert'])
|
||||||
|
mock_probe.side_effect = [mock_element, None, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert "AUTH0_INLINE_ERROR" in result
|
||||||
|
# Coarse labels only — no raw text snippets
|
||||||
|
assert "Falsches Passwort" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_auth0_error_selector_error_element_password(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should report AUTH0_INLINE_ERROR when #error-element-password has visible text."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://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 = "Password is required"),
|
||||||
|
):
|
||||||
|
# Error probes 1-3 miss, 4th (#error-element-password) hits.
|
||||||
|
# MFA probes (calls 5-7) all miss.
|
||||||
|
mock_probe.side_effect = [None, None, None, mock_element, None, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert "AUTH0_INLINE_ERROR" in result
|
||||||
|
assert "Password is required" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_mfa_one_time_code_input(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should report MFA_DETECTED when input[autocomplete='one-time-code'] is present."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa"),
|
||||||
|
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 only, so only MFA
|
||||||
|
# probes run. One-time-code probe (1st) hits; SMS and email miss.
|
||||||
|
mock_probe.side_effect = [mock_element, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "MFA_DETECTED" in result
|
||||||
|
assert "ONE_TIME_CODE_INPUT" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_mfa_sms_prompt(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should report SMS_VERIFICATION when German SMS prompt text is present."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa-sms"),
|
||||||
|
patch.object(test_bot, "timeout", return_value = 5.0),
|
||||||
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
|
||||||
|
):
|
||||||
|
# Error selectors gated to password page. One-time-code probe (1st)
|
||||||
|
# misses; SMS text probe (2nd) hits; email probe (3rd) misses.
|
||||||
|
mock_probe.side_effect = [None, mock_element, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "MFA_DETECTED" in result
|
||||||
|
assert "SMS_VERIFICATION" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_unknown_fallback(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should return UNKNOWN when no probe matches and URL is non-password."""
|
||||||
|
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, return_value = None),
|
||||||
|
):
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert result.startswith("UNKNOWN (url=")
|
||||||
|
assert "kleinanzeigen.de/meine-anzeigen" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_combined_password_page_and_error(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should preserve multiple facts: URL classification + AUTH0_INLINE_ERROR."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://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 = "Invalid password"),
|
||||||
|
):
|
||||||
|
mock_probe.side_effect = [mock_element, None, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert "AUTH0_INLINE_ERROR" in result
|
||||||
|
assert "Invalid password" not in result
|
||||||
|
assert " + " in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_never_raises(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should never raise, even when dependencies throw."""
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", side_effect = RuntimeError("boom")),
|
||||||
|
):
|
||||||
|
# Must not propagate the exception
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert result == "UNKNOWN (classification_error)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# MFA email verification test
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_mfa_email_prompt(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Should report EMAIL_VERIFICATION when German email prompt text is present."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa-email"),
|
||||||
|
patch.object(test_bot, "timeout", return_value = 5.0),
|
||||||
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
|
||||||
|
):
|
||||||
|
# Error selectors gated to password page. OTC probe (1st) misses,
|
||||||
|
# SMS probe (2nd) misses, email prompt probe (3rd) hits.
|
||||||
|
mock_probe.side_effect = [None, None, mock_element]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "MFA_DETECTED" in result
|
||||||
|
assert "EMAIL_VERIFICATION" in result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Auth0 error text edge cases
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_auth0_error_blank_text_not_reported(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""Auth0 error element with whitespace-only text should not produce AUTH0_INLINE_ERROR."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://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 = " \n "),
|
||||||
|
):
|
||||||
|
mock_probe.side_effect = [mock_element, None, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert "AUTH0_INLINE_ERROR" not in result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Behavior tests — probe resilience and selector gating
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_probe_exception_preserves_facts(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""When an error probe raises, password-page fact is preserved."""
|
||||||
|
with (
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://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,
|
||||||
|
):
|
||||||
|
# First error probe raises; remaining error probes miss.
|
||||||
|
# MFA probes all miss.
|
||||||
|
mock_probe.side_effect = [RuntimeError("boom"), None, None, None, None, None, None]
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in result
|
||||||
|
assert "AUTH0_INLINE_ERROR" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classify_role_alert_gated_from_non_password_page(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
|
"""[role='alert'] on non-password URL must not produce AUTH0_INLINE_ERROR."""
|
||||||
|
mock_element = MagicMock(spec = Element)
|
||||||
|
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 only, so even though
|
||||||
|
# mock_element is returned for any probe, no error label appears.
|
||||||
|
# MFA probes are gated to non-destination URLs — meine-anzeigen is
|
||||||
|
# a valid destination, so no MFA probes run either.
|
||||||
|
mock_probe.return_value = mock_element
|
||||||
|
|
||||||
|
result = await login_flow._classify_post_submit_state(test_bot)
|
||||||
|
|
||||||
|
# Verify no probes ran at all — both error and MFA probes are gated
|
||||||
|
mock_probe.assert_not_awaited()
|
||||||
|
assert "AUTH0_INLINE_ERROR" not in result
|
||||||
|
assert result.startswith("UNKNOWN (url=")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_for_post_auth0_submit_transition_safe_diagnostics(
|
||||||
|
self, test_bot:KleinanzeigenBot,
|
||||||
|
) -> None:
|
||||||
|
"""TimeoutError uses coarse labels and sanitised URL."""
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]),
|
||||||
|
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
|
||||||
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch(
|
||||||
|
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
|
||||||
|
new_callable = AsyncMock,
|
||||||
|
return_value = "STILL_ON_PASSWORD_PAGE + AUTH0_INLINE_ERROR",
|
||||||
|
),
|
||||||
|
patch("kleinanzeigen_bot.login_flow._diagnostic_url", return_value = "https://kleinanzeigen.de/u/login/password"),
|
||||||
|
pytest.raises(TimeoutError) as exc_info,
|
||||||
|
):
|
||||||
|
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
|
||||||
|
|
||||||
|
# Exception carries coarse classification and sanitised URL — no raw text
|
||||||
|
assert "STILL_ON_PASSWORD_PAGE" in str(exc_info.value)
|
||||||
|
assert "AUTH0_INLINE_ERROR" in str(exc_info.value)
|
||||||
|
assert "kleinanzeigen.de/u/login/password" in str(exc_info.value)
|
||||||
|
assert "Auth0 post-submit verification remained inconclusive" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_current_page_url_strips_userinfo(
|
||||||
|
self, test_bot:KleinanzeigenBot,
|
||||||
|
) -> None:
|
||||||
|
"""current_page_url strips userinfo, query, and fragment from URLs."""
|
||||||
|
# Set up a page with a raw URL containing credentials and query/fragment
|
||||||
|
test_bot.page = AsyncMock()
|
||||||
|
test_bot.page.url = "https://user:secret@login.kleinanzeigen.de/u/login/password?state=abc&code=secret#frag"
|
||||||
|
|
||||||
|
result = current_page_url(test_bot)
|
||||||
|
|
||||||
|
# Sensitive parts must be removed
|
||||||
|
assert "user:" not in result
|
||||||
|
assert "secret" not in result
|
||||||
|
assert "state=" not in result
|
||||||
|
assert "code=" not in result
|
||||||
|
assert "#frag" not in result
|
||||||
|
|
||||||
|
# Sanitised path and host must be preserved
|
||||||
|
assert "login.kleinanzeigen.de/u/login/password" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_for_post_auth0_submit_transition_url_failure(
|
||||||
|
self, test_bot:KleinanzeigenBot,
|
||||||
|
) -> None:
|
||||||
|
"""TimeoutError prefix preserved when URL retrieval fails."""
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]),
|
||||||
|
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
|
||||||
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch(
|
||||||
|
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
|
||||||
|
new_callable = AsyncMock,
|
||||||
|
return_value = "STILL_ON_PASSWORD_PAGE",
|
||||||
|
),
|
||||||
|
patch("kleinanzeigen_bot.login_flow._diagnostic_url", return_value = "unknown"),
|
||||||
|
pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"),
|
||||||
|
):
|
||||||
|
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_for_post_auth0_submit_transition_predicate_url_failure(
|
||||||
|
self, test_bot:KleinanzeigenBot,
|
||||||
|
) -> None:
|
||||||
|
"""current_page_url failure in predicate reaches classified TimeoutError."""
|
||||||
|
|
||||||
|
async def _call_predicate(
|
||||||
|
condition:Callable[[], Any] | None = None, *,
|
||||||
|
timeout:object = None, timeout_error_message:str = "",
|
||||||
|
apply_multiplier:bool = True,
|
||||||
|
) -> Any:
|
||||||
|
assert condition is not None
|
||||||
|
result = condition()
|
||||||
|
result = await result if inspect.isawaitable(result) else result
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
raise TimeoutError(timeout_error_message or "timed out")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = _call_predicate),
|
||||||
|
patch("kleinanzeigen_bot.login_flow.current_page_url", side_effect = RuntimeError("boom")),
|
||||||
|
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
|
||||||
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch("kleinanzeigen_bot.login_flow._classify_post_submit_state", new_callable = AsyncMock, return_value = "STILL_ON_PASSWORD_PAGE"),
|
||||||
|
pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"),
|
||||||
|
):
|
||||||
|
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
|
||||||
|
|||||||
Reference in New Issue
Block a user