# tests/test_adversarial_phase5.py
"""Adversarial tests for Phase 5 -- Tenants, Rollouts, Replay.

Attacker mindset: each test tries to bypass validation, exhaust resources,
corrupt state, race concurrent state, or leak internal information. All tests
MUST PASS -- the application must handle every attack gracefully.

Categories:
  1. TestAdversarialPhase5InputValidation  -- regex bypass, boundary, injection
  2. TestAdversarialPhase5Concurrency      -- race conditions on shared state
  3. TestAdversarialPhase5DoS             -- resource exhaustion / caps
  4. TestAdversarialPhase5StateCorruption  -- illegal state machine transitions
  5. TestAdversarialPhase5DataIntegrity    -- circular refs, invalid configs
  6. TestAdversarialPhase5InfoLeakage      -- no internals in error responses
"""

from __future__ import annotations

import threading
import time
import uuid
from typing import Any

import pytest
from fastapi.testclient import TestClient

from veronica.api.app import create_app


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


def _min_policy(chain_id: str = "chain-adv5") -> dict[str, Any]:
    """Minimal valid policy_config payload for rollout creation."""
    return {
        "chain_id": chain_id,
        "ceiling_usd": 1.0,
        "on_exceed": "halt",
        "issued_at": time.time(),
    }


def _create_rollout(c: TestClient, chain_id: str = "chain-adv5") -> str:
    """Create a DRAFT rollout and return its id."""
    resp = c.post(
        "/rollouts",
        json={"policy_config": _min_policy(chain_id), "created_by": "tester"},
    )
    assert resp.status_code == 201, resp.text
    return resp.json()["id"]


def _advance_to_active(c: TestClient, rollout_id: str) -> None:
    """Drive rollout through simulate -> approve -> promote -> activate."""
    c.post(f"/rollouts/{rollout_id}/simulate")
    c.post(f"/rollouts/{rollout_id}/approve", json={"actor": "approver"})
    c.post(f"/rollouts/{rollout_id}/promote", json={"actor": "promoter"})
    c.post(f"/rollouts/{rollout_id}/activate", json={"actor": "activator"})


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def client(monkeypatch: pytest.MonkeyPatch) -> Any:
    """Auth-disabled client with lifespan started."""
    monkeypatch.setenv("VERONICA_AUTH_DISABLED", "1")
    monkeypatch.delenv("VERONICA_API_KEY", raising=False)
    app = create_app()
    with TestClient(app, raise_server_exceptions=False) as c:
        yield c


# ===========================================================================
# Category 1: Input Validation Bypass
# ===========================================================================


