"""Property-based tests for operator approval workflow. Feature: operator-approval-workflow (bugfix) Bug Condition Exploration: The `requires_approval()` function is correct at the function level — when `auto_approve_paper=False`, it returns `True` for PAPER mode. However, the config JSON stored in `risk_configs` is always `{}`, so `PortfolioRiskConfig.from_db_json({})` produces `auto_approve_paper=True`, and no UI/endpoint ever sets it to `False`. The approval gate is therefore never reached for paper orders. Tests: - Property 1 (Bug Condition): Paper Mode Approval Bypass """ from __future__ import annotations from hypothesis import given, settings from hypothesis import strategies as st from services.risk.approval import requires_approval from services.risk.engine import ( OperatorApproval, PortfolioRiskConfig, TradingMode, ) # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- def _risk_config_with_approval_disabled() -> st.SearchStrategy[PortfolioRiskConfig]: """Generate random PortfolioRiskConfig instances with auto_approve_paper=False.""" return st.builds( PortfolioRiskConfig, trading_mode=st.just(TradingMode.PAPER), operator_approval=st.builds( OperatorApproval, auto_approve_paper=st.just(False), require_approval_for_live=st.booleans(), approval_timeout_minutes=st.integers(min_value=1, max_value=1440), ), ) # --------------------------------------------------------------------------- # Property 1: Bug Condition — Paper Mode Approval Bypass # --------------------------------------------------------------------------- class TestBugConditionExploration: """Exploration tests to surface the bug condition. **Validates: Requirements 1.1, 1.2, 1.5, 2.1** The bug is that paper orders always bypass approval because the config JSON in the database never contains operator_approval settings, so `auto_approve_paper` always defaults to `True`. """ @given(config=_risk_config_with_approval_disabled()) @settings(max_examples=100) def test_requires_approval_returns_true_when_auto_approve_paper_is_false( self, config: PortfolioRiskConfig, ) -> None: """Function-level correctness: requires_approval() returns True when auto_approve_paper is explicitly False and mode is PAPER. This property is expected to PASS — the function itself is correct. The bug is at the config/persistence layer, not the function. """ result = requires_approval(config, TradingMode.PAPER) assert result is True, ( f"requires_approval() returned False for PAPER mode with " f"auto_approve_paper=False. Config: {config.operator_approval}" ) def test_from_db_json_empty_config_defaults_to_auto_approve(self) -> None: """Root cause demonstration: PortfolioRiskConfig.from_db_json({}) always produces auto_approve_paper=True. This test is EXPECTED TO FAIL (assert False) because it demonstrates the bug — empty config JSON always defaults to auto-approve, meaning the approval gate is never reached for paper orders. The test asserts that from_db_json({}) should produce auto_approve_paper=False (the safe default), but it actually produces True. This is the root cause of the bug. """ config = PortfolioRiskConfig.from_db_json({}) # The bug: empty JSON defaults auto_approve_paper to True. # We assert the EXPECTED (correct) behavior: empty config should NOT # auto-approve paper orders, so the approval gate is active by default. assert config.operator_approval.auto_approve_paper is False, ( f"PortfolioRiskConfig.from_db_json({{}}) produced " f"auto_approve_paper={config.operator_approval.auto_approve_paper}. " f"Empty config JSON always defaults to auto-approve, which means " f"the approval gate is never reached for paper orders. " f"This is the root cause of bug 1.1." ) def test_no_dedicated_approval_config_endpoint(self) -> None: """Verify there is no dedicated endpoint to update operator_approval settings in the risk config. This test is EXPECTED TO FAIL because the endpoint does not exist yet. It imports the FastAPI app and checks that no route matches PUT /api/admin/trading/approval-config. """ from services.api.app import app approval_config_routes = [ route for route in app.routes if hasattr(route, "path") and route.path == "/api/admin/trading/approval-config" ] assert len(approval_config_routes) > 0, ( "No dedicated endpoint exists for PUT /api/admin/trading/approval-config. " "The existing PUT /api/admin/trading/config replaces the entire config JSON, " "and no UI calls it with operator_approval fields. " "This means there is no way for the operator to toggle auto_approve_paper." ) # --------------------------------------------------------------------------- # Strategies for Preservation Properties # --------------------------------------------------------------------------- def _live_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]: """Generate random PortfolioRiskConfig instances in LIVE mode.""" return st.builds( PortfolioRiskConfig, trading_mode=st.just(TradingMode.LIVE), operator_approval=st.builds( OperatorApproval, auto_approve_paper=st.booleans(), require_approval_for_live=st.booleans(), approval_timeout_minutes=st.integers(min_value=1, max_value=1440), ), ) def _disabled_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]: """Generate random PortfolioRiskConfig instances in DISABLED mode.""" return st.builds( PortfolioRiskConfig, trading_mode=st.just(TradingMode.DISABLED), operator_approval=st.builds( OperatorApproval, auto_approve_paper=st.booleans(), require_approval_for_live=st.booleans(), approval_timeout_minutes=st.integers(min_value=1, max_value=1440), ), ) def _paper_mode_auto_approve_config() -> st.SearchStrategy[PortfolioRiskConfig]: """Generate random PortfolioRiskConfig instances in PAPER mode with auto_approve_paper=True.""" return st.builds( PortfolioRiskConfig, trading_mode=st.just(TradingMode.PAPER), operator_approval=st.builds( OperatorApproval, auto_approve_paper=st.just(True), require_approval_for_live=st.booleans(), approval_timeout_minutes=st.integers(min_value=1, max_value=1440), ), ) # --------------------------------------------------------------------------- # Property 2: Preservation — Non-Paper-Toggle Approval Behavior # --------------------------------------------------------------------------- class TestPreservationProperties: """Preservation tests for non-buggy approval behavior. **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6** These tests capture the existing correct behavior of requires_approval() for inputs that are NOT affected by the bug (i.e., non-paper-toggle scenarios). They must PASS on unfixed code and continue to PASS after the fix is applied, ensuring no regressions. """ @given(config=_live_mode_config()) @settings(max_examples=100) def test_live_mode_result_equals_require_approval_for_live( self, config: PortfolioRiskConfig, ) -> None: """For all LIVE mode configs, requires_approval() returns the value of require_approval_for_live. Observed behavior on unfixed code: - requires_approval(config, LIVE) returns True when require_approval_for_live=True - requires_approval(config, LIVE) returns False when require_approval_for_live=False """ result = requires_approval(config, TradingMode.LIVE) expected = config.operator_approval.require_approval_for_live assert result is expected, ( f"LIVE mode: requires_approval() returned {result}, " f"expected {expected} (require_approval_for_live={expected}). " f"Config: {config.operator_approval}" ) @given(config=_disabled_mode_config()) @settings(max_examples=100) def test_disabled_mode_always_returns_false( self, config: PortfolioRiskConfig, ) -> None: """For all DISABLED mode configs, requires_approval() always returns False. Observed behavior on unfixed code: - requires_approval(config, DISABLED) returns False for all configs Orders are blocked upstream by the risk engine in disabled mode, so the approval gate is irrelevant. """ result = requires_approval(config, TradingMode.DISABLED) assert result is False, ( f"DISABLED mode: requires_approval() returned {result}, " f"expected False. Config: {config.operator_approval}" ) @given(config=_paper_mode_auto_approve_config()) @settings(max_examples=100) def test_paper_mode_auto_approve_true_returns_false( self, config: PortfolioRiskConfig, ) -> None: """For all PAPER mode configs with auto_approve_paper=True, requires_approval() always returns False. Observed behavior on unfixed code: - requires_approval(config, PAPER) returns False when auto_approve_paper=True This is the default (non-buggy) paper mode behavior — paper orders are auto-approved when the setting is True. """ result = requires_approval(config, TradingMode.PAPER) assert result is False, ( f"PAPER mode with auto_approve_paper=True: requires_approval() " f"returned {result}, expected False. Config: {config.operator_approval}" )