fix: operator approval workflow — add approval toggle, lockout CRUD, and PBT tests

- 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
This commit is contained in:
Celes Renata
2026-04-17 06:14:46 +00:00
parent 3b7ded37cc
commit b149f70507
9 changed files with 1035 additions and 5 deletions
+249
View File
@@ -0,0 +1,249 @@
"""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}"
)