class TestAdversarialPhase5InputValidation:
    """Attacker tries to slip malformed inputs past validation."""

    def test_tenant_id_with_dot_rejected(self, client: TestClient) -> None:
        """Tenant ID containing '.' must be rejected (regex allows [a-zA-Z0-9_-] only)."""
        resp = client.post("/tenants", json={"id": "bad.tenant", "name": "x"})
        assert resp.status_code == 422

    def test_tenant_id_with_slash_rejected(self, client: TestClient) -> None:
        """Tenant ID containing '/' must be rejected."""
        resp = client.post("/tenants", json={"id": "bad/tenant", "name": "x"})
        assert resp.status_code == 422

    def test_tenant_id_with_space_rejected(self, client: TestClient) -> None:
        """Tenant ID with spaces must be rejected."""
        resp = client.post("/tenants", json={"id": "bad tenant", "name": "x"})
        assert resp.status_code == 422

    def test_tenant_id_at_max_length_accepted(self, client: TestClient) -> None:
        """Tenant ID exactly 128 chars must be accepted."""
        long_id = "a" * 128
        resp = client.post("/tenants", json={"id": long_id, "name": "boundary"})
        assert resp.status_code == 201

    def test_tenant_id_exceeding_max_length_rejected(self, client: TestClient) -> None:
        """Tenant ID of 129 chars must be rejected."""
        over_id = "a" * 129
        resp = client.post("/tenants", json={"id": over_id, "name": "too long"})
        assert resp.status_code == 422

    def test_tenant_name_at_max_length_accepted(self, client: TestClient) -> None:
        """Tenant name exactly 256 chars must be accepted."""
        resp = client.post("/tenants", json={"id": "name-boundary", "name": "n" * 256})
        assert resp.status_code == 201

    def test_tenant_parent_id_with_control_chars_rejected(
        self, client: TestClient
    ) -> None:
        """parent_id with control characters must be rejected."""
        resp = client.post(
            "/tenants",
            json={"id": "child-x", "name": "child", "parent_id": "bad\x00id"},
        )
        assert resp.status_code == 422

    def test_rollout_created_by_with_semicolon_rejected(
        self, client: TestClient
    ) -> None:
        """created_by with shell injection char ';' must be rejected by regex."""
        resp = client.post(
            "/rollouts",
            json={"policy_config": _min_policy(), "created_by": "alice;rm -rf /"},
        )
        assert resp.status_code == 422

    def test_rollout_actor_with_newline_rejected(self, client: TestClient) -> None:
        """Actor containing newline must be rejected (log injection prevention)."""
        rollout_id = _create_rollout(client)
        # simulate first so we can approve
        client.post(f"/rollouts/{rollout_id}/simulate")
        resp = client.post(
            f"/rollouts/{rollout_id}/approve",
            json={"actor": "alice\nadmin:true"},
        )
        assert resp.status_code == 422

    def test_replay_chain_id_with_unicode_rejected(self, client: TestClient) -> None:
        """chain_id with Unicode outside allowed set must be rejected."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-\u4e2d\u6587",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
            },
        )
        assert resp.status_code == 422

    def test_replay_chain_id_exceeding_max_length_rejected(
        self, client: TestClient
    ) -> None:
        """chain_id of 257 chars must be rejected (max 256)."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "c" * 257,
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
            },
        )
        assert resp.status_code == 422

    def test_override_policy_ceiling_usd_negative_rejected(
        self, client: TestClient
    ) -> None:
        """override_policy with negative ceiling_usd must be rejected."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-neg",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
                "override_policy": {
                    "chain_id": "chain-neg",
                    "ceiling_usd": -1.0,
                    "on_exceed": "halt",
                    "issued_at": time.time(),
                },
            },
        )
        assert resp.status_code == 422

    def test_override_policy_ceiling_steps_negative_rejected(
        self, client: TestClient
    ) -> None:
        """override_policy with negative ceiling_steps must be rejected."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-steps-neg",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
                "override_policy": {
                    "chain_id": "chain-steps-neg",
                    "ceiling_usd": 5.0,
                    "on_exceed": "halt",
                    "issued_at": time.time(),
                    "ceiling_steps": -100,
                },
            },
        )
        assert resp.status_code == 422

    def test_tenant_policy_override_ceiling_usd_negative_rejected(
        self, client: TestClient
    ) -> None:
        """policy_overrides with negative ceiling_usd must be rejected via resolver validation."""
        # Create tenant with invalid override -- route validates via _validate_overrides (key count),
        # but negative value is caught by PolicyResolver._validate_override_value on resolution.
        # Create tenant first with negative ceiling_usd override -- API allows it through
        # (key count check only), but effective-policy must reject it.
        client.post(
            "/tenants",
            json={
                "id": "neg-ceiling",
                "name": "neg",
                "policy_overrides": {"ceiling_usd": -5.0},
            },
        )
        # The resolver raises ValueError for negative ceiling_usd => route returns 500 or 422.
        # We verify the response is not 200 (negative value must never be silently accepted).
        resp = client.get("/tenants/neg-ceiling/effective-policy")
        assert resp.status_code != 200


# ===========================================================================
# Category 2: Concurrent Access / Race Conditions
# ===========================================================================


