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:
Jens
2026-06-27 00:50:04 +02:00
committed by GitHub
parent ccae35563a
commit 4acbc09fac
3 changed files with 467 additions and 12 deletions

View File

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

View File

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

View File

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