199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
import zipfile
|
|
from http.server import ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
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())
|
|
|
|
@pytest.mark.parametrize("instruction,capabilities", [
|
|
("增加位置控制、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热"]),
|
|
("增加动态木马功能", ["时间控制", "位置控制", "动态木马"]),
|
|
("增加动态木马、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"]),
|
|
])
|
|
def test_real_http_task_publish_detail_and_download_flow(
|
|
self, instruction: str, capabilities: list[str], monkeypatch
|
|
) -> None:
|
|
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"]
|
|
assert artifact["capabilities"] == capabilities
|
|
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
|
assert json.loads(archive.read("manifest.json"))["capabilities"] == capabilities
|
|
|
|
frames = []
|
|
|
|
def publish(frame):
|
|
frames.append(frame)
|
|
return {"status": "published"}
|
|
|
|
monkeypatch.setattr(
|
|
SeatDemoHandler, "service", SimpleNamespace(publish_firmware_notification=publish)
|
|
)
|
|
notify_status, notified = self.post(
|
|
f"/api/firmware-workbench/artifacts/{artifact_id}/notify", {}
|
|
)
|
|
assert notify_status == 200
|
|
assert notified["published_count"] == 3
|
|
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
|
|
assert all(frame["firmware"]["release_id"] == release_id for frame in frames)
|
|
|
|
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
|