class TestAdversarialPhase5Concurrency:
    """Attacker triggers race conditions on shared registry state."""

    def test_concurrent_rollout_activate_idempotent_or_409(
        self, client: TestClient
    ) -> None:
        """10 threads simultaneously trying to activate the same PROMOTED rollout.

        Exactly one must succeed (200). The rest must get 409 (already ACTIVE,
        ACTIVE->ACTIVE not allowed) or 200 (race won). After all threads finish,
        the rollout must be in ACTIVE state exactly once.
        """
        rollout_id = _create_rollout(client)
        client.post(f"/rollouts/{rollout_id}/simulate")
        client.post(f"/rollouts/{rollout_id}/approve", json={"actor": "approver"})
        client.post(f"/rollouts/{rollout_id}/promote", json={"actor": "promoter"})

        results: list[int] = []
        lock = threading.Lock()

        def try_activate() -> None:
            resp = client.post(
                f"/rollouts/{rollout_id}/activate",
                json={"actor": "racer"},
            )
            with lock:
                results.append(resp.status_code)

        threads = [threading.Thread(target=try_activate) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        success_count = results.count(200)
        assert success_count >= 1, "At least one activation must succeed"

        # Final state must be ACTIVE
        final = client.get(f"/rollouts/{rollout_id}")
        assert final.status_code == 200
        assert final.json()["state"] == "active"

    def test_concurrent_tenant_creation_same_id(self, client: TestClient) -> None:
        """10 threads simultaneously creating a tenant with the same ID.

        Exactly one must succeed (201). The rest must get 422 (duplicate).
        """
        results: list[int] = []
        lock = threading.Lock()

        def try_create() -> None:
            resp = client.post("/tenants", json={"id": "race-tenant", "name": "racer"})
            with lock:
                results.append(resp.status_code)

        threads = [threading.Thread(target=try_create) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert results.count(201) == 1, (
            f"Exactly one creation must succeed, got {results}"
        )
        assert all(c in (201, 422) for c in results), (
            f"Unexpected status codes: {results}"
        )

    def test_concurrent_simulate_and_approve(self, client: TestClient) -> None:
        """Concurrent simulate + approve on same DRAFT rollout must not corrupt state.

        simulate transitions DRAFT -> SIMULATED.
        approve transitions SIMULATED -> APPROVED.
        Running both concurrently must not result in an invalid intermediate state.
        """
        rollout_id = _create_rollout(client)

        sim_result: list[int] = []
        approve_result: list[int] = []

        def do_simulate() -> None:
            r = client.post(f"/rollouts/{rollout_id}/simulate")
            sim_result.append(r.status_code)

        def do_approve() -> None:
            r = client.post(
                f"/rollouts/{rollout_id}/approve", json={"actor": "approver"}
            )
            approve_result.append(r.status_code)

        t1 = threading.Thread(target=do_simulate)
        t2 = threading.Thread(target=do_approve)
        t1.start()
        t2.start()
        t1.join()
        t2.join()

        # At least simulate must succeed (DRAFT -> SIMULATED is the first step)
        assert 200 in sim_result, "simulate must succeed"

        # Final state must be a known valid state (not corrupted)
        final = client.get(f"/rollouts/{rollout_id}")
        assert final.status_code == 200
        assert final.json()["state"] in ("draft", "simulated", "approved")

    def test_concurrent_revoke_from_multiple_threads(self, client: TestClient) -> None:
        """Multiple threads revoking the same rollout -- exactly one must succeed."""
        rollout_id = _create_rollout(client)

        results: list[int] = []
        lock = threading.Lock()

        def try_revoke() -> None:
            r = client.post(f"/rollouts/{rollout_id}/revoke", json={"actor": "revoker"})
            with lock:
                results.append(r.status_code)

        threads = [threading.Thread(target=try_revoke) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Exactly one revoke must succeed; the rest must get 409 (already REVOKED)
        assert results.count(200) == 1, (
            f"Exactly one revoke must succeed, got {results}"
        )
        final = client.get(f"/rollouts/{rollout_id}")
        assert final.json()["state"] == "revoked"


# ===========================================================================
# Category 3: Resource Exhaustion / DoS
# ===========================================================================


class TestAdversarialPhase5DoS:
    """Attacker attempts to exhaust resources or bypass size caps."""

    def test_tenant_with_21_override_keys_rejected(self, client: TestClient) -> None:
        """Creating a tenant with 21 policy_overrides keys must be rejected (limit: 20)."""
        # Build a dict with 21 known keys -- fill with known keys repeated by using
        # all 12 known keys then pad with unknown-key names to force count > 20.
        # Actually, unknown keys are also rejected, so we use valid keys (12 max known)
        # and pad with garbage to hit count > 20. The count check fires first.
        overrides = {f"key_{i}": i for i in range(21)}
        resp = client.post(
            "/tenants",
            json={
                "id": "too-many-overrides",
                "name": "dos",
                "policy_overrides": overrides,
            },
        )
        assert resp.status_code == 422
        assert "20" in resp.json().get("detail", "")

    def test_rollout_sim_result_exceeds_64kb_rejected(self, client: TestClient) -> None:
        """set_simulation_result with >64KB payload must be rejected at registry level.

        The /simulate endpoint uses distributor.distribute() -- we test the registry
        directly via a crafted rollout that would overflow. Since the HTTP endpoint
        doesn't accept a custom sim_result body, we verify the registry raises on
        oversized data by calling it directly.
        """
        from veronica.rollouts.registry import RolloutRegistry
        from veronica.types import PolicyConfig

        reg = RolloutRegistry()
        policy = PolicyConfig(
            chain_id="chain-dos",
            ceiling_usd=1.0,
            on_exceed="halt",
            issued_at=time.time(),
        )
        rollout = reg.create(policy_config=policy, created_by="tester")

        # Build a result that is clearly over 64KB
        # NOTE: Tests deprecated set_simulation_result() directly for registry-level
        # coverage. Router uses simulate() which has the same size check.
        huge_result = {"data": "x" * 70_000}
        with pytest.raises(ValueError, match="65536"):
            reg.set_simulation_result(rollout.id, huge_result)

    def test_replay_with_large_event_count_is_capped(self, client: TestClient) -> None:
        """Replay engine must cap processing at _MAX_REPLAY_EVENTS (100_000).

        We can't inject 100K+ events via HTTP easily, but we can verify the
        engine's cap constant is enforced by checking that a replay of an empty
        store returns event_count=0 and does not crash.
        """
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-large",
                "from_timestamp": 0.0,
                "to_timestamp": time.time() + 3600,
            },
        )
        assert resp.status_code == 200
        assert resp.json()["event_count"] == 0

    def test_rollout_history_cap_raises_on_overflow(self, client: TestClient) -> None:
        """Registry must raise InvalidTransitionError when history exceeds 1000 entries.

        We drive the rollout through oscillating transitions to accumulate history.
        Because each cycle needs multiple HTTP calls (simulate->approve->promote->..),
        we test the registry directly to avoid 1000 HTTP round-trips.
        """
        from veronica.rollouts.registry import InvalidTransitionError, RolloutRegistry
        from veronica.rollouts.models import RolloutState
        from veronica.types import PolicyConfig

        reg = RolloutRegistry()
        policy = PolicyConfig(
            chain_id="chain-hist",
            ceiling_usd=1.0,
            on_exceed="halt",
            issued_at=time.time(),
        )
        rollout = reg.create(policy_config=policy, created_by="tester")

        # Each DRAFT->SIMULATED->DRAFT cycle = 2 transitions.
        # 500 cycles = 1000 transitions -- the 1001st must raise.
        rid = rollout.id
        try:
            for _ in range(500):
                reg.transition(rid, RolloutState.SIMULATED, "tester")
                reg.transition(rid, RolloutState.DRAFT, "tester")
        except InvalidTransitionError:
            pass  # Hit the cap -- expected at or before 1000 transitions

        # After the cap, further transitions must always raise
        with pytest.raises(InvalidTransitionError, match="History limit"):
            reg.transition(rid, RolloutState.SIMULATED, "overflow")


# ===========================================================================
# Category 4: State Corruption
# ===========================================================================


class TestAdversarialPhase5StateCorruption:
    """Attacker tries to force illegal state machine transitions."""

    def test_revoked_to_draft_rejected(self, client: TestClient) -> None:
        """REVOKED is terminal -- transitioning to DRAFT must return 409."""
        rollout_id = _create_rollout(client)
        client.post(f"/rollouts/{rollout_id}/revoke", json={"actor": "revoker"})

        # Try to go back to DRAFT via simulate (which internally transitions DRAFT->SIMULATED;
        # but the rollout is already REVOKED, so simulate must fail)
        resp = client.post(f"/rollouts/{rollout_id}/simulate")
        assert resp.status_code == 409

    def test_revoked_to_active_rejected(self, client: TestClient) -> None:
        """REVOKED is terminal -- activate must return 409."""
        rollout_id = _create_rollout(client)
        client.post(f"/rollouts/{rollout_id}/revoke", json={"actor": "revoker"})

        resp = client.post(
            f"/rollouts/{rollout_id}/activate", json={"actor": "attacker"}
        )
        assert resp.status_code == 409

    def test_skip_states_draft_to_active_rejected(self, client: TestClient) -> None:
        """Skipping SIMULATED/APPROVED/PROMOTED: DRAFT -> ACTIVE must be rejected."""
        rollout_id = _create_rollout(client)
        resp = client.post(
            f"/rollouts/{rollout_id}/activate", json={"actor": "attacker"}
        )
        assert resp.status_code == 409

    def test_double_activate_rejected(self, client: TestClient) -> None:
        """Activating an already ACTIVE rollout must return 409."""
        rollout_id = _create_rollout(client)
        _advance_to_active(client, rollout_id)

        resp = client.post(
            f"/rollouts/{rollout_id}/activate", json={"actor": "attacker"}
        )
        assert resp.status_code == 409

    def test_revoked_then_all_transitions_rejected(self, client: TestClient) -> None:
        """After revoke, every transition endpoint must return 409."""
        rollout_id = _create_rollout(client)
        r = client.post(f"/rollouts/{rollout_id}/revoke", json={"actor": "revoker"})
        assert r.status_code == 200

        for endpoint, payload in [
            ("simulate", None),
            ("approve", {"actor": "x"}),
            ("promote", {"actor": "x"}),
            ("activate", {"actor": "x"}),
            ("revoke", {"actor": "x"}),
        ]:
            resp = client.post(
                f"/rollouts/{rollout_id}/{endpoint}",
                json=payload if payload else None,
            )
            assert resp.status_code == 409, (
                f"Expected 409 for {endpoint} on REVOKED rollout, got {resp.status_code}: {resp.text}"
            )


# ===========================================================================
# Category 5: Data Integrity
# ===========================================================================


class TestAdversarialPhase5DataIntegrity:
    """Attacker tries to corrupt data through circular refs, bad configs, etc."""

    def test_tenant_circular_parent_reference_rejected(
        self, client: TestClient
    ) -> None:
        """Creating a parent reference cycle must be rejected.

        After creating A and B (B's parent = A), attempting to update A so that
        A's parent = B would require parent update (not supported via PUT),
        so we instead try to create a chain where C's parent = B, B's parent = A,
        A's parent = C (a cycle initiated through C).
        Since PUT does not allow parent_id changes, we test via the create path:
        create A (no parent), create B (parent=A), try to create A2 with parent=B
        and then set B's parent to a non-existent id -- that must fail.
        For a real circular ref test we directly test the registry.
        """
        from veronica.tenants.registry import TenantRegistry
        from veronica.tenants.models import TenantNode

        reg = TenantRegistry()
        a = TenantNode(id="circ-a", name="A")
        b = TenantNode(id="circ-b", name="B", parent_id="circ-a")
        reg.register(a)
        reg.register(b)

        # Manually insert a circular edge on the internal registry state to create A->B->A cycle.
        # The registry stores deepcopies, so we must mutate the internal dict directly.
        reg._tenants[
            "circ-a"
        ].parent_id = "circ-b"  # force the cycle on the stored object
        with pytest.raises(RuntimeError, match="[Cc]ircular"):
            reg.get_ancestors("circ-b")

    def test_tenant_deep_hierarchy_over_100_raises(self, client: TestClient) -> None:
        """Registering a chain deeper than MAX_DEPTH (100) must raise RuntimeError.

        _walk_ancestors_unsafe is called during register() to verify no cycle exists.
        Once the chain reaches MAX_DEPTH nodes, inserting the next node triggers the
        depth guard inside _walk_ancestors_unsafe and raises RuntimeError.
        """
        from veronica.tenants.models import TenantNode
        from veronica.tenants.registry import MAX_DEPTH, TenantRegistry

        reg = TenantRegistry()
        prev_id = None
        # Build a chain of MAX_DEPTH+1 nodes (indices 0 .. MAX_DEPTH).
        # All succeed: _walk_ancestors_unsafe walks up to MAX_DEPTH ancestors during
        # each registration, and depth stays < MAX_DEPTH for the chain length here.
        for i in range(MAX_DEPTH + 1):
            node = TenantNode(id=f"deep-{i}", name=f"Deep {i}", parent_id=prev_id)
            reg.register(node)
            prev_id = node.id

        # Registering one more node (the 102nd) requires walking MAX_DEPTH+1 ancestors.
        # When depth reaches MAX_DEPTH (== 100), the guard fires: RuntimeError.
        overflow_node = TenantNode(
            id="deep-overflow", name="Overflow", parent_id=prev_id
        )
        with pytest.raises(RuntimeError, match="[Cc]ircular"):
            reg.register(overflow_node)

    def test_rollout_create_with_invalid_policy_config_rejected(
        self, client: TestClient
    ) -> None:
        """Rollout creation with missing required policy fields must be rejected."""
        resp = client.post(
            "/rollouts",
            json={
                "policy_config": {"on_exceed": "halt"},  # missing chain_id, ceiling_usd
                "created_by": "tester",
            },
        )
        assert resp.status_code == 422

    def test_replay_override_policy_missing_chain_id_rejected(
        self, client: TestClient
    ) -> None:
        """override_policy without chain_id must be rejected (422)."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-x",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
                "override_policy": {
                    "ceiling_usd": 5.0,
                    "on_exceed": "halt",
                    # chain_id intentionally omitted
                },
            },
        )
        assert resp.status_code == 422

    def test_replay_override_policy_missing_ceiling_usd_rejected(
        self, client: TestClient
    ) -> None:
        """override_policy without ceiling_usd must be rejected (422)."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-y",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
                "override_policy": {
                    "chain_id": "chain-y",
                    "on_exceed": "halt",
                    # ceiling_usd intentionally omitted
                },
            },
        )
        assert resp.status_code == 422


