feat: 完善服务端工程化、发布流程和自动化测试
This commit is contained in:
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user