"""Autodiscovery resolver (SPEC ยง6.2), with respx-mocked HTTP and a DNS stub.""" from __future__ import annotations import httpx import pytest import respx from app.accounts import discovery, ssrf from app.accounts.discovery import discover, domain_of, parse_autoconfig_xml from app.accounts.types import ServerConfig AUTOCONFIG_XML = """ example.com imap.example.com 993 SSL password-cleartext %EMAILADDRESS% smtp.example.com 587 STARTTLS password-cleartext %EMAILADDRESS% """ @pytest.fixture(autouse=True) def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def] """Bypass the real DNS/public-IP check so respx can intercept the fetch.""" async def ok(_host: str) -> None: return None monkeypatch.setattr(ssrf, "assert_host_is_public", ok) # --- XML parsing ------------------------------------------------------------- def test_parse_autoconfig_xml() -> None: cand = parse_autoconfig_xml(AUTOCONFIG_XML) assert cand is not None assert cand.imap == ServerConfig("imap.example.com", 993, "ssl", "password") assert cand.smtp == ServerConfig("smtp.example.com", 587, "starttls", "password") def test_parse_autoconfig_xml_rejects_garbage() -> None: assert parse_autoconfig_xml("not xml <<<") is None assert parse_autoconfig_xml("") is None def test_domain_of() -> None: assert domain_of("Alice@Example.COM") == "example.com" # --- preset step ------------------------------------------------------------- async def test_preset_hit_short_circuits() -> None: result = await discover("someone@gmail.com") assert len(result.candidates) == 1 c = result.candidates[0] assert c.source == "preset" assert c.imap.host == "imap.gmail.com" assert c.oauth_provider == "google" assert result.steps[0].method == "preset" and result.steps[0].status == "found" async def test_preset_polish_provider() -> None: result = await discover("jan@wp.pl") assert result.candidates[0].imap.host == "imap.wp.pl" assert result.candidates[0].source == "preset" # --- autoconfig / ispdb via HTTP -------------------------------------------- @respx.mock async def test_autoconfig_found() -> None: respx.get(url__regex=r"https://autoconfig\.example\.com/mail/.*").mock( return_value=httpx.Response(200, text=AUTOCONFIG_XML) ) result = await discover("user@example.com") assert result.candidates[0].source == "autoconfig" assert result.candidates[0].imap.host == "imap.example.com" # preset step was recorded as not_found before autoconfig succeeded assert result.steps[0].status == "not_found" @respx.mock async def test_ispdb_found_after_autoconfig_misses() -> None: respx.get(url__regex=r"https://autoconfig\.example\.com/.*").mock( return_value=httpx.Response(404) ) respx.get(url__regex=r"https://example\.com/\.well-known/.*").mock( return_value=httpx.Response(404) ) respx.get(url__regex=r"https://autoconfig\.thunderbird\.net/v1\.1/example\.com").mock( return_value=httpx.Response(200, text=AUTOCONFIG_XML) ) result = await discover("user@example.com") assert result.candidates[0].source == "ispdb" methods = {s.method for s in result.steps} assert {"preset", "autoconfig", "ispdb"} <= methods # --- SRV via DNS stub -------------------------------------------------------- class _FakeSrv: def __init__(self, priority: int, weight: int, port: int, target: str) -> None: self.priority = priority self.weight = weight self.port = port self.target = target @respx.mock async def test_srv_found(monkeypatch) -> None: # type: ignore[no-untyped-def] # autoconfig + ispdb all miss respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) async def fake_srv_lookup(name: str, tls: str) -> ServerConfig | None: if name.startswith("_imaps._tcp"): return ServerConfig("imap.example.com", 993, "ssl") if name.startswith("_submissions._tcp"): return ServerConfig("smtp.example.com", 465, "ssl") return None monkeypatch.setattr(discovery, "_srv_lookup", fake_srv_lookup) result = await discover("user@example.com") assert result.candidates[0].source == "srv" assert result.candidates[0].imap.port == 993 # --- probe fallback ---------------------------------------------------------- @respx.mock async def test_probe_found_when_all_else_misses(monkeypatch) -> None: # type: ignore[no-untyped-def] respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) async def no_srv(_name: str, _tls: str) -> ServerConfig | None: return None async def fake_probe(host: str, port: int, tls: str, kind: str) -> ServerConfig | None: # only imap.example.com:993 and smtp.example.com:465 "answer" if (host, port) in {("imap.example.com", 993), ("smtp.example.com", 465)}: return ServerConfig(host, port, tls) return None monkeypatch.setattr(discovery, "_srv_lookup", no_srv) monkeypatch.setattr(discovery, "_probe_one", fake_probe) result = await discover("user@example.com") assert result.candidates[0].source == "probe" assert result.candidates[0].imap.host == "imap.example.com" assert result.candidates[0].smtp.host == "smtp.example.com" @respx.mock async def test_all_steps_fail_records_every_step(monkeypatch) -> None: # type: ignore[no-untyped-def] respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) async def no_srv(_name: str, _tls: str) -> ServerConfig | None: return None async def no_probe(_h: str, _p: int, _t: str, _k: str) -> ServerConfig | None: return None monkeypatch.setattr(discovery, "_srv_lookup", no_srv) monkeypatch.setattr(discovery, "_probe_one", no_probe) result = await discover("user@nowhere.invalid") assert result.candidates == [] methods = [s.method for s in result.steps] assert methods == ["preset", "autoconfig", "ispdb", "srv", "probe"] assert all(s.status in ("not_found", "error") for s in result.steps)