2026-07-17 23:20:34 +02:00
|
|
|
"""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 = """<?xml version="1.0"?>
|
|
|
|
|
<clientConfig version="1.1">
|
|
|
|
|
<emailProvider id="example.com">
|
|
|
|
|
<domain>example.com</domain>
|
|
|
|
|
<incomingServer type="imap">
|
|
|
|
|
<hostname>imap.example.com</hostname>
|
|
|
|
|
<port>993</port>
|
|
|
|
|
<socketType>SSL</socketType>
|
|
|
|
|
<authentication>password-cleartext</authentication>
|
|
|
|
|
<username>%EMAILADDRESS%</username>
|
|
|
|
|
</incomingServer>
|
|
|
|
|
<outgoingServer type="smtp">
|
|
|
|
|
<hostname>smtp.example.com</hostname>
|
|
|
|
|
<port>587</port>
|
|
|
|
|
<socketType>STARTTLS</socketType>
|
|
|
|
|
<authentication>password-cleartext</authentication>
|
|
|
|
|
<username>%EMAILADDRESS%</username>
|
|
|
|
|
</outgoingServer>
|
|
|
|
|
</emailProvider>
|
|
|
|
|
</clientConfig>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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."""
|
|
|
|
|
|
2026-10-14 22:08:06 +02:00
|
|
|
async def ok(_host: str) -> list[str]:
|
|
|
|
|
return ["93.184.216.34"]
|
|
|
|
|
|
|
|
|
|
async def unpinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
|
|
|
|
|
return url, {}, {} # respx matches on the host name, not the pinned IP
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
|
2026-10-14 22:08:06 +02:00
|
|
|
monkeypatch.setattr(ssrf, "_pinned", unpinned)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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("<clientConfig></clientConfig>") 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)
|