"""tests/test_owner_id_bypass.py — Owner ID bypass prevention for commitments,
open loops, and contradictions routes.

Security: Verifies that when auth middleware sets request.state.owner_id,
body/query-supplied owner_id values are IGNORED (preventing cross-tenant
data access / IDOR).

Related: #818 (original IDOR fix for memory routes), extended to commitments
and contradictions routes.

Run: pytest tests/test_owner_id_bypass.py -v
"""

from __future__ import annotations

import logging
import os
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.api.deps import PREFIX


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_mock_plugin():
    """Create a mock CortexPlugin with async storage methods for commitments/contradictions."""
    plugin = MagicMock()
    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.close = AsyncMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = "default"
    plugin.config.max_remember_body_bytes = 524288
    # Needed by create_app lifespan
    plugin.remember = AsyncMock()
    plugin.retrieve = AsyncMock()
    plugin.wake = AsyncMock(return_value=MagicMock(session_id="s", cornerstones_loaded=0, errors=[]))
    plugin.sleep = AsyncMock()
    return plugin


@contextmanager
def _make_authed_client(api_key="test-key", auth_owner="alice"):
    """Create a test client where auth middleware resolves owner_id to *auth_owner*.

    Uses CORTEX_API_KEY_OWNER env var so the AuthProvider.validate_api_key()
    returns the desired owner_id when the admin API key matches.
    """
    env = {
        "CORTEX_API_KEY": api_key,
        "CORTEX_API_KEY_OWNER": auth_owner,
    }
    with patch.dict(os.environ, env, clear=False):
        from cortex.api.http_server import create_app
        from starlette.testclient import TestClient

        mock_plugin = _make_mock_plugin()
        app = create_app(api_key=api_key)
        with patch("cortex.api.plugin.CortexPlugin") as MockClass:
            MockClass.return_value = mock_plugin
            with TestClient(app, raise_server_exceptions=False) as tc:
                tc._auth_headers = {
                    "X-API-Key": api_key,
                }
                tc._mock_plugin = mock_plugin
                yield tc


# ---------------------------------------------------------------------------
# _resolve_owner_id WARNING log tests
# ---------------------------------------------------------------------------

class TestResolveOwnerIdWarningLog:
    """Verify WARNING is logged when body owner_id differs from auth identity."""

    def test_warning_logged_on_mismatch(self, caplog):
        from cortex.api.deps import _resolve_owner_id

        request = MagicMock()
        request.state.owner_id = "alice"

        # Ensure the logger propagates so caplog can capture it
        deps_logger = logging.getLogger("cortex.api.deps")
        deps_logger.propagate = True
        with caplog.at_level(logging.WARNING):
            result = _resolve_owner_id(request, body_owner_id="bob")

        assert result == "alice"
        assert any("owner_id override" in msg for msg in caplog.messages), (
            f"Expected WARNING about owner_id override, got: {caplog.messages}"
        )
        assert any("bob" in msg and "alice" in msg for msg in caplog.messages)

    def test_no_warning_when_matching(self, caplog):
        from cortex.api.deps import _resolve_owner_id

        request = MagicMock()
        request.state.owner_id = "alice"

        with caplog.at_level(logging.WARNING, logger="cortex.api.deps"):
            result = _resolve_owner_id(request, body_owner_id="alice")

        assert result == "alice"
        assert not any("owner_id override" in msg for msg in caplog.messages)

    def test_no_warning_when_no_body_owner(self, caplog):
        from cortex.api.deps import _resolve_owner_id

        request = MagicMock()
        request.state.owner_id = "alice"

        with caplog.at_level(logging.WARNING, logger="cortex.api.deps"):
            result = _resolve_owner_id(request, body_owner_id=None)

        assert result == "alice"
        assert not any("owner_id override" in msg for msg in caplog.messages)


# ---------------------------------------------------------------------------
# Commitments — owner_id bypass prevention
# ---------------------------------------------------------------------------

