"""Issue #250 (tg12): Docker bridge local-operator trust must be bound to
the frontend container's hostname, not the entire 172.16.0.0/12 range.

Previous behavior trusted ANY private-RFC1918 source IP on the bridge
when ``SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1``. On a shared
Docker host this granted local-operator privileges to any other
container that could route to the backend's bridge — far broader than
intended.

The fix narrows trust to source IPs that forward-resolve from one of the
configured frontend container hostnames (default: the compose service
name ``frontend`` plus the explicit ``container_name``
``shadowbroker-frontend``). Operators with renamed containers can list
the new names in ``SHADOWBROKER_TRUSTED_FRONTEND_HOSTS``.

These tests exercise the resolution helpers directly so that we don't
need a live Docker daemon to validate the contract.
"""
import socket
from unittest.mock import patch

import pytest


# ---------------------------------------------------------------------------
# _trusted_bridge_frontend_hostnames — env parsing
# ---------------------------------------------------------------------------

class TestTrustedHostnameParsing:
    def _fn(self):
        from auth import _trusted_bridge_frontend_hostnames
        return _trusted_bridge_frontend_hostnames

    def test_default_covers_compose_service_and_container_name(self):
        with patch.dict("os.environ", {}, clear=False):
            # Make sure the env var is not set so we exercise the default.
            import os
            os.environ.pop("SHADOWBROKER_TRUSTED_FRONTEND_HOSTS", None)
            assert self._fn()() == ["frontend", "shadowbroker-frontend"]

    def test_custom_list_via_env(self):
        with patch.dict(
            "os.environ",
            {"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": "my-ui,alt-frontend"},
        ):
            assert self._fn()() == ["my-ui", "alt-frontend"]

    def test_whitespace_trimmed(self):
        with patch.dict(
            "os.environ",
            {"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": "  my-ui , alt-frontend  "},
        ):
            assert self._fn()() == ["my-ui", "alt-frontend"]

    def test_empty_env_falls_back_to_default(self):
        # An empty string still falls back to the bundled defaults so a
        # misconfigured env var doesn't silently dismantle bridge trust.
        with patch.dict(
            "os.environ",
            {"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": ""},
        ):
            # Per docs: empty string sets the env var to "" so os.environ.get
            # returns "" — that string is parsed and yields []. We assert
            # that empty parse yields [] (caller fail-closes from there).
            assert self._fn()() == []


# ---------------------------------------------------------------------------
# _resolve_trusted_bridge_ips — DNS resolution with cache + fail-closed
# ---------------------------------------------------------------------------

class TestResolveTrustedBridgeIps:
    def setup_method(self):
        # Reset the module-level cache before each test so prior tests
        # don't bleed state across cases.
        from auth import _DOCKER_BRIDGE_TRUST_CACHE
        _DOCKER_BRIDGE_TRUST_CACHE["ips"] = frozenset()
        _DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0

    def test_resolves_configured_hostnames(self):
        from auth import _resolve_trusted_bridge_ips

        def fake_gethostbyname_ex(host):
            mapping = {
                "frontend": ("frontend", [], ["172.18.0.3"]),
                "shadowbroker-frontend": ("shadowbroker-frontend", [], ["172.18.0.3", "172.18.0.4"]),
            }
            if host not in mapping:
                raise socket.gaierror("no such host")
            return mapping[host]

        with patch("socket.gethostbyname_ex", side_effect=fake_gethostbyname_ex):
            ips = _resolve_trusted_bridge_ips()
        assert ips == frozenset({"172.18.0.3", "172.18.0.4"})

    def test_fail_closed_when_dns_returns_nothing(self):
        from auth import _resolve_trusted_bridge_ips

        def always_fail(host):
            raise socket.gaierror("no resolver")

        with patch("socket.gethostbyname_ex", side_effect=always_fail):
            ips = _resolve_trusted_bridge_ips()
        assert ips == frozenset()

    def test_partial_resolution_is_kept(self):
        """If one hostname resolves and another fails, we keep the
        successful one rather than discarding the whole set."""
        from auth import _resolve_trusted_bridge_ips

        def partial(host):
            if host == "frontend":
                return ("frontend", [], ["172.18.0.3"])
            raise socket.gaierror("missing")

        with patch("socket.gethostbyname_ex", side_effect=partial):
            ips = _resolve_trusted_bridge_ips()
        assert ips == frozenset({"172.18.0.3"})

    def test_cache_short_circuits_repeated_dns_calls(self):
        from auth import _resolve_trusted_bridge_ips

        call_count = {"n": 0}

        def counting(host):
            call_count["n"] += 1
            return ("frontend", [], ["172.18.0.3"])

        with patch("socket.gethostbyname_ex", side_effect=counting):
            _resolve_trusted_bridge_ips()
            calls_after_first = call_count["n"]
            _resolve_trusted_bridge_ips()
            _resolve_trusted_bridge_ips()
        # Second + third calls hit the cache, not the DNS stub.
        assert call_count["n"] == calls_after_first

    def test_cache_expires(self):
        from auth import _resolve_trusted_bridge_ips, _DOCKER_BRIDGE_TRUST_CACHE

        with patch("socket.gethostbyname_ex", return_value=("frontend", [], ["172.18.0.3"])):
            _resolve_trusted_bridge_ips()
        # Force expiry.
        _DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0
        with patch("socket.gethostbyname_ex", return_value=("frontend", [], ["172.18.0.9"])) as stub:
            ips = _resolve_trusted_bridge_ips()
            assert stub.called
        assert "172.18.0.9" in ips


# ---------------------------------------------------------------------------
# _is_docker_bridge_host — composite of the helpers above
# ---------------------------------------------------------------------------

class TestIsDockerBridgeHost:
    def setup_method(self):
        from auth import _DOCKER_BRIDGE_TRUST_CACHE
        _DOCKER_BRIDGE_TRUST_CACHE["ips"] = frozenset()
        _DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0

    def test_trusts_resolved_frontend_ip(self):
        from auth import _is_docker_bridge_host

        with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset({"172.18.0.3"})):
            assert _is_docker_bridge_host("172.18.0.3") is True

    def test_rejects_arbitrary_bridge_ip(self):
        """A rogue container on the same bridge but at a different IP
        must NOT be trusted, even though it falls in 172.16.0.0/12."""
        from auth import _is_docker_bridge_host

        with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset({"172.18.0.3"})):
            assert _is_docker_bridge_host("172.18.0.99") is False

    def test_rejects_public_ip_without_dns_work(self):
        """Public IPs skip DNS resolution entirely (perf + safety)."""
        from auth import _is_docker_bridge_host

        with patch("auth._resolve_trusted_bridge_ips") as stub:
            assert _is_docker_bridge_host("8.8.8.8") is False
            stub.assert_not_called()

    def test_rejects_non_ip_input(self):
        from auth import _is_docker_bridge_host

        assert _is_docker_bridge_host("") is False
        assert _is_docker_bridge_host("not-an-ip") is False
        assert _is_docker_bridge_host("frontend") is False

    def test_fails_closed_when_dns_returns_empty(self):
        """If Docker DNS can't resolve any frontend hostname, the bridge
        is not trusted — even for IPs that would have been trusted under
        the old 172.16.0.0/12 blanket policy."""
        from auth import _is_docker_bridge_host

        with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset()):
            assert _is_docker_bridge_host("172.18.0.3") is False
