from __future__ import annotations import json import threading import urllib.error import urllib.request from http.server import ThreadingHTTPServer from pathlib import Path import pytest from cognitive_agent.application.self_repair import SelfRepairService from cognitive_agent.auth import AuthConfig, AuthRuntime from cognitive_agent.server import SeatDemoHandler def _auth_disabled() -> AuthRuntime: return AuthRuntime( AuthConfig( enabled=False, username="", password_hash="", session_secret=b"", session_ttl_seconds=3600, login_attempts=5, login_window_seconds=300, requests_per_minute=120, trust_proxy_headers=False, ) ) class TestSelfRepairApi: @classmethod def setup_class(cls) -> None: cls.repair = SelfRepairService() cls.original_repair = SeatDemoHandler.repair cls.original_auth = SeatDemoHandler.auth SeatDemoHandler.repair = cls.repair SeatDemoHandler.auth = _auth_disabled() cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler) cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) cls.thread.start() cls.base_url = f"http://127.0.0.1:{cls.server.server_port}" @classmethod def teardown_class(cls) -> None: cls.server.shutdown() cls.server.server_close() cls.thread.join(timeout=2) SeatDemoHandler.repair = cls.original_repair SeatDemoHandler.auth = cls.original_auth def request( self, path: str, *, method: str = "GET", payload: dict[str, object] | None = None, role: str | None = "admin", ) -> urllib.request.Request: headers = {"Accept": "application/json"} data = None if role is not None: headers["X-Cognitive-Agent-Role"] = role if payload is not None: headers["Content-Type"] = "application/json" data = json.dumps(payload).encode("utf-8") return urllib.request.Request( f"{self.base_url}{path}", method=method, headers=headers, data=data ) def test_user_cannot_call_self_repair_api(self) -> None: for request in ( self.request("/api/self-repair/state", role="user"), self.request( "/api/self-repair/healing/start", method="POST", payload={"case_id": "FI-FIELD-01", "execution_mode": "shadow"}, role="user", ), ): with pytest.raises(urllib.error.HTTPError) as caught: urllib.request.urlopen(request, timeout=3) assert caught.value.code == 403 body = json.loads(caught.value.read().decode("utf-8")) assert body["error"] == "admin_required" def test_admin_can_read_and_start_shadow_repair(self) -> None: with urllib.request.urlopen( self.request("/api/self-repair/state"), timeout=3 ) as response: state = json.loads(response.read().decode("utf-8")) assert state["schema"] == "seat-self-healing-console-state-v1" assert len(state["scenarios"]) == 6 assert state["hardware"]["connector_enabled_count"] == 21 with urllib.request.urlopen( self.request( "/api/self-repair/runtime", method="POST", payload={"active": True, "execution_mode": "shadow"}, ), timeout=3, ) as response: runtime = json.loads(response.read().decode("utf-8"))["runtime"] assert runtime["active"] is True with urllib.request.urlopen( self.request( "/api/self-repair/healing/start", method="POST", payload={"case_id": "FI-FIELD-01", "execution_mode": "shadow"}, ), timeout=3, ) as response: started = json.loads(response.read().decode("utf-8"))["healing"] assert started["case_id"] == "FI-FIELD-01" assert started["execution_mode"] == "shadow" assert started["injection_scope"] == "SHADOW_TELEMETRY_ONLY" with urllib.request.urlopen( self.request( "/api/self-repair/healing/reset", method="POST", payload={"execution_mode": "shadow"}, ), timeout=3, ) as response: reset = json.loads(response.read().decode("utf-8")) assert reset["ok"] is True assert reset["healing"]["phase"] == "IDLE" def test_rule_self_test_is_available_without_hardware(self) -> None: with urllib.request.urlopen( self.request( "/api/self-repair/hardware/self-test", method="POST", payload={} ), timeout=5, ) as response: result = json.loads(response.read().decode("utf-8")) assert result["ok"] is True assert result["self_test"]["result"] == "PASS" def test_frontend_assets_and_role_transition_are_removed(self) -> None: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") with urllib.request.urlopen( f"{self.base_url}/console-v2.js?v=desktop-refactor-31", timeout=3 ) as response: console_script = response.read().decode("utf-8") assert "self-repair" not in html assert '"admin"' not in console_script for path in ( "/self-repair.js", "/self-repair.css", "/self-repair-seat3d.js", ): with pytest.raises(urllib.error.HTTPError) as missing_asset: urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) assert missing_asset.value.code == 404 def test_self_repair_bridge_uses_the_backend_runtime_directory() -> None: source = ( Path(__file__).resolve().parents[2] / "tools" / "zcan" / "seat_self_repair_bridge.py" ).read_text(encoding="utf-8") assert '"artifacts", "tmp", "self-repair", "runtime"' in source assert 'os.path.join(BASE_DIR, "seat_function_runtime")' not in source assert "A silent or disconnected CAN bus must not kill" in source assert 'self.message("GUI STOP_ALL发送失败' in source assert 'self.message("GUI thermal off发送失败' in source assert "if opcode == 0x01 and self.latest_system is not None" in source assert "A bounded\n # timed command is deliberately allowed" in source