"""Issue #256 (tg12): per-peer HMAC secrets must defeat cross-peer
impersonation.

Before the fix, ALL peer-push HMACs were derived from the single
fleet-shared ``MESH_PEER_PUSH_SECRET``. The receiver could only prove
"this request was signed by someone who knows the fleet secret" — not
which peer signed it. Any peer that knew the secret could compute the
expected HMAC for any other peer's URL and impersonate that peer.

The fix introduces ``MESH_PEER_SECRETS``, a per-peer URL-to-secret map.
When a peer URL appears there:

- Only the listed per-peer secret is accepted for that URL.
- The global ``MESH_PEER_PUSH_SECRET`` is ignored for that specific URL.
- A peer that knows only the global secret (or a different peer's
  per-peer secret) cannot forge a request claiming to be that peer.

When a peer URL is NOT listed (the common case for single-peer installs
and for migration windows), the resolver falls back to the global
secret — preserving existing behavior with zero operator action.

These tests exercise ``resolve_peer_key_for_url`` directly so we cover
the security contract without spinning up a full mesh node.
"""
from __future__ import annotations

import hashlib
import hmac

import pytest


# ---------------------------------------------------------------------------
# _lookup_per_peer_secret — env parsing
# ---------------------------------------------------------------------------


class TestLookupPerPeerSecret:
    def setup_method(self):
        # Invalidate the parser cache so each test sees its own env state.
        from services.mesh import mesh_crypto

        mesh_crypto._PEER_SECRETS_CACHE = {}
        mesh_crypto._PEER_SECRETS_CACHE_RAW = ""

    def test_returns_empty_when_env_unset(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.delenv("MESH_PEER_SECRETS", raising=False)
        assert _lookup_per_peer_secret("https://peer.example") == ""

    def test_returns_empty_when_env_blank(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv("MESH_PEER_SECRETS", "")
        assert _lookup_per_peer_secret("https://peer.example") == ""

    def test_returns_per_peer_secret_for_listed_url(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-a.example=secretA,https://peer-b.example=secretB",
        )
        assert _lookup_per_peer_secret("https://peer-a.example") == "secretA"
        assert _lookup_per_peer_secret("https://peer-b.example") == "secretB"

    def test_returns_empty_for_url_not_listed(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-a.example=secretA",
        )
        assert _lookup_per_peer_secret("https://other.example") == ""

    def test_url_is_normalized_before_lookup(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        # Configure with a trailing slash + uppercase host. Lookup with
        # plain lowercase host. Both should normalize to the same key.
        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://Peer-A.Example/=secretA",
        )
        assert _lookup_per_peer_secret("https://peer-a.example") == "secretA"

    def test_whitespace_around_entries_is_stripped(self, monkeypatch):
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "  https://peer-a.example = secretA , https://peer-b.example=secretB  ",
        )
        assert _lookup_per_peer_secret("https://peer-a.example") == "secretA"
        assert _lookup_per_peer_secret("https://peer-b.example") == "secretB"

    def test_malformed_entries_are_skipped_not_raised(self, monkeypatch):
        """A garbled MESH_PEER_SECRETS value must NOT crash the resolver.
        Bad entries are silently dropped; well-formed entries still work.
        This is the "fail-forward, not loud" rule — a typo in operator
        config should not take the whole backend down."""
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "no_equals_sign,=missing_url,https://no.secret=,https://good.example=secretGood",
        )
        assert _lookup_per_peer_secret("https://good.example") == "secretGood"
        # The malformed ones produce no entry (and don't poison the cache).
        assert _lookup_per_peer_secret("https://no.secret") == ""

    def test_cache_invalidates_on_env_change(self, monkeypatch):
        """A test (or operator) updating MESH_PEER_SECRETS must see the
        new value immediately — no process restart required."""
        from services.mesh.mesh_crypto import _lookup_per_peer_secret

        monkeypatch.setenv("MESH_PEER_SECRETS", "https://a.example=first")
        assert _lookup_per_peer_secret("https://a.example") == "first"
        monkeypatch.setenv("MESH_PEER_SECRETS", "https://a.example=second")
        assert _lookup_per_peer_secret("https://a.example") == "second"


