b149f70507
- Add GET/PUT /api/admin/trading/approval-config endpoints - Add POST/DELETE /api/admin/trading/lockouts endpoints - Add useApprovalConfig, useUpdateApprovalConfig, useCreateLockout, useDeleteLockout hooks - Add Paper Order Approval toggle card with confirmation dialog - Add lockout creation form and delete button to Active Lockouts card - Add MSW handlers for all new endpoints - Add property-based tests for bug condition exploration and preservation
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""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}"
|
|
)
|