180 lines
6.5 KiB
Python
180 lines
6.5 KiB
Python
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
|