# ---------------------------------------------------------------------------
# resolve_peer_key_for_url — precedence + fallback
# ---------------------------------------------------------------------------


class TestResolvePeerKeyForUrl:
    def setup_method(self):
        from services.mesh import mesh_crypto

        mesh_crypto._PEER_SECRETS_CACHE = {}
        mesh_crypto._PEER_SECRETS_CACHE_RAW = ""

    def _fake_settings(self, global_secret: str):
        from unittest.mock import MagicMock

        s = MagicMock()
        s.MESH_PEER_PUSH_SECRET = global_secret
        return s

    def test_falls_back_to_global_when_no_per_peer_entry(self, monkeypatch):
        """Single-peer installs: MESH_PEER_SECRETS empty, MESH_PEER_PUSH_SECRET
        set — must keep working as before."""
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )

        monkeypatch.delenv("MESH_PEER_SECRETS", raising=False)
        with monkeypatch.context() as m:
            m.setattr(
                "services.config.get_settings",
                lambda: self._fake_settings("global-secret"),
            )
            key = resolve_peer_key_for_url("https://peer.example")
            expected = _derive_peer_key("global-secret", "https://peer.example")
        assert key == expected
        assert len(key) == 32  # SHA-256 output

    def test_per_peer_secret_takes_precedence_over_global(self, monkeypatch):
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-a.example=per-peer-a-secret",
        )
        with monkeypatch.context() as m:
            m.setattr(
                "services.config.get_settings",
                lambda: self._fake_settings("global-secret"),
            )
            key = resolve_peer_key_for_url("https://peer-a.example")
            expected_per_peer = _derive_peer_key(
                "per-peer-a-secret", "https://peer-a.example"
            )
            expected_global = _derive_peer_key("global-secret", "https://peer-a.example")
        assert key == expected_per_peer
        assert key != expected_global

    def test_unlisted_peer_uses_global_during_migration(self, monkeypatch):
        """Partial migration: peer A is in MESH_PEER_SECRETS, peer B is
        not yet. Peer B must keep working under the global secret."""
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-a.example=per-peer-a-secret",
        )
        with monkeypatch.context() as m:
            m.setattr(
                "services.config.get_settings",
                lambda: self._fake_settings("global-secret"),
            )
            key_a = resolve_peer_key_for_url("https://peer-a.example")
            key_b = resolve_peer_key_for_url("https://peer-b.example")
            expected_b = _derive_peer_key("global-secret", "https://peer-b.example")
        assert key_b == expected_b
        # Peer A's per-peer key must differ from peer B's global key
        # (they're keyed by different secrets and different URLs).
        assert key_a != key_b

    def test_returns_empty_when_no_secret_available(self, monkeypatch):
        from services.mesh.mesh_crypto import resolve_peer_key_for_url

        monkeypatch.delenv("MESH_PEER_SECRETS", raising=False)
        with monkeypatch.context() as m:
            m.setattr(
                "services.config.get_settings",
                lambda: self._fake_settings(""),
            )
            key = resolve_peer_key_for_url("https://peer.example")
        assert key == b""

    def test_returns_empty_when_url_is_unparseable(self, monkeypatch):
        from services.mesh.mesh_crypto import resolve_peer_key_for_url

        with monkeypatch.context() as m:
            m.setattr(
                "services.config.get_settings",
                lambda: self._fake_settings("global-secret"),
            )
            assert resolve_peer_key_for_url("") == b""
            assert resolve_peer_key_for_url("not-a-url") == b""
            assert resolve_peer_key_for_url(None) == b""


# ---------------------------------------------------------------------------
# The actual #256 attack: peer A cannot impersonate peer B
# ---------------------------------------------------------------------------


