diff --git a/tests/test_checker.py b/tests/test_checker.py new file mode 100644 index 0000000..8ad3631 --- /dev/null +++ b/tests/test_checker.py @@ -0,0 +1,108 @@ +import asyncio +import json +import logging +import socket + +import pytest + +from app.checker import EndpointChecker, redact_url +from app.models import State +from app.settings import Settings + + +@pytest.fixture +def checker() -> EndpointChecker: + return EndpointChecker(Settings(request_timeout_seconds=1, connect_timeout_seconds=1, + max_redirects=2)) + + +@pytest.mark.asyncio +async def test_public_address_is_pinned_for_request(checker: EndpointChecker, monkeypatch) -> None: + async def resolve(_host: str, _port: int) -> list[str]: + return ["93.184.216.34"] + seen: list[str] = [] + async def request(_url: str, addresses: list[str]) -> tuple[int, None]: + seen.extend(addresses) + return 200, None + monkeypatch.setattr(checker, "_resolve", resolve) + monkeypatch.setattr(checker, "_request_once", request) + result = await checker.check("https://example.com/?token=secret") + assert result.state == State.UP + assert seen == ["93.184.216.34"] + assert "secret" not in (result.final_url or "") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.1.1", "::1", "192.0.2.1"]) +async def test_dns_rejects_non_global_answers(checker: EndpointChecker, monkeypatch, address: str) -> None: + loop = asyncio.get_running_loop() + def answers(*_args, **_kwargs): + family = socket.AF_INET6 if ":" in address else socket.AF_INET + return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (address, 443))] + monkeypatch.setattr(loop, "getaddrinfo", answers) + with pytest.raises(Exception, match="globally routable"): + await checker._resolve("attacker.invalid", 443) + + +@pytest.mark.asyncio +async def test_mixed_dns_answer_is_rejected(checker: EndpointChecker, monkeypatch) -> None: + loop = asyncio.get_running_loop() + def answers(*_args, **_kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 80)) + for ip in ("93.184.216.34", "127.0.0.1")] + monkeypatch.setattr(loop, "getaddrinfo", answers) + with pytest.raises(Exception): + await checker._resolve("rebinding.invalid", 80) + + +@pytest.mark.asyncio +async def test_redirect_target_is_resolved_and_blocked(checker: EndpointChecker, monkeypatch) -> None: + calls = 0 + async def resolve(host: str, _port: int) -> list[str]: + if host == "localhost": + from app.checker import SecurityError + raise SecurityError("destination is not globally routable") + return ["93.184.216.34"] + async def request(_url: str, _addresses: list[str]) -> tuple[int, str]: + nonlocal calls + calls += 1 + return 302, "http://localhost/admin?key=secret" + monkeypatch.setattr(checker, "_resolve", resolve) + monkeypatch.setattr(checker, "_request_once", request) + result = await checker.check("https://example.com") + assert result.state == State.ERROR + assert result.error == "blocked_destination" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_timeout_mapping(checker: EndpointChecker, monkeypatch) -> None: + async def resolve(_host: str, _port: int) -> list[str]: + return ["93.184.216.34"] + async def request(_url: str, _addresses: list[str]): + raise asyncio.TimeoutError + monkeypatch.setattr(checker, "_resolve", resolve) + monkeypatch.setattr(checker, "_request_once", request) + result = await checker.check("https://example.com") + assert result.state == State.ERROR and result.error == "timeout" + + +def test_redaction_removes_credentials_query_and_fragment() -> None: + value = redact_url("https://user:secret@example.com/path?token=abc&x=1#frag") + assert value == "https://example.com/path?REDACTED" + assert all(secret not in value for secret in ("user", "secret", "abc", "frag")) + + +@pytest.mark.asyncio +async def test_structured_logs_do_not_leak_query(checker: EndpointChecker, monkeypatch, caplog) -> None: + async def resolve(_host: str, _port: int) -> list[str]: + return ["93.184.216.34"] + async def request(_url: str, _addresses: list[str]) -> tuple[int, None]: + return 500, None + monkeypatch.setattr(checker, "_resolve", resolve) + monkeypatch.setattr(checker, "_request_once", request) + with caplog.at_level(logging.INFO, logger="endpoint_monitor.checker"): + result = await checker.check("https://example.com/a?api_key=supersecret") + assert result.state == State.DOWN + assert "supersecret" not in caplog.text + assert all("event" in json.loads(record.message) for record in caplog.records)