feat: 完善服务端工程化、发布流程和自动化测试

This commit is contained in:
2026-07-16 08:36:12 +00:00
parent 0b71e8d024
commit 5d7aa560e5
29 changed files with 822 additions and 7987 deletions
+86
View File
@@ -0,0 +1,86 @@
import importlib
import os
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def purge_server_modules():
for name in list(sys.modules):
if name == "main" or name == "db" or name == "minio_tool" or name.startswith("app."):
sys.modules.pop(name, None)
def write_test_private_key(path: Path):
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
class TestPasswordHasher:
def hash(self, password: str) -> str:
return "test-hash:" + password
def verify(self, password_hash: str, password: str) -> bool:
return password_hash == self.hash(password)
@pytest.fixture()
def server_module(tmp_path, monkeypatch):
key_path = tmp_path / "keys" / "manifest_private_key.pem"
write_test_private_key(key_path)
env = {
"ENV_FILE_PATH": str(tmp_path / ".env"),
"DB_FILE": str(tmp_path / "mini.db"),
"SQL_FILE": str(PROJECT_ROOT / "tables.sql"),
"ADMIN_TOKEN": "AdminTokenForTests2026",
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "AdminPass2026",
"ADMIN_JWT_SECRET": "AdminJwtSecretForTests2026",
"CLIENT_API_TOKEN": "ClientTokenForTests2026",
"CRASH_REPORT_TOKEN": "CrashReportTokenForTests2026",
"CRASH_SYMBOL_TOKEN": "CrashSymbolTokenForTests2026",
"CRASH_ADMIN_TOKEN": "CrashAdminTokenForTests2026",
"LICENSE_KEY_ENCRYPTION_SECRET": "LicenseEncryptionSecretForTests2026",
"MANIFEST_PRIVATE_KEY_PATH": str(key_path),
"MINIO_AUTO_START": "false",
"MINIO_ENDPOINT": "127.0.0.1:1",
"MINIO_ACCESS_KEY": "minio_test",
"MINIO_SECRET_KEY": "minio_test_secret",
"MINIO_BUCKET": "updates",
"MINIO_PUBLIC_ENDPOINT": "",
"MINIO_CONNECT_TIMEOUT_SEC": "0.1",
"MINIO_READ_TIMEOUT_SEC": "0.1",
"MINIO_RETRY_TOTAL": "0",
"LOCAL_UPLOAD_ROOT": str(tmp_path / "local_uploads"),
"MINIO_DATA_DIR": str(tmp_path / "minio_data"),
"UPLOAD_SPOOL_DIR": str(tmp_path / "upload_spool"),
"CRASH_STORAGE_ROOT": str(tmp_path / "crash_storage"),
"TARGET_PLATFORM": "windows",
"TARGET_ARCH": "x64",
"RELEASE_MAIN_EXECUTABLE": "bin/SimCAE.exe",
}
for key, value in env.items():
monkeypatch.setenv(key, value)
monkeypatch.chdir(PROJECT_ROOT)
purge_server_modules()
main = importlib.import_module("main")
security = importlib.import_module("app.core.security")
security.PASSWORD_HASHER = TestPasswordHasher()
yield main
purge_server_modules()
+133
View File
@@ -0,0 +1,133 @@
import base64
import asyncio
import json
from types import SimpleNamespace
def fake_request(**kwargs):
return SimpleNamespace(
headers=kwargs.get("headers", {}),
client=SimpleNamespace(host=kwargs.get("host", "127.0.0.1")),
state=SimpleNamespace(**kwargs.get("state", {})),
)
def add_app(app_id="testapp", app_name="Test App"):
from app.api.routes import admin_app_channel
from app.schemas.app_channel import AppCreateRequest
response = admin_app_channel.admin_add_app(AppCreateRequest(app_id=app_id, app_name=app_name))
assert response["msg"] == "应用创建成功"
def create_license(app_id="testapp", channel="stable"):
from app.api.routes import admin_license
from app.schemas.license import LicenseCreateRequest
response = admin_license.admin_license_create(
LicenseCreateRequest(
app_id=app_id,
channel=channel,
customer_name="自动化测试客户",
max_devices=1,
valid_until="2099-12-31T23:59:59Z",
)
)
return response["license_key"]
def encode_device_credential(issue_response: dict) -> str:
wrapper = {
"identity_text": issue_response["identity_text"],
"signature": issue_response["signature"],
}
return base64.b64encode(json.dumps(wrapper, separators=(",", ":")).encode()).decode()
async def run_admin_login_license_and_device_issue(server_module):
async with server_module.lifespan(server_module.app):
from app.api.routes import admin_auth, admin_license, client_update
from app.schemas.admin_auth import AdminLoginRequest
from app.schemas.client_update import CheckUpdateRequest, DeviceIssueRequest
login = admin_auth.login(
AdminLoginRequest(username="admin", password="AdminPass2026"),
fake_request(headers={"user-agent": "pytest"}),
)
assert login["success"] is True
assert login["data"]["accessToken"]
add_app()
license_key = create_license()
issue_data = await client_update.issue_device(
fake_request(),
DeviceIssueRequest(
app_id="testapp",
channel="stable",
license_key=license_key,
installation_id="install-test-000001",
machine_hash="ab" * 32,
),
)
assert issue_data["identity"]["app_id"] == "testapp"
assert issue_data["identity"]["channel"] == "stable"
credential = encode_device_credential(issue_data)
assert credential
check_data = await client_update.check_update(
fake_request(state={"device_identity": issue_data["identity"]}),
CheckUpdateRequest(
app_id="testapp",
channel="stable",
current_version="1.0.0",
client_protocol=3,
),
)
assert check_data["allow_run"] is True
assert check_data["release_available"] is False
licenses = admin_license.admin_license_list(app_id="testapp")
assert licenses["list"][0]["used_devices"] == 1
def test_admin_login_license_and_device_issue(server_module):
asyncio.run(run_admin_login_license_and_device_issue(server_module))
async def run_publish_payload_creates_version(server_module, tmp_path):
async with server_module.lifespan(server_module.app):
from app.api.routes import admin_version
from app.services import publish_service
add_app("publishapp", "Publish App")
release_dir = tmp_path / "release"
bin_dir = release_dir / "bin"
bin_dir.mkdir(parents=True)
exe_path = bin_dir / "SimCAE.exe"
readme_path = release_dir / "README.txt"
exe_path.write_bytes(b"fake exe")
readme_path.write_text("hello", encoding="utf-8")
result = await publish_service.publish_payload(
{
"upload_items": [
{"path": "bin/SimCAE.exe", "local_path": exe_path},
{"path": "README.txt", "local_path": readme_path},
],
"archive_upload": None,
"archive_temp_dir": None,
"app_id": "publishapp",
"channel": "stable",
"version": "1.0.1",
"client_protocol": 3,
}
)
assert result["file_count"] == 2
versions = admin_version.admin_get_version_list("publishapp")
assert any(item["version"] == "1.0.1" for item in versions["list"])
def test_publish_payload_creates_version(server_module, tmp_path):
asyncio.run(run_publish_payload_creates_version(server_module, tmp_path))
+113
View File
@@ -0,0 +1,113 @@
import hashlib
import io
import json
from types import SimpleNamespace
import uuid
def crash_metadata(client_report_id: str, minidump: bytes):
return {
"schemaVersion": 1,
"clientReportId": client_report_id,
"crashTimeUtc": "2026-07-16T00:00:00Z",
"product": "SIMCAE",
"appVersion": "1.0.0",
"gitCommit": "test-commit",
"buildType": "Release",
"channel": "stable",
"platform": {"os": "Windows", "arch": "x64"},
"crash": {"exceptionCode": "0xC0000005"},
"userConsent": {"upload": True},
"files": [
{
"kind": "minidump",
"name": "crash.dmp",
"sha256": hashlib.sha256(minidump).hexdigest(),
}
],
}
class FakeHeaders(dict):
def __init__(self, values: dict[str, str]):
super().__init__((key.lower(), value) for key, value in values.items())
def get(self, key: str, default=None):
return super().get(key.lower(), default)
class FakeUpload:
def __init__(self, filename: str, data: bytes):
self.filename = filename
self._stream = io.BytesIO(data)
async def read(self, size: int = -1) -> bytes:
return self._stream.read(size)
class FakeMultipartRequest:
def __init__(self, headers: dict[str, str], form: dict, host: str = "127.0.0.1"):
self.headers = FakeHeaders(headers)
self._form = form
self.client = SimpleNamespace(host=host)
async def form(self):
return self._form
async def run_crash_report_upload_detail_and_duplicate(server_module):
async with server_module.lifespan(server_module.app):
from app.services import crash_api_service
minidump = b"fake minidump bytes"
client_report_id = str(uuid.uuid4())
metadata = crash_metadata(client_report_id, minidump)
metadata_bytes = json.dumps(metadata).encode()
headers = {
"Authorization": "Bearer CrashReportTokenForTests2026",
"Idempotency-Key": client_report_id,
"Content-Type": "multipart/form-data; boundary=test",
"Content-Length": str(len(metadata_bytes) + len(minidump)),
"X-SimCAE-Client": "SIMCAE",
"X-SimCAE-Version": "1.0.0",
}
upload = await crash_api_service.create_crash_report(
FakeMultipartRequest(
headers,
{
"metadata": FakeUpload("metadata.json", metadata_bytes),
"minidump": FakeUpload("crash.dmp", minidump),
},
)
)
assert upload.status_code == 201
upload_body = json.loads(upload.body)
report_id = upload_body["reportId"]
duplicate = await crash_api_service.create_crash_report(
FakeMultipartRequest(
headers,
{
"metadata": FakeUpload("metadata.json", metadata_bytes),
"minidump": FakeUpload("crash.dmp", minidump),
},
)
)
assert duplicate.status_code == 200
assert json.loads(duplicate.body)["duplicate"] is True
detail = crash_api_service.get_crash_report_detail(
report_id,
FakeMultipartRequest(
{"Authorization": "Bearer CrashAdminTokenForTests2026"},
{},
),
)
assert detail["clientReportId"] == client_report_id
def test_crash_report_upload_detail_and_duplicate(server_module):
import asyncio
asyncio.run(run_crash_report_upload_detail_and_duplicate(server_module))