# ===========================================================================
# Category 6: Information Leakage
# ===========================================================================


class TestAdversarialPhase5InfoLeakage:
    """Attacker probes error responses for internal state / UUIDs."""

    def test_invalid_tenant_error_does_not_expose_internals(
        self, client: TestClient
    ) -> None:
        """Creating a duplicate tenant must return a user-facing message, not a traceback."""
        client.post("/tenants", json={"id": "dup-info", "name": "First"})
        resp = client.post("/tenants", json={"id": "dup-info", "name": "Second"})
        assert resp.status_code == 422
        body = resp.text.lower()
        assert "traceback" not in body
        assert "self._tenants" not in body

    def test_nonexistent_rollout_404_does_not_leak_uuid_format(
        self, client: TestClient
    ) -> None:
        """404 for a non-existent rollout must not expose internal UUID structure beyond the ID."""
        fake_id = str(uuid.uuid4())
        resp = client.get(f"/rollouts/{fake_id}")
        assert resp.status_code == 404
        detail = resp.json().get("detail", "")
        # The detail may echo back the rollout id (acceptable), but must not contain
        # internal identifiers like memory addresses or unrelated UUIDs
        assert "0x" not in detail, "Memory addresses must not appear in error responses"
        assert detail.count("-") <= 6, "Suspiciously many UUIDs in error response"

    def test_replay_store_error_does_not_expose_internals(
        self, client: TestClient
    ) -> None:
        """Replay with invalid override_policy must return a sanitized error message."""
        resp = client.post(
            "/replay",
            json={
                "chain_id": "chain-leak",
                "from_timestamp": 1.0,
                "to_timestamp": 2.0,
                "override_policy": {
                    "chain_id": "chain-leak",
                    "ceiling_usd": 5.0,
                    "on_exceed": "INVALID_VALUE",
                    "issued_at": time.time(),
                },
            },
        )
        assert resp.status_code == 422
        body = resp.text.lower()
        assert "traceback" not in body
        assert "_store" not in body
        assert "__dict__" not in body