class TestCommitmentsOwnerBypass:
    """POST/GET/PATCH /commitments must use auth-resolved owner, not body/query."""

    def test_create_commitment_ignores_body_owner_id(self):
        """POST /commitments with body.owner_id='attacker' should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            # Mock the store function to capture the owner_id it receives
            with patch("cortex.api.routes.commitments.create_commitment") as mock_create:
                mock_obj = MagicMock()
                mock_obj.id = "c1"
                mock_obj.owner_id = "alice"
                mock_obj.content = "test"
                mock_obj.due_at = None
                mock_obj.status = "open"
                mock_obj.source_turn_id = None
                mock_obj.created_at = "2026-01-01T00:00:00Z"
                mock_obj.closed_at = None
                mock_create.return_value = mock_obj

                resp = tc.post(
                    f"{PREFIX}/commitments",
                    json={"owner_id": "attacker", "content": "steal data"},
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 201
                # Verify the store was called with auth identity, not attacker
                call_kwargs = mock_create.call_args
                assert call_kwargs[1]["owner_id"] == "alice" or call_kwargs.kwargs.get("owner_id") == "alice", (
                    f"IDOR: create_commitment called with wrong owner_id: {call_kwargs}"
                )

    def test_list_commitments_ignores_query_owner_id(self):
        """GET /commitments?owner_id=attacker should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.commitments.list_commitments") as mock_list:
                mock_list.return_value = []

                resp = tc.get(
                    f"{PREFIX}/commitments?owner_id=attacker",
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                call_kwargs = mock_list.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: list_commitments called with wrong owner_id: {call_kwargs}"
                )

    def test_patch_commitment_ignores_body_owner_id(self):
        """PATCH /commitments/{id} with body.owner_id='attacker' should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.commitments.close_commitment") as mock_close:
                mock_obj = MagicMock()
                mock_obj.id = "c1"
                mock_obj.owner_id = "alice"
                mock_obj.content = "test"
                mock_obj.due_at = None
                mock_obj.status = "completed"
                mock_obj.source_turn_id = None
                mock_obj.created_at = "2026-01-01T00:00:00Z"
                mock_obj.closed_at = "2026-01-02T00:00:00Z"
                mock_close.return_value = mock_obj

                resp = tc.patch(
                    f"{PREFIX}/commitments/c1",
                    json={"status": "completed", "owner_id": "attacker"},
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                call_kwargs = mock_close.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: close_commitment called with wrong owner_id: {call_kwargs}"
                )


# ---------------------------------------------------------------------------
# Open Loops — owner_id bypass prevention
# ---------------------------------------------------------------------------

class TestOpenLoopsOwnerBypass:
    """POST/GET/PATCH /open-loops must use auth-resolved owner, not body/query."""

    def test_create_open_loop_ignores_body_owner_id(self):
        """POST /open-loops with body.owner_id='attacker' should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.commitments.create_open_loop") as mock_create:
                mock_obj = MagicMock()
                mock_obj.id = "ol1"
                mock_obj.owner_id = "alice"
                mock_obj.content = "test loop"
                mock_obj.context = None
                mock_obj.status = "open"
                mock_obj.carry_count = 0
                mock_obj.created_at = "2026-01-01T00:00:00Z"
                mock_obj.resolved_at = None
                mock_create.return_value = mock_obj

                resp = tc.post(
                    f"{PREFIX}/open-loops",
                    json={"owner_id": "attacker", "content": "steal data"},
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 201
                call_kwargs = mock_create.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: create_open_loop called with wrong owner_id: {call_kwargs}"
                )

    def test_list_open_loops_ignores_query_owner_id(self):
        """GET /open-loops?owner_id=attacker should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.commitments.list_open_loops") as mock_list:
                mock_list.return_value = []

                resp = tc.get(
                    f"{PREFIX}/open-loops?owner_id=attacker",
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                call_kwargs = mock_list.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: list_open_loops called with wrong owner_id: {call_kwargs}"
                )

    def test_patch_open_loop_ignores_body_owner_id(self):
        """PATCH /open-loops/{id} with body.owner_id='attacker' should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.commitments.resolve_open_loop") as mock_resolve:
                mock_obj = MagicMock()
                mock_obj.id = "ol1"
                mock_obj.owner_id = "alice"
                mock_obj.content = "test loop"
                mock_obj.context = None
                mock_obj.status = "resolved"
                mock_obj.carry_count = 0
                mock_obj.created_at = "2026-01-01T00:00:00Z"
                mock_obj.resolved_at = "2026-01-02T00:00:00Z"
                mock_resolve.return_value = mock_obj

                resp = tc.patch(
                    f"{PREFIX}/open-loops/ol1",
                    json={"owner_id": "attacker"},
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                call_kwargs = mock_resolve.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: resolve_open_loop called with wrong owner_id: {call_kwargs}"
                )


# ---------------------------------------------------------------------------
# Contradictions — owner_id bypass prevention
# ---------------------------------------------------------------------------

class TestContradictionsOwnerBypass:
    """GET/PATCH /contradictions must use auth-resolved owner, not body/query."""

    def test_list_contradictions_ignores_query_owner_id(self):
        """GET /contradictions?owner_id=attacker should use auth identity."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.contradictions.list_contradictions") as mock_list:
                mock_list.return_value = []

                resp = tc.get(
                    f"{PREFIX}/contradictions?owner_id=attacker",
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                call_kwargs = mock_list.call_args
                assert call_kwargs[1]["owner_id"] == "alice", (
                    f"IDOR: list_contradictions called with wrong owner_id: {call_kwargs}"
                )

    def test_resolve_contradiction_body_owner_checked(self):
        """PATCH /contradictions/{id} should resolve owner via auth, log warning on mismatch."""
        with _make_authed_client(auth_owner="alice") as tc:
            with patch("cortex.api.routes.contradictions.mark_resolved") as mock_resolve:
                mock_resolve.return_value = True

                resp = tc.patch(
                    f"{PREFIX}/contradictions/1",
                    json={"resolution": "kept_a", "owner_id": "attacker"},
                    headers=tc._auth_headers,
                )
                assert resp.status_code == 200
                # mark_resolved doesn't take owner_id, but _resolve_owner_id was called
                # to validate (and log warning). The key test is that the endpoint
                # doesn't crash and the warning is generated.
