feat(market-intel): gate manual fetch behind mcp readiness
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-18 15:40:56 +08:00
parent d990316d74
commit 921e9eeb15
11 changed files with 429 additions and 13 deletions

View File

@@ -17,6 +17,7 @@ from services.market_intel.html_diagnostics import parse_html_diagnostics
from services.market_intel.mcp_activation_runbook import build_mcp_activation_runbook_preview
from services.market_intel.mcp_contract import build_mcp_tool_contract_preview
from services.market_intel.mcp_deploy_preflight import build_mcp_deploy_preflight_plan
from services.market_intel.mcp_fetch_gate import build_mcp_fetch_gate_preview
from services.market_intel.mcp_readiness import build_mcp_readiness_plan
from services.market_intel.platform_seed_db_diff import build_platform_seed_db_diff_plan
from services.market_intel.schema_db_probe import build_schema_db_probe_plan
@@ -99,6 +100,41 @@ def test_manual_discovery_fetch_is_blocked_when_flags_are_off():
assert result["runs"][0]["status"] == "blocked"
assert result["runs"][0]["network_allowed"] is False
assert result["runs"][0]["database_write_allowed"] is False
assert result["mcp_fetch_gate"]["network_request_allowed"] is False
assert result["runs"][0]["network_gate"]["manual_fetch_gate_open"] is False
def test_manual_runner_fetch_can_be_blocked_by_mcp_gate_even_when_flags_allow():
class RuntimeStatus:
enabled = True
crawler_enabled = True
called = {"count": 0}
def fake_get(*args, **kwargs):
called["count"] += 1
raise AssertionError("MCP gate 關閉時不應發 HTTP request")
gate = {
"manual_fetch_gate_open": False,
"network_request_allowed": False,
"blocked_reasons": ["mcp_router_enabled"],
"operator_message": "人工 fetch 仍被 MCP gate 阻擋",
}
adapter = get_adapter("momo")
runner = ManualDiscoveryRunner(
runtime_status=RuntimeStatus(),
http_get=fake_get,
network_allowed_override=False,
network_gate=gate,
)
result = runner.run(adapter, fetch=True).to_dict()
assert called["count"] == 0
assert result["status"] == "blocked"
assert result["network_allowed"] is False
assert result["network_gate"]["blocked_reasons"] == ["mcp_router_enabled"]
assert "MCP gate" in result["error_message"]
def test_manual_runner_fetch_uses_injected_http_get_when_allowed():
@@ -299,6 +335,8 @@ def test_candidate_preview_default_is_empty_and_does_not_call_network():
assert preview["candidate_count"] == 0
assert preview["database_write_allowed"] is False
assert preview["scheduler_attached"] is False
assert preview["mcp_fetch_gate_open"] is False
assert preview["mcp_fetch_gate"]["network_request_allowed"] is False
assert preview["run_statuses"][0]["status"] == "planned"
@@ -314,6 +352,8 @@ def test_candidate_preview_fetch_is_blocked_when_flags_are_off():
assert called["count"] == 0
assert preview["candidate_count"] == 0
assert preview["run_statuses"][0]["status"] == "blocked"
assert preview["mcp_fetch_gate_open"] is False
assert "market_intel_enabled" in preview["mcp_fetch_gate"]["blocked_reasons"]
def test_candidate_preview_aggregates_and_filters_by_band():
@@ -399,6 +439,10 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint():
assert "data-market-intel-mcp-activation-stages" in template
assert "data-market-intel-mcp-activation-safety" in template
assert "data-market-intel-mcp-activation-fallback" in template
assert "data-market-intel-mcp-fetch-gate" in template
assert "data-market-intel-mcp-fetch-gate-checks" in template
assert "data-market-intel-mcp-fetch-gate-sequence" in template
assert "data-market-intel-mcp-fetch-gate-readiness" in template
assert "data-market-intel-migration" in template
assert "data-market-intel-migration-tables" in template
assert "data-market-intel-approval" in template
@@ -415,6 +459,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint():
assert "market_intel.market_intel_mcp_readiness" in template
assert "market_intel.market_intel_mcp_deploy_preflight" in template
assert "market_intel.market_intel_mcp_activation_runbook" in template
assert "market_intel.market_intel_mcp_fetch_gate" in template
assert "market_intel.market_intel_migration_blueprint" in template
assert "market_intel.market_intel_write_approval_runbook" in template
assert "market_intel.market_intel_deployment_readiness" in template
@@ -440,7 +485,7 @@ def test_legacy_source_bridge_default_is_planned_only():
bridge = MarketIntelService().build_legacy_source_bridge()
assert bridge["mode"] == "legacy_source_bridge_planned"
assert bridge["phase"] == "phase_31_mcp_activation_runbook_preview"
assert bridge["phase"] == "phase_32_mcp_fetch_gate_preview"
assert bridge["execute_requested"] is False
assert bridge["read_only_query_executed"] is False
assert bridge["database_connection_opened"] is False
@@ -598,7 +643,7 @@ def test_mcp_tool_contract_preview_is_read_only_and_whitelisted():
contract = MarketIntelService().build_mcp_tool_contract()
assert contract["mode"] == "mcp_tool_contract_preview"
assert contract["phase"] == "phase_31_mcp_activation_runbook_preview"
assert contract["phase"] == "phase_32_mcp_fetch_gate_preview"
assert contract["caller"] == "market_intel"
assert contract["contract_ready"] is True
assert contract["blocked_reasons"] == []
@@ -731,7 +776,7 @@ def test_mcp_activation_runbook_route_is_preview_only():
assert response.status_code == 200
assert data["mode"] == "mcp_activation_runbook_preview"
assert data["phase"] == "phase_31_mcp_activation_runbook_preview"
assert data["phase"] == "phase_32_mcp_fetch_gate_preview"
assert data["deployment_actions_executed"] is False
assert data["docker_command_executed"] is False
assert data["ssh_command_executed"] is False
@@ -740,6 +785,88 @@ def test_mcp_activation_runbook_route_is_preview_only():
assert data["scheduler_attached"] is False
def test_mcp_fetch_gate_default_blocks_external_fetch():
gate = MarketIntelService().build_mcp_fetch_gate(fetch_requested=True)
assert gate["mode"] == "mcp_fetch_gate_planned"
assert gate["phase"] == "phase_32_mcp_fetch_gate_preview"
assert gate["fetch_requested"] is True
assert gate["manual_fetch_gate_open"] is False
assert gate["network_request_allowed"] is False
assert gate["would_use_external_network"] is False
assert gate["database_session_created"] is False
assert gate["database_write_executed"] is False
assert gate["database_commit_executed"] is False
assert gate["external_network_executed"] is False
assert gate["scheduler_attached"] is False
assert gate["writes_executed"] is False
assert gate["would_write_database"] is False
assert "market_intel_enabled" in gate["blocked_reasons"]
assert "market_intel_crawler_enabled" in gate["blocked_reasons"]
assert "mcp_readiness_executed" in gate["blocked_reasons"]
assert "mcp_router_enabled" in gate["blocked_reasons"]
def test_mcp_fetch_gate_can_open_with_mocked_ready_state():
class RuntimeStatus:
enabled = True
crawler_enabled = True
database_write_allowed = False
scheduler_attached = False
readiness = {
"mode": "mcp_readiness_read_only",
"execute_requested": True,
"router_enabled": True,
"external_mcp_complete": True,
"internal_mcp_complete": True,
"market_intel_mcp_integrated": True,
"blocked_reasons": [],
"readiness_checks": {
"market_intel_tool_contract_ready": True,
"external_servers_all_healthy": True,
},
}
gate = build_mcp_fetch_gate_preview(
RuntimeStatus(),
fetch_requested=True,
execute_readiness=True,
readiness=readiness,
)
assert gate["mode"] == "mcp_fetch_gate_read_only"
assert gate["manual_fetch_prerequisites_met"] is True
assert gate["manual_fetch_gate_open"] is True
assert gate["network_request_allowed"] is True
assert gate["blocked_reasons"] == []
assert gate["database_write_executed"] is False
assert gate["scheduler_attached"] is False
def test_mcp_fetch_gate_route_is_preview_only():
from routes.market_intel_routes import market_intel_bp
app = Flask(__name__)
app.secret_key = "test-secret"
app.register_blueprint(market_intel_bp)
client = app.test_client()
with client.session_transaction() as session:
session["logged_in"] = True
response = client.get("/api/market_intel/mcp_fetch_gate")
data = response.get_json()
assert response.status_code == 200
assert data["mode"] == "mcp_fetch_gate_planned"
assert data["phase"] == "phase_32_mcp_fetch_gate_preview"
assert data["fetch_requested"] is False
assert data["network_request_allowed"] is False
assert data["external_network_executed"] is False
assert data["database_write_executed"] is False
assert data["scheduler_attached"] is False
def test_mcp_deploy_preflight_blocks_without_required_env():
preflight = build_mcp_deploy_preflight_plan(env={})
@@ -803,7 +930,7 @@ def test_mcp_deploy_preflight_route_is_preview_only():
assert response.status_code == 200
assert data["mode"] == "mcp_external_deploy_preflight_preview"
assert data["phase"] == "phase_31_mcp_activation_runbook_preview"
assert data["phase"] == "phase_32_mcp_fetch_gate_preview"
assert data["deployment_actions_executed"] is False
assert data["docker_command_executed"] is False
assert data["ssh_command_executed"] is False
@@ -818,7 +945,7 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch):
readiness = MarketIntelService().build_mcp_readiness()
assert readiness["mode"] == "mcp_readiness_planned"
assert readiness["phase"] == "phase_31_mcp_activation_runbook_preview"
assert readiness["phase"] == "phase_32_mcp_fetch_gate_preview"
assert readiness["execute_requested"] is False
assert readiness["router_enabled"] is False
assert readiness["external_mcp_complete"] is False
@@ -1204,6 +1331,7 @@ def test_deployment_readiness_reports_app_only_release_gate():
assert readiness["checks"]["mcp_tool_contract_ready"] is True
assert readiness["checks"]["mcp_deploy_preflight_preview_safe"] is True
assert readiness["checks"]["mcp_activation_runbook_preview_safe"] is True
assert readiness["checks"]["mcp_fetch_gate_preview_safe"] is True
assert readiness["checks"]["writer_plan_dry_run_only"] is True
assert readiness["writer_plan_summary"]["writes_executed"] is False
assert "readiness_checks_not_all_passed" not in readiness["blocked_reasons"]
@@ -1223,6 +1351,7 @@ def test_deployment_readiness_reports_app_only_release_gate():
assert "/api/market_intel/mcp_tool_contract" in readiness["production_smoke_targets"]
assert "/api/market_intel/mcp_deploy_preflight" in readiness["production_smoke_targets"]
assert "/api/market_intel/mcp_activation_runbook" in readiness["production_smoke_targets"]
assert "/api/market_intel/mcp_fetch_gate" in readiness["production_smoke_targets"]
assert readiness["write_approval_runbook"]["ready_for_real_write"] is False
assert readiness["write_approval_runbook"]["writes_executed"] is False
assert readiness["migration_blueprint"]["migration_executed"] is False
@@ -1239,6 +1368,8 @@ def test_deployment_readiness_reports_app_only_release_gate():
assert readiness["mcp_deploy_preflight"]["docker_command_executed"] is False
assert readiness["mcp_activation_runbook"]["deployment_actions_executed"] is False
assert readiness["mcp_activation_runbook"]["docker_command_executed"] is False
assert readiness["mcp_fetch_gate"]["network_request_allowed"] is False
assert readiness["mcp_fetch_gate"]["external_network_executed"] is False
def test_write_approval_runbook_is_read_only_and_blocks_real_write():