class TestCrossPeerImpersonationRefused:
    """The core regression: when MESH_PEER_SECRETS is configured, a peer
    that knows ONLY the global secret (or a different peer's per-peer
    secret) cannot produce a valid HMAC for another peer's URL."""

    def setup_method(self):
        from services.mesh import mesh_crypto

        mesh_crypto._PEER_SECRETS_CACHE = {}
        mesh_crypto._PEER_SECRETS_CACHE_RAW = ""

    def _hmac(self, key: bytes, body: bytes) -> str:
        return hmac.new(key, body, hashlib.sha256).hexdigest()

    def test_peer_a_global_secret_cannot_forge_peer_b_hmac(self, monkeypatch):
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )
        from unittest.mock import MagicMock

        # Receiver has BOTH the global secret AND a per-peer secret for B.
        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-b.example=per-peer-b-secret",
        )
        settings = MagicMock()
        settings.MESH_PEER_PUSH_SECRET = "global-secret"
        monkeypatch.setattr(
            "services.config.get_settings", lambda: settings
        )

        body = b'{"events": [{"id": 1}]}'

        # Attacker (peer A) knows only the global secret. Tries to forge
        # an HMAC claiming to be peer B.
        attacker_key = _derive_peer_key("global-secret", "https://peer-b.example")
        attacker_hmac = self._hmac(attacker_key, body)

        # Receiver derives B's expected key from B's per-peer secret.
        receiver_key = resolve_peer_key_for_url("https://peer-b.example")
        expected_hmac = self._hmac(receiver_key, body)

        # The forgery MUST NOT match.
        assert attacker_hmac != expected_hmac

    def test_peer_a_per_peer_secret_cannot_forge_peer_b_hmac(self, monkeypatch):
        """Even harder case: peer A has its OWN per-peer secret, but
        still does not know peer B's per-peer secret, and so cannot
        forge an HMAC for peer B."""
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )
        from unittest.mock import MagicMock

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-a.example=secretA,https://peer-b.example=secretB",
        )
        settings = MagicMock()
        settings.MESH_PEER_PUSH_SECRET = ""
        monkeypatch.setattr(
            "services.config.get_settings", lambda: settings
        )

        body = b'{"events": [{"id": 99}]}'

        # Attacker A tries to forge for B using its own secret (secretA).
        attacker_key = _derive_peer_key("secretA", "https://peer-b.example")
        attacker_hmac = self._hmac(attacker_key, body)

        receiver_key = resolve_peer_key_for_url("https://peer-b.example")
        expected_hmac = self._hmac(receiver_key, body)

        assert attacker_hmac != expected_hmac

    def test_legitimate_peer_b_request_verifies(self, monkeypatch):
        """Positive control: when peer B uses ITS per-peer secret and
        claims to be itself, the receiver accepts the HMAC."""
        from services.mesh.mesh_crypto import resolve_peer_key_for_url
        from unittest.mock import MagicMock

        monkeypatch.setenv(
            "MESH_PEER_SECRETS",
            "https://peer-b.example=secretB",
        )
        settings = MagicMock()
        settings.MESH_PEER_PUSH_SECRET = ""
        monkeypatch.setattr(
            "services.config.get_settings", lambda: settings
        )

        body = b'{"events": [{"id": 7}]}'

        # Peer B and the receiver both call resolve_peer_key_for_url.
        sender_key = resolve_peer_key_for_url("https://peer-b.example")
        receiver_key = resolve_peer_key_for_url("https://peer-b.example")

        sender_hmac = self._hmac(sender_key, body)
        expected_hmac = self._hmac(receiver_key, body)

        assert sender_hmac == expected_hmac

    def test_single_peer_install_zero_behavior_change(self, monkeypatch):
        """The "no UX hostility" guarantee: an install with the global
        secret set and NO MESH_PEER_SECRETS entries must derive exactly
        the same key as before this change."""
        from services.mesh.mesh_crypto import (
            resolve_peer_key_for_url,
            _derive_peer_key,
        )
        from unittest.mock import MagicMock

        monkeypatch.delenv("MESH_PEER_SECRETS", raising=False)
        settings = MagicMock()
        settings.MESH_PEER_PUSH_SECRET = "legacy-global-secret"
        monkeypatch.setattr(
            "services.config.get_settings", lambda: settings
        )

        # The legacy derivation that every prior call site used.
        legacy_key = _derive_peer_key("legacy-global-secret", "https://peer.example")
        # The new resolver, with no per-peer entries configured.
        new_key = resolve_peer_key_for_url("https://peer.example")

        assert new_key == legacy_key
