from __future__ import annotations import json import tempfile import threading import time import urllib.request from http.server import ThreadingHTTPServer from pathlib import Path from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService from cognitive_agent.auth import AuthConfig, AuthRuntime from cognitive_agent.server import SeatDemoHandler ROOT = Path(__file__).resolve().parents[2] class TestFirmwareWorkbenchApi: @classmethod def setup_class(cls) -> None: cls.temporary_directory = tempfile.TemporaryDirectory() root = Path(cls.temporary_directory.name) image = root / "Seat_Controller_demo.hex" image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80) def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path: log("info", f"IAR task {task_id}: 0 errors, 0 warnings") return image cls.workbench = FirmwareWorkbenchService( ROOT, artifact_root=root / "workbench", ota_root=root / "ota", build_runner=build_runner, ) cls.original_workbench = SeatDemoHandler.firmware_workbench cls.original_auth = SeatDemoHandler.auth SeatDemoHandler.firmware_workbench = cls.workbench SeatDemoHandler.auth = AuthRuntime( AuthConfig( enabled=False, username="", password_hash="", session_secret=b"t" * 64, session_ttl_seconds=3600, login_attempts=5, login_window_seconds=300, requests_per_minute=120, trust_proxy_headers=False, ) ) 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) cls.workbench.close() SeatDemoHandler.firmware_workbench = cls.original_workbench SeatDemoHandler.auth = cls.original_auth cls.temporary_directory.cleanup() @classmethod def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]: request = urllib.request.Request( f"{cls.base_url}{path}", method="POST", headers={"Content-Type": "application/json"}, data=json.dumps(payload, ensure_ascii=False).encode(), ) with urllib.request.urlopen(request, timeout=5) as response: return response.status, json.loads(response.read().decode()) def test_real_http_task_publish_detail_and_download_flow(self) -> None: instruction = "增加位置控制、座椅通风和座椅加热功能" status, created = self.post( "/api/firmware-workbench/tasks", {"instruction": instruction} ) assert status == 202 task_id = str(created["task"]["id"]) deadline = time.monotonic() + 5 while time.monotonic() < deadline: with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5 ) as response: task = json.loads(response.read().decode()) if task["status"] not in {"waiting", "running", "pending_confirmation"}: break time.sleep(0.03) assert task["status"] == "success" assert task["message"] == "已发布,待车机端下载升级" with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5 ) as response: latest = json.loads(response.read().decode()) assert latest["release_id"] == task["release_id"] assert latest["download_url"].endswith(f"/{task['release_id']}/download") repeated_status, repeated = self.post( "/api/firmware-workbench/tasks", {"instruction": instruction} ) assert repeated_status == 202 assert repeated["task"]["id"] != task_id repeated_task_id = str(repeated["task"]["id"]) deadline = time.monotonic() + 5 while time.monotonic() < deadline: with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5 ) as response: repeated_task = json.loads(response.read().decode()) if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}: break time.sleep(0.03) assert repeated_task["status"] == "success" artifact_id = task["artifact_id"] with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5 ) as response: artifact = json.loads(response.read().decode()) assert artifact["status"] == "published" assert artifact["sha256"] release_id = task["release_id"] with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download", timeout=5, ) as response: package = response.read() assert response.headers["Content-Type"] == "application/zip" assert "attachment" in response.headers["Content-Disposition"] assert len(package) == artifact["size_bytes"] delete_request = urllib.request.Request( f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", method="DELETE", ) with urllib.request.urlopen(delete_request, timeout=5) as response: deleted = json.loads(response.read().decode()) assert deleted["deleted"] is True repeated_artifact_id = str(repeated_task["artifact_id"]) repeated_delete_request = urllib.request.Request( f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}", method="DELETE", ) with urllib.request.urlopen(repeated_delete_request, timeout=5) as response: assert json.loads(response.read().decode())["deleted"] is True with urllib.request.urlopen( f"{self.base_url}/api/firmware-workbench/state", timeout=5 ) as response: state = json.loads(response.read().decode()) assert [item["id"] for item in state["artifacts"]] == [ "fw-art-time-only-baseline" ] with urllib.request.urlopen( f"{self.base_url}/firmware-workbench.html", timeout=5 ) as response: page = response.read().decode() assert response.headers["X-Frame-Options"] == "SAMEORIGIN" assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"] assert "座椅固件开发工作台" in page