mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 15:43:54 +08:00
feat: add FastAPI manager API compatibility baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Executable Java/FastAPI compatibility-test support."""
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from tests.compatibility.differential_runner import ContractRunner, _build_report
|
||||
from tests.compatibility.seed_contract_data import ADMIN_TOKEN, SERVER_SECRET
|
||||
|
||||
TARGET_ROOT = Path(__file__).resolve().parents[2]
|
||||
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
|
||||
PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}")
|
||||
MISSING_LONG_ID = "9223372036854775806"
|
||||
PASSWORD_CHARACTERS = frozenset(string.ascii_letters + string.digits + "!@#$%^&*()")
|
||||
|
||||
# These create endpoints accept an object whose fields are largely optional. An
|
||||
# empty JSON object could therefore create an isolated row instead of exercising
|
||||
# validation. Omitting the body is deterministic on both implementations and
|
||||
# keeps this runner read-only with respect to intentional business writes.
|
||||
NO_BODY_ROUTES = {
|
||||
("POST", "/admin/dict/data/save"),
|
||||
("POST", "/admin/dict/type/save"),
|
||||
("POST", "/agent/template"),
|
||||
("POST", "/agent/voice-print"),
|
||||
("POST", "/datasets"),
|
||||
("POST", "/device/manual-add"),
|
||||
("POST", "/device/register"),
|
||||
}
|
||||
|
||||
MULTIPART_ROUTES = {
|
||||
("POST", "/datasets/{dataset_id}/documents"),
|
||||
("POST", "/otaMag/upload"),
|
||||
("POST", "/otaMag/uploadAssetsBin"),
|
||||
("POST", "/voiceClone/upload"),
|
||||
}
|
||||
|
||||
NUMERIC_ID_CONTROLLERS = {
|
||||
"AdminController",
|
||||
"SysDictDataController",
|
||||
"SysDictTypeController",
|
||||
"SysParamsController",
|
||||
}
|
||||
|
||||
# Hibernate Validator exposes constraint violations as a Set, so an empty body
|
||||
# can legitimately select a different first message on two otherwise identical
|
||||
# processes. Each payload below leaves exactly one declared constraint invalid;
|
||||
# the resulting full response remains suitable for an exact comparison and the
|
||||
# request cannot reach a successful write.
|
||||
SINGLE_CONSTRAINT_PAYLOADS: dict[tuple[str, str], dict[str, Any]] = {
|
||||
("POST", "/admin/params"): {
|
||||
"paramCode": "contract.missing.type",
|
||||
"paramValue": "contract-value",
|
||||
},
|
||||
("PUT", "/admin/params"): {
|
||||
"id": MISSING_LONG_ID,
|
||||
"paramCode": "contract.missing.value",
|
||||
"valueType": "string",
|
||||
},
|
||||
("POST", "/admin/server/emit-action"): {
|
||||
"targetWs": "contract-missing-ws",
|
||||
},
|
||||
("POST", "/agent/chat-history/report"): {
|
||||
"macAddress": "AA:BB:CC:DD:EE:FC",
|
||||
"sessionId": "contract-missing-content",
|
||||
"chatType": 1,
|
||||
},
|
||||
("POST", "/config/agent-models"): {
|
||||
"macAddress": "AA:BB:CC:DD:EE:FB",
|
||||
"clientId": "contract-missing-selected-module",
|
||||
},
|
||||
("POST", "/correct-word/file"): {
|
||||
"fileName": "contract-invalid.txt",
|
||||
},
|
||||
("PUT", "/correct-word/file/{fileId}"): {
|
||||
"fileName": "contract-invalid.txt",
|
||||
},
|
||||
("PUT", "/device/address-book/alias"): {
|
||||
"targetMac": "AA:BB:CC:DD:EE:FD",
|
||||
},
|
||||
("PUT", "/device/address-book/permission"): {
|
||||
"macAddress": "AA:BB:CC:DD:EE:FE",
|
||||
},
|
||||
("POST", "/models/provider"): {
|
||||
"providerCode": "contract-provider-code",
|
||||
"name": "Contract Provider",
|
||||
"fields": "[]",
|
||||
"sort": 0,
|
||||
},
|
||||
("POST", "/ttsVoice"): {
|
||||
"languages": "zh",
|
||||
"name": "Contract Voice",
|
||||
"ttsModelId": "contract-missing-model",
|
||||
},
|
||||
("PUT", "/ttsVoice/{id}"): {
|
||||
"languages": "zh",
|
||||
"ttsModelId": "contract-missing-model",
|
||||
"ttsVoice": "contract-voice",
|
||||
},
|
||||
}
|
||||
|
||||
RANDOM_PASSWORD_ROUTE = ("PUT", "/admin/users/{id}")
|
||||
OTA_DOWNLOAD_UUID_ROUTE = ("GET", "/otaMag/getDownloadUrl/{id}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedRouteCase:
|
||||
method: str
|
||||
template: str
|
||||
path: str
|
||||
auth: str
|
||||
request_kwargs: dict[str, Any]
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str]:
|
||||
return self.method, self.template
|
||||
|
||||
|
||||
def _load_routes() -> list[dict[str, Any]]:
|
||||
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
|
||||
routes = cast(list[dict[str, Any]], manifest["routes"])
|
||||
if manifest["count"] != 154 or len(routes) != 154:
|
||||
raise RuntimeError("Java route manifest is not closed at 154 routes")
|
||||
return routes
|
||||
|
||||
|
||||
def _path_value(route: dict[str, Any], name: str) -> str:
|
||||
controller = str(route["controller"])
|
||||
if name == "userId" or (name == "id" and controller in NUMERIC_ID_CONTROLLERS):
|
||||
return MISSING_LONG_ID
|
||||
if name == "status":
|
||||
return "0" if controller in {"AdminController", "ModelController"} else "contract-missing-status"
|
||||
if name == "deviceCode":
|
||||
return "999999"
|
||||
if name == "macAddress":
|
||||
return "AA:BB:CC:DD:EE:FE"
|
||||
if name == "dictType":
|
||||
return "CONTRACT_MISSING_DICT_TYPE"
|
||||
if name == "modelType":
|
||||
return "CONTRACT_MISSING_MODEL_TYPE"
|
||||
if name == "provideCode":
|
||||
return "contract-missing-provider"
|
||||
return f"contract-missing-{name.lower()}"
|
||||
|
||||
|
||||
def _safe_path(route: dict[str, Any]) -> str:
|
||||
template = str(route["path"])
|
||||
return PATH_PARAMETER.sub(lambda match: _path_value(route, match.group(1)), template)
|
||||
|
||||
|
||||
def _headers(auth: str) -> dict[str, str]:
|
||||
if auth == "database-token":
|
||||
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
if auth == "server-secret":
|
||||
return {"Authorization": f"Bearer {SERVER_SECRET}"}
|
||||
return {}
|
||||
|
||||
|
||||
def build_cases() -> list[AuthenticatedRouteCase]:
|
||||
cases: list[AuthenticatedRouteCase] = []
|
||||
for route in _load_routes():
|
||||
method = str(route["method"])
|
||||
template = str(route["path"])
|
||||
auth = str(route["auth"])
|
||||
kwargs: dict[str, Any] = {}
|
||||
headers = _headers(auth)
|
||||
if headers:
|
||||
kwargs["headers"] = headers
|
||||
key = (method, template)
|
||||
if key in SINGLE_CONSTRAINT_PAYLOADS:
|
||||
kwargs["json"] = SINGLE_CONSTRAINT_PAYLOADS[key]
|
||||
elif method in {"POST", "PUT"} and key not in MULTIPART_ROUTES and key not in NO_BODY_ROUTES:
|
||||
kwargs["json"] = {}
|
||||
cases.append(
|
||||
AuthenticatedRouteCase(
|
||||
method=method,
|
||||
template=template,
|
||||
path=_safe_path(route),
|
||||
auth=auth,
|
||||
request_kwargs=kwargs,
|
||||
)
|
||||
)
|
||||
return cases
|
||||
|
||||
|
||||
def _valid_random_password(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 12
|
||||
and set(value) <= PASSWORD_CHARACTERS
|
||||
and any(character in string.digits for character in value)
|
||||
and any(character in string.ascii_lowercase for character in value)
|
||||
and any(character in string.ascii_uppercase for character in value)
|
||||
and any(character in "!@#$%^&*()" for character in value)
|
||||
)
|
||||
|
||||
|
||||
def _valid_uuid4(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
parsed = uuid.UUID(value)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
return parsed.version == 4 and str(parsed) == value
|
||||
|
||||
|
||||
def _normalize_dynamic_data(
|
||||
value: Any,
|
||||
*,
|
||||
validator: Callable[[Any], bool],
|
||||
placeholder: str,
|
||||
) -> Any:
|
||||
if (
|
||||
isinstance(value, dict)
|
||||
and value.get("code") == 0
|
||||
and validator(value.get("data"))
|
||||
):
|
||||
return {**value, "data": placeholder}
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_random_password(value: Any) -> Any:
|
||||
return _normalize_dynamic_data(
|
||||
value,
|
||||
validator=_valid_random_password,
|
||||
placeholder="<valid-random-password>",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_ota_download_uuid(value: Any) -> Any:
|
||||
return _normalize_dynamic_data(
|
||||
value,
|
||||
validator=_valid_uuid4,
|
||||
placeholder="<valid-uuid-v4>",
|
||||
)
|
||||
|
||||
|
||||
DYNAMIC_RESPONSES: dict[
|
||||
tuple[str, str], tuple[Callable[[Any], Any], Callable[[Any], bool], str]
|
||||
] = {
|
||||
RANDOM_PASSWORD_ROUTE: (
|
||||
_normalize_random_password,
|
||||
_valid_random_password,
|
||||
"random_password",
|
||||
),
|
||||
OTA_DOWNLOAD_UUID_ROUTE: (
|
||||
_normalize_ota_download_uuid,
|
||||
_valid_uuid4,
|
||||
"uuid_v4",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _validate_dynamic_response(
|
||||
runner: ContractRunner,
|
||||
*,
|
||||
java_body: Any,
|
||||
fastapi_body: Any,
|
||||
validator: Callable[[Any], bool],
|
||||
label: str,
|
||||
) -> None:
|
||||
result = runner.results[-1]
|
||||
validity = {
|
||||
f"java:{label}": isinstance(java_body, dict)
|
||||
and java_body.get("code") == 0
|
||||
and validator(java_body.get("data")),
|
||||
f"fastapi:{label}": isinstance(fastapi_body, dict)
|
||||
and fastapi_body.get("code") == 0
|
||||
and validator(fastapi_body.get("data")),
|
||||
}
|
||||
result.checks.update(validity)
|
||||
result.passed = all(result.checks.values())
|
||||
failed = [name for name, passed in validity.items() if not passed]
|
||||
if failed and result.difference is None:
|
||||
result.difference = "invalid dynamic response format: " + ", ".join(failed)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
|
||||
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
|
||||
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
|
||||
parser.add_argument("--mysql-port", type=int, default=13316)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("compatibility/authenticated-route-results.json"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
|
||||
try:
|
||||
for case in build_cases():
|
||||
dynamic = DYNAMIC_RESPONSES.get(case.key)
|
||||
java, fastapi = runner.request_pair(
|
||||
f"{case.method} {case.template}",
|
||||
f"authenticated-route:{case.auth}",
|
||||
case.method,
|
||||
case.path,
|
||||
normalize=dynamic[0] if dynamic else None,
|
||||
exact_headers=("content-type",),
|
||||
**case.request_kwargs,
|
||||
)
|
||||
if dynamic:
|
||||
_validate_dynamic_response(
|
||||
runner,
|
||||
java_body=java.body,
|
||||
fastapi_body=fastapi.body,
|
||||
validator=dynamic[1],
|
||||
label=dynamic[2],
|
||||
)
|
||||
finally:
|
||||
runner.close()
|
||||
|
||||
report = _build_report(runner)
|
||||
report["coverage"] = {
|
||||
"java_routes": len(runner.results),
|
||||
"request_profile": "authenticated-safe-business-or-validation",
|
||||
"side_effect_policy": "no intentional successful writes",
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
summary = report["summary"]
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
for result in runner.results:
|
||||
if not result.passed:
|
||||
print(f"FAIL {result.name}: {result.difference}")
|
||||
sys.exit(1 if summary["failed"] else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,952 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import asyncmy # type: ignore[import-untyped]
|
||||
import httpx
|
||||
from asyncmy.cursors import DictCursor # type: ignore[import-untyped]
|
||||
|
||||
from tests.compatibility.seed_contract_data import (
|
||||
ADMIN_ID,
|
||||
ADMIN_TOKEN,
|
||||
EXPIRED_TOKEN,
|
||||
MQTT_SIGNATURE_KEY,
|
||||
NORMAL_ID,
|
||||
NORMAL_TOKEN,
|
||||
SERVER_SECRET,
|
||||
)
|
||||
|
||||
JSONValue = dict[str, Any] | list[Any] | str | int | float | bool | None
|
||||
Normalizer = Callable[[JSONValue], JSONValue]
|
||||
SENSITIVE_REPORT_KEYS = {
|
||||
"accesstoken",
|
||||
"authorization",
|
||||
"mqttsignaturekey",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
"serversecret",
|
||||
"token",
|
||||
"websockettoken",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractResult:
|
||||
name: str
|
||||
category: str
|
||||
passed: bool
|
||||
checks: dict[str, bool]
|
||||
difference: str | None
|
||||
java: dict[str, Any]
|
||||
fastapi: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapturedResponse:
|
||||
status: int
|
||||
headers: dict[str, str | None]
|
||||
body: JSONValue
|
||||
body_sha256: str
|
||||
|
||||
|
||||
class ContractRunner:
|
||||
def __init__(
|
||||
self,
|
||||
java_base: str,
|
||||
fastapi_base: str,
|
||||
mock_base: str,
|
||||
mysql_port: int,
|
||||
):
|
||||
self.bases = {"java": java_base.rstrip("/"), "fastapi": fastapi_base.rstrip("/")}
|
||||
self.mock_base = mock_base.rstrip("/")
|
||||
self.mysql_port = mysql_port
|
||||
self.client = httpx.Client(timeout=20.0, follow_redirects=False)
|
||||
self.results: list[ContractResult] = []
|
||||
|
||||
def close(self) -> None:
|
||||
self.client.close()
|
||||
|
||||
@staticmethod
|
||||
def _capture(response: httpx.Response) -> CapturedResponse:
|
||||
content_type = response.headers.get("content-type")
|
||||
try:
|
||||
body: JSONValue = response.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
try:
|
||||
body = response.content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body = {"base64": base64.b64encode(response.content).decode()}
|
||||
return CapturedResponse(
|
||||
status=response.status_code,
|
||||
headers={
|
||||
"content-type": content_type,
|
||||
"content-disposition": response.headers.get("content-disposition"),
|
||||
"content-length": response.headers.get("content-length"),
|
||||
},
|
||||
body=body,
|
||||
body_sha256=hashlib.sha256(response.content).hexdigest(),
|
||||
)
|
||||
|
||||
def request_pair(
|
||||
self,
|
||||
name: str,
|
||||
category: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
normalize: Normalizer | None = None,
|
||||
exact_headers: tuple[str, ...] = (),
|
||||
**kwargs: Any,
|
||||
) -> tuple[CapturedResponse, CapturedResponse]:
|
||||
captured: dict[str, CapturedResponse] = {}
|
||||
for side, base in self.bases.items():
|
||||
captured[side] = self._capture(self.client.request(method, base + path, **kwargs))
|
||||
java, fastapi = captured["java"], captured["fastapi"]
|
||||
java_body = normalize(java.body) if normalize else java.body
|
||||
fastapi_body = normalize(fastapi.body) if normalize else fastapi.body
|
||||
checks = {
|
||||
"http_status": java.status == fastapi.status,
|
||||
"body": java_body == fastapi_body,
|
||||
}
|
||||
for header in exact_headers:
|
||||
checks[f"header:{header}"] = java.headers.get(header) == fastapi.headers.get(header)
|
||||
difference = _first_difference(java_body, fastapi_body)
|
||||
if difference is None and not checks["http_status"]:
|
||||
difference = f"HTTP status Java={java.status} FastAPI={fastapi.status}"
|
||||
if difference is None:
|
||||
for check, passed in checks.items():
|
||||
if not passed:
|
||||
difference = (
|
||||
f"{check} Java={java.headers.get(check.removeprefix('header:'))!r} "
|
||||
f"FastAPI={fastapi.headers.get(check.removeprefix('header:'))!r}"
|
||||
)
|
||||
break
|
||||
self.results.append(
|
||||
ContractResult(
|
||||
name=name,
|
||||
category=category,
|
||||
passed=all(checks.values()),
|
||||
checks=checks,
|
||||
difference=difference,
|
||||
java=_response_summary(java, java_body),
|
||||
fastapi=_response_summary(fastapi, fastapi_body),
|
||||
)
|
||||
)
|
||||
return java, fastapi
|
||||
|
||||
def add_custom(
|
||||
self,
|
||||
name: str,
|
||||
category: str,
|
||||
checks: Mapping[str, bool],
|
||||
*,
|
||||
difference: str | None = None,
|
||||
java: Mapping[str, Any] | None = None,
|
||||
fastapi: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
failed_checks = [check for check, passed in checks.items() if not passed]
|
||||
self.results.append(
|
||||
ContractResult(
|
||||
name=name,
|
||||
category=category,
|
||||
passed=not failed_checks,
|
||||
checks=dict(checks),
|
||||
difference=difference or (", ".join(failed_checks) if failed_checks else None),
|
||||
java=dict(java or {}),
|
||||
fastapi=dict(fastapi or {}),
|
||||
)
|
||||
)
|
||||
|
||||
def run_read_only_cases(self) -> None:
|
||||
self.request_pair("public configuration", "configuration", "GET", "/user/pub-config")
|
||||
languages: list[str | None] = [None, "zh-CN", "zh-TW", "en-US", "de-DE", "vi-VN", "pt-BR"]
|
||||
for language in languages:
|
||||
headers = {} if language is None else {"Accept-Language": language}
|
||||
self.request_pair(
|
||||
f"unauthenticated localized response [{language or 'default'}]",
|
||||
"authentication-i18n",
|
||||
"GET",
|
||||
"/user/info",
|
||||
headers=headers,
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
self.request_pair(
|
||||
"expired database token",
|
||||
"authentication",
|
||||
"GET",
|
||||
"/user/info",
|
||||
headers=_bearer(EXPIRED_TOKEN),
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
self.request_pair(
|
||||
"normal user forbidden from admin endpoint",
|
||||
"authorization",
|
||||
"GET",
|
||||
"/admin/users",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"Long user ID and token response",
|
||||
"serialization",
|
||||
"GET",
|
||||
"/user/info",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"user page Long/date/null structure",
|
||||
"serialization",
|
||||
"GET",
|
||||
"/admin/users?page=1&limit=10",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"agent list null/date fields",
|
||||
"agent",
|
||||
"GET",
|
||||
"/agent/list",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"device list UTC display and epoch fields",
|
||||
"device",
|
||||
"GET",
|
||||
"/device/bind/contract-agent-1",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"filtered model provider page",
|
||||
"model",
|
||||
"GET",
|
||||
"/models/provider?page=1&limit=10&name=Contract%20Provider",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"correct-word page",
|
||||
"correct-word",
|
||||
"GET",
|
||||
"/correct-word/file/list?page=1&limit=10",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
self.request_pair(
|
||||
"correct-word binary download headers and bytes",
|
||||
"binary-download",
|
||||
"GET",
|
||||
"/correct-word/file/download/contract-words",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
exact_headers=("content-type", "content-disposition", "content-length"),
|
||||
)
|
||||
|
||||
def run_validation_cases(self) -> None:
|
||||
normal = _bearer(NORMAL_TOKEN)
|
||||
admin = _bearer(ADMIN_TOKEN)
|
||||
self.request_pair(
|
||||
"minimum boundary validation",
|
||||
"validation",
|
||||
"PUT",
|
||||
"/device/update/contract-device-1",
|
||||
headers=normal,
|
||||
json={"autoUpdate": -1},
|
||||
)
|
||||
self.request_pair(
|
||||
"maximum boundary validation",
|
||||
"validation",
|
||||
"PUT",
|
||||
"/device/update/contract-device-1",
|
||||
headers=normal,
|
||||
json={"autoUpdate": 2},
|
||||
)
|
||||
self.request_pair(
|
||||
"UTF-16 string-length validation boundary",
|
||||
"validation",
|
||||
"PUT",
|
||||
"/device/update/contract-device-1",
|
||||
headers=normal,
|
||||
json={"alias": "x" * 65},
|
||||
)
|
||||
# Hibernate Validator returns a Set of constraint violations, so the
|
||||
# first of these five declared annotations is intentionally unstable.
|
||||
# Validate the exact declared set and envelope, not one random order.
|
||||
declared_messages = {
|
||||
"modelType不能为空",
|
||||
"providerCode不能为空",
|
||||
"name不能为空",
|
||||
"fields(JSON格式)不能为空",
|
||||
"sort不能为空",
|
||||
}
|
||||
java_responses = [
|
||||
self._capture(
|
||||
self.client.post(
|
||||
self.bases["java"] + "/models/provider",
|
||||
headers=admin,
|
||||
json={},
|
||||
)
|
||||
)
|
||||
for _ in range(10)
|
||||
]
|
||||
fastapi_response = self._capture(
|
||||
self.client.post(
|
||||
self.bases["fastapi"] + "/models/provider",
|
||||
headers=admin,
|
||||
json={},
|
||||
)
|
||||
)
|
||||
java_messages = {
|
||||
str(response.body.get("msg"))
|
||||
for response in java_responses
|
||||
if isinstance(response.body, dict)
|
||||
}
|
||||
fastapi_message = (
|
||||
str(fastapi_response.body.get("msg")) if isinstance(fastapi_response.body, dict) else None
|
||||
)
|
||||
self.add_custom(
|
||||
"required model-provider fields (unordered Java ConstraintViolation Set)",
|
||||
"validation",
|
||||
{
|
||||
"java_http_200": all(response.status == 200 for response in java_responses),
|
||||
"java_code_10034": all(
|
||||
isinstance(response.body, dict) and response.body.get("code") == 10034
|
||||
for response in java_responses
|
||||
),
|
||||
"java_messages_are_declared_constraints": bool(java_messages)
|
||||
and java_messages <= declared_messages,
|
||||
"fastapi_http_200": fastapi_response.status == 200,
|
||||
"fastapi_code_10034": isinstance(fastapi_response.body, dict)
|
||||
and fastapi_response.body.get("code") == 10034,
|
||||
"fastapi_message_is_declared_constraint": fastapi_message in declared_messages,
|
||||
},
|
||||
java={"observed_messages": sorted(java_messages), "declared_messages": sorted(declared_messages)},
|
||||
fastapi={"body": fastapi_response.body},
|
||||
)
|
||||
self.request_pair(
|
||||
"invalid pagination number format",
|
||||
"validation",
|
||||
"GET",
|
||||
"/admin/users?page=bad&limit=10",
|
||||
headers=admin,
|
||||
)
|
||||
|
||||
def run_server_auth_and_config(self) -> None:
|
||||
self.request_pair(
|
||||
"missing server secret",
|
||||
"server-secret",
|
||||
"POST",
|
||||
"/config/server-base",
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
self.request_pair(
|
||||
"invalid server secret",
|
||||
"server-secret",
|
||||
"POST",
|
||||
"/config/server-base",
|
||||
headers=_bearer("invalid-contract-secret"),
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
self.request_pair(
|
||||
"valid server secret runtime configuration",
|
||||
"server-secret",
|
||||
"POST",
|
||||
"/config/server-base",
|
||||
headers=_bearer(SERVER_SECRET),
|
||||
)
|
||||
|
||||
def run_ota_cases(self) -> None:
|
||||
self.request_pair(
|
||||
"OTA health MIME and body",
|
||||
"ota",
|
||||
"GET",
|
||||
"/ota/",
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
self.request_pair(
|
||||
"OTA missing required Device-Id",
|
||||
"ota-validation",
|
||||
"POST",
|
||||
"/ota/",
|
||||
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
|
||||
)
|
||||
self.request_pair(
|
||||
"OTA invalid Device-Id",
|
||||
"ota-validation",
|
||||
"POST",
|
||||
"/ota/",
|
||||
headers={"Device-Id": "not-a-mac"},
|
||||
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
|
||||
exact_headers=("content-type", "content-length"),
|
||||
)
|
||||
java, fastapi = self.request_pair(
|
||||
"OTA WebSocket/MQTT credentials",
|
||||
"ota-signing",
|
||||
"POST",
|
||||
"/ota/",
|
||||
headers={"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-client"},
|
||||
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
|
||||
normalize=_normalize_ota_dynamic,
|
||||
exact_headers=("content-type", "content-length"),
|
||||
)
|
||||
self.add_custom(
|
||||
"OTA HMAC/Base64 cryptographic validity",
|
||||
"ota-signing",
|
||||
{
|
||||
"java_credentials": _valid_ota_credentials(java.body),
|
||||
"fastapi_credentials": _valid_ota_credentials(fastapi.body),
|
||||
},
|
||||
java={"credential_shape": _ota_credential_shape(java.body)},
|
||||
fastapi={"credential_shape": _ota_credential_shape(fastapi.body)},
|
||||
)
|
||||
self.request_pair(
|
||||
"activation missing Device-Id",
|
||||
"activation",
|
||||
"POST",
|
||||
"/ota/activate",
|
||||
)
|
||||
self.request_pair(
|
||||
"activation unknown device",
|
||||
"activation",
|
||||
"POST",
|
||||
"/ota/activate",
|
||||
headers={"Device-Id": "FF:EE:DD:CC:BB:AA"},
|
||||
)
|
||||
self.request_pair(
|
||||
"activation bound device MIME",
|
||||
"activation",
|
||||
"POST",
|
||||
"/ota/activate",
|
||||
headers={"Device-Id": "AA:BB:CC:DD:EE:01"},
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
|
||||
def run_external_mock_case(self) -> None:
|
||||
self.client.delete(self.mock_base + "/__requests")
|
||||
java, fastapi = self.request_pair(
|
||||
"MQTT tools external mock response",
|
||||
"external-mock",
|
||||
"POST",
|
||||
"/device/tools/list/contract-device-1",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
records = self.client.get(self.mock_base + "/__requests").json().get("requests", [])
|
||||
recent = [record for record in records if str(record.get("path", "")).startswith("/api/commands/")]
|
||||
expected_token = hashlib.sha256(
|
||||
f"{datetime.now(tz=timezone.utc).date().isoformat()}{MQTT_SIGNATURE_KEY}".encode()
|
||||
).hexdigest()
|
||||
auth_values = [record.get("headers", {}).get("authorization") for record in recent]
|
||||
bodies = [record.get("body") for record in recent]
|
||||
self.add_custom(
|
||||
"MQTT external request format and daily auth",
|
||||
"external-mock",
|
||||
{
|
||||
"both_services_called_mock": len(recent) == 2,
|
||||
"request_bodies_equal": len(bodies) == 2 and bodies[0] == bodies[1],
|
||||
"daily_tokens_valid": len(auth_values) == 2
|
||||
and all(value == f"Bearer {expected_token}" for value in auth_values),
|
||||
"responses_compatible": java.body == fastapi.body,
|
||||
},
|
||||
java={"request": recent[0] if recent else None},
|
||||
fastapi={"request": recent[1] if len(recent) > 1 else None},
|
||||
)
|
||||
|
||||
def run_correct_word_crud(self) -> None:
|
||||
payload = {
|
||||
"fileName": "contract-run-created.txt",
|
||||
"content": ["xiaozhi|小智", "ESP32|esp32"],
|
||||
"fileSize": 31,
|
||||
}
|
||||
responses: dict[str, CapturedResponse] = {}
|
||||
identifiers: dict[str, str] = {}
|
||||
for side, base in self.bases.items():
|
||||
response = self._capture(
|
||||
self.client.post(base + "/correct-word/file", headers=_bearer(NORMAL_TOKEN), json=payload)
|
||||
)
|
||||
responses[side] = response
|
||||
if isinstance(response.body, dict):
|
||||
data = response.body.get("data")
|
||||
if isinstance(data, dict) and isinstance(data.get("id"), str):
|
||||
identifiers[side] = data["id"]
|
||||
java_normalized = _normalize_created_correct_word(responses["java"].body)
|
||||
fastapi_normalized = _normalize_created_correct_word(responses["fastapi"].body)
|
||||
self.add_custom(
|
||||
"correct-word create response",
|
||||
"crud",
|
||||
{
|
||||
"http_status": responses["java"].status == responses["fastapi"].status,
|
||||
"body": java_normalized == fastapi_normalized,
|
||||
"ids_returned": set(identifiers) == {"java", "fastapi"},
|
||||
},
|
||||
difference=_first_difference(java_normalized, fastapi_normalized),
|
||||
java={"body": java_normalized},
|
||||
fastapi={"body": fastapi_normalized},
|
||||
)
|
||||
if set(identifiers) != {"java", "fastapi"}:
|
||||
return
|
||||
side_effects = {
|
||||
side: _database_row(
|
||||
self.mysql_port,
|
||||
"manager_java_test" if side == "java" else "manager_fastapi_test",
|
||||
"SELECT file_name,word_count,content,creator FROM ai_agent_correct_word_file WHERE id=%s",
|
||||
(identifier,),
|
||||
)
|
||||
for side, identifier in identifiers.items()
|
||||
}
|
||||
self.add_custom(
|
||||
"correct-word create database side effect",
|
||||
"database-side-effect",
|
||||
{"row_equal": side_effects["java"] == side_effects["fastapi"]},
|
||||
difference=_first_difference(side_effects["java"], side_effects["fastapi"]),
|
||||
java=side_effects["java"],
|
||||
fastapi=side_effects["fastapi"],
|
||||
)
|
||||
|
||||
update = {"fileName": "contract-run-updated.txt", "content": ["A|B"], "fileSize": 3}
|
||||
update_responses: dict[str, CapturedResponse] = {}
|
||||
for side, identifier in identifiers.items():
|
||||
update_responses[side] = self._capture(
|
||||
self.client.put(
|
||||
self.bases[side] + f"/correct-word/file/{identifier}",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
json=update,
|
||||
)
|
||||
)
|
||||
self.add_custom(
|
||||
"correct-word update response",
|
||||
"crud",
|
||||
{
|
||||
"status": update_responses["java"].status == update_responses["fastapi"].status,
|
||||
"body": update_responses["java"].body == update_responses["fastapi"].body,
|
||||
},
|
||||
difference=_first_difference(update_responses["java"].body, update_responses["fastapi"].body),
|
||||
java={"body": update_responses["java"].body},
|
||||
fastapi={"body": update_responses["fastapi"].body},
|
||||
)
|
||||
updated_rows = {
|
||||
side: _database_row(
|
||||
self.mysql_port,
|
||||
"manager_java_test" if side == "java" else "manager_fastapi_test",
|
||||
"SELECT file_name,word_count,content,creator,updater FROM ai_agent_correct_word_file WHERE id=%s",
|
||||
(identifier,),
|
||||
)
|
||||
for side, identifier in identifiers.items()
|
||||
}
|
||||
self.add_custom(
|
||||
"correct-word update database side effect",
|
||||
"database-side-effect",
|
||||
{"row_equal": updated_rows["java"] == updated_rows["fastapi"]},
|
||||
difference=_first_difference(updated_rows["java"], updated_rows["fastapi"]),
|
||||
java=updated_rows["java"],
|
||||
fastapi=updated_rows["fastapi"],
|
||||
)
|
||||
downloads = {
|
||||
side: self._capture(
|
||||
self.client.get(
|
||||
self.bases[side] + f"/correct-word/file/download/{identifier}",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
)
|
||||
for side, identifier in identifiers.items()
|
||||
}
|
||||
self.add_custom(
|
||||
"correct-word updated binary download",
|
||||
"binary-download",
|
||||
{
|
||||
"status": downloads["java"].status == downloads["fastapi"].status,
|
||||
"bytes": downloads["java"].body_sha256 == downloads["fastapi"].body_sha256,
|
||||
"content_type": downloads["java"].headers["content-type"]
|
||||
== downloads["fastapi"].headers["content-type"],
|
||||
"content_disposition": downloads["java"].headers["content-disposition"]
|
||||
== downloads["fastapi"].headers["content-disposition"],
|
||||
"content_length": downloads["java"].headers["content-length"]
|
||||
== downloads["fastapi"].headers["content-length"],
|
||||
},
|
||||
java=asdict(downloads["java"]),
|
||||
fastapi=asdict(downloads["fastapi"]),
|
||||
)
|
||||
for side, identifier in identifiers.items():
|
||||
self.client.delete(
|
||||
self.bases[side] + f"/correct-word/file/{identifier}",
|
||||
headers=_bearer(NORMAL_TOKEN),
|
||||
)
|
||||
counts = {
|
||||
side: _database_scalar(
|
||||
self.mysql_port,
|
||||
"manager_java_test" if side == "java" else "manager_fastapi_test",
|
||||
"SELECT COUNT(*) FROM ai_agent_correct_word_file WHERE id=%s",
|
||||
(identifier,),
|
||||
)
|
||||
for side, identifier in identifiers.items()
|
||||
}
|
||||
self.add_custom(
|
||||
"correct-word delete cascade side effect",
|
||||
"database-side-effect",
|
||||
{"both_deleted": counts == {"java": 0, "fastapi": 0}},
|
||||
java={"remaining": counts["java"]},
|
||||
fastapi={"remaining": counts["fastapi"]},
|
||||
)
|
||||
|
||||
def run_ota_upload_download(self) -> None:
|
||||
firmware = b"manager-api-contract-firmware\x00\xff"
|
||||
upload_responses: dict[str, CapturedResponse] = {}
|
||||
paths: dict[str, str] = {}
|
||||
for side, base in self.bases.items():
|
||||
response = self._capture(
|
||||
self.client.post(
|
||||
base + "/otaMag/upload",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
files={"file": ("contract.bin", firmware, "application/octet-stream")},
|
||||
)
|
||||
)
|
||||
upload_responses[side] = response
|
||||
if isinstance(response.body, dict) and isinstance(response.body.get("data"), str):
|
||||
paths[side] = response.body["data"]
|
||||
self.add_custom(
|
||||
"OTA multipart upload response",
|
||||
"upload",
|
||||
{
|
||||
"status": upload_responses["java"].status == upload_responses["fastapi"].status,
|
||||
"body": upload_responses["java"].body == upload_responses["fastapi"].body,
|
||||
"paths_returned": set(paths) == {"java", "fastapi"},
|
||||
},
|
||||
difference=_first_difference(upload_responses["java"].body, upload_responses["fastapi"].body),
|
||||
java={"body": upload_responses["java"].body},
|
||||
fastapi={"body": upload_responses["fastapi"].body},
|
||||
)
|
||||
self.request_pair(
|
||||
"OTA multipart extension validation",
|
||||
"upload-validation",
|
||||
"POST",
|
||||
"/otaMag/upload",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
files={"file": ("contract.txt", b"invalid", "text/plain")},
|
||||
)
|
||||
if set(paths) != {"java", "fastapi"}:
|
||||
return
|
||||
create_responses: dict[str, CapturedResponse] = {}
|
||||
for side, base in self.bases.items():
|
||||
create_responses[side] = self._capture(
|
||||
self.client.post(
|
||||
base + "/otaMag",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
json={
|
||||
"id": "contract-ota-run",
|
||||
"firmwareName": "Contract Firmware",
|
||||
"type": "contract-run",
|
||||
"version": "1.0.0",
|
||||
"size": len(firmware),
|
||||
"remark": None,
|
||||
"firmwarePath": paths[side],
|
||||
"sort": 9,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.add_custom(
|
||||
"OTA metadata create response",
|
||||
"crud",
|
||||
{
|
||||
"status": create_responses["java"].status == create_responses["fastapi"].status,
|
||||
"body": create_responses["java"].body == create_responses["fastapi"].body,
|
||||
},
|
||||
difference=_first_difference(create_responses["java"].body, create_responses["fastapi"].body),
|
||||
java={"body": create_responses["java"].body},
|
||||
fastapi={"body": create_responses["fastapi"].body},
|
||||
)
|
||||
rows = {
|
||||
side: _database_row(
|
||||
self.mysql_port,
|
||||
"manager_java_test" if side == "java" else "manager_fastapi_test",
|
||||
"SELECT id,firmware_name,type,version,size,remark,firmware_path,sort FROM ai_ota WHERE id=%s",
|
||||
("contract-ota-run",),
|
||||
)
|
||||
for side in self.bases
|
||||
}
|
||||
self.add_custom(
|
||||
"OTA metadata database side effect",
|
||||
"database-side-effect",
|
||||
{"row_equal": rows["java"] == rows["fastapi"]},
|
||||
difference=_first_difference(rows["java"], rows["fastapi"]),
|
||||
java=rows["java"],
|
||||
fastapi=rows["fastapi"],
|
||||
)
|
||||
download_ids: dict[str, str] = {}
|
||||
for side, base in self.bases.items():
|
||||
response = self.client.get(
|
||||
base + "/otaMag/getDownloadUrl/contract-ota-run",
|
||||
headers=_bearer(ADMIN_TOKEN),
|
||||
).json()
|
||||
if isinstance(response.get("data"), str):
|
||||
download_ids[side] = response["data"]
|
||||
if set(download_ids) != {"java", "fastapi"}:
|
||||
self.add_custom(
|
||||
"OTA download token generation",
|
||||
"binary-download",
|
||||
{"tokens_returned": False},
|
||||
java={"id": download_ids.get("java")},
|
||||
fastapi={"id": download_ids.get("fastapi")},
|
||||
)
|
||||
return
|
||||
for attempt in range(1, 5):
|
||||
responses = {
|
||||
side: self._capture(self.client.get(self.bases[side] + f"/otaMag/download/{identifier}"))
|
||||
for side, identifier in download_ids.items()
|
||||
}
|
||||
self.add_custom(
|
||||
f"OTA binary download attempt {attempt}",
|
||||
"binary-download",
|
||||
{
|
||||
"status": responses["java"].status == responses["fastapi"].status,
|
||||
"bytes": responses["java"].body_sha256 == responses["fastapi"].body_sha256,
|
||||
"content_type": responses["java"].headers["content-type"]
|
||||
== responses["fastapi"].headers["content-type"],
|
||||
"content_disposition": responses["java"].headers["content-disposition"]
|
||||
== responses["fastapi"].headers["content-disposition"],
|
||||
"content_length": responses["java"].headers["content-length"]
|
||||
== responses["fastapi"].headers["content-length"],
|
||||
},
|
||||
java=asdict(responses["java"]),
|
||||
fastapi=asdict(responses["fastapi"]),
|
||||
)
|
||||
|
||||
|
||||
def _bearer(value: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {value}"}
|
||||
|
||||
|
||||
def _response_summary(response: CapturedResponse, normalized_body: JSONValue) -> dict[str, Any]:
|
||||
report_body = _redact_report_value(normalized_body)
|
||||
body_text = json.dumps(report_body, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return {
|
||||
"status": response.status,
|
||||
"headers": response.headers,
|
||||
"body": report_body if len(body_text) <= 8_000 else None,
|
||||
"body_sha256": hashlib.sha256(body_text.encode()).hexdigest(),
|
||||
"body_excerpt": body_text[:1_000],
|
||||
}
|
||||
|
||||
|
||||
def _redact_report_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
redacted: dict[str, Any] = {}
|
||||
parameter_code = next(
|
||||
(
|
||||
item
|
||||
for key, item in value.items()
|
||||
if re.sub(r"[^a-z0-9]", "", str(key).lower()) == "paramcode"
|
||||
),
|
||||
None,
|
||||
)
|
||||
sensitive_parameter = (
|
||||
isinstance(parameter_code, str)
|
||||
and re.sub(r"[^a-z0-9]", "", parameter_code.lower()) in SENSITIVE_REPORT_KEYS
|
||||
)
|
||||
for key, item in value.items():
|
||||
normalized_key = re.sub(r"[^a-z0-9]", "", str(key).lower())
|
||||
redacted[str(key)] = (
|
||||
"<redacted>"
|
||||
if normalized_key in SENSITIVE_REPORT_KEYS
|
||||
or sensitive_parameter and normalized_key == "paramvalue"
|
||||
else _redact_report_value(item)
|
||||
)
|
||||
return redacted
|
||||
if isinstance(value, list):
|
||||
return [_redact_report_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_redact_report_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _first_difference(left: Any, right: Any, path: str = "$") -> str | None:
|
||||
if type(left) is not type(right):
|
||||
return f"{path}: type Java={type(left).__name__} FastAPI={type(right).__name__}"
|
||||
if isinstance(left, dict):
|
||||
left_keys, right_keys = set(left), set(right)
|
||||
if left_keys != right_keys:
|
||||
return (
|
||||
f"{path}: keys Java-only={sorted(left_keys - right_keys)} "
|
||||
f"FastAPI-only={sorted(right_keys - left_keys)}"
|
||||
)
|
||||
for key in sorted(left):
|
||||
difference = _first_difference(left[key], right[key], f"{path}.{key}")
|
||||
if difference:
|
||||
return difference
|
||||
return None
|
||||
if isinstance(left, list):
|
||||
if len(left) != len(right):
|
||||
return f"{path}: length Java={len(left)} FastAPI={len(right)}"
|
||||
for index, (left_item, right_item) in enumerate(zip(left, right, strict=True)):
|
||||
difference = _first_difference(left_item, right_item, f"{path}[{index}]")
|
||||
if difference:
|
||||
return difference
|
||||
return None
|
||||
if left != right:
|
||||
return f"{path}: Java={left!r} FastAPI={right!r}"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_ota_dynamic(value: JSONValue) -> JSONValue:
|
||||
copied = json.loads(json.dumps(value, ensure_ascii=False))
|
||||
if isinstance(copied, dict):
|
||||
server_time = copied.get("server_time")
|
||||
if isinstance(server_time, dict) and isinstance(server_time.get("timestamp"), int):
|
||||
server_time["timestamp"] = "<millisecond-timestamp>"
|
||||
websocket = copied.get("websocket")
|
||||
if isinstance(websocket, dict) and isinstance(websocket.get("token"), str):
|
||||
websocket["token"] = "<validated-websocket-token>" # noqa: S105 - redaction marker
|
||||
return cast(JSONValue, copied)
|
||||
|
||||
|
||||
def _valid_ota_credentials(value: JSONValue) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
websocket, mqtt = value.get("websocket"), value.get("mqtt")
|
||||
if not isinstance(websocket, dict) or not isinstance(mqtt, dict):
|
||||
return False
|
||||
token = websocket.get("token")
|
||||
if not isinstance(token, str) or "." not in token:
|
||||
return False
|
||||
encoded, raw_timestamp = token.rsplit(".", 1)
|
||||
if not raw_timestamp.isdigit():
|
||||
return False
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hmac.new(
|
||||
SERVER_SECRET.encode(),
|
||||
f"contract-client|AA:BB:CC:DD:EE:01|{raw_timestamp}".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode().rstrip("=")
|
||||
if not hmac.compare_digest(encoded, expected):
|
||||
return False
|
||||
client_id, username, password = mqtt.get("client_id"), mqtt.get("username"), mqtt.get("password")
|
||||
if not all(isinstance(item, str) for item in (client_id, username, password)):
|
||||
return False
|
||||
expected_password = base64.b64encode(
|
||||
hmac.new(
|
||||
MQTT_SIGNATURE_KEY.encode(),
|
||||
f"{client_id}|{username}".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode()
|
||||
try:
|
||||
user_data = json.loads(base64.b64decode(str(username)).decode())
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
return hmac.compare_digest(str(password), expected_password) and isinstance(user_data.get("ip"), str)
|
||||
|
||||
|
||||
def _ota_credential_shape(value: JSONValue) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {"valid": False}
|
||||
websocket, mqtt = value.get("websocket"), value.get("mqtt")
|
||||
return {
|
||||
"websocket_token_pattern": bool(
|
||||
isinstance(websocket, dict)
|
||||
and re.fullmatch(r"[A-Za-z0-9_-]{43}\.\d{10}", str(websocket.get("token", "")))
|
||||
),
|
||||
"mqtt_fields": sorted(mqtt) if isinstance(mqtt, dict) else [],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_created_correct_word(value: JSONValue) -> JSONValue:
|
||||
copied = json.loads(json.dumps(value, ensure_ascii=False))
|
||||
if isinstance(copied, dict) and isinstance(copied.get("data"), dict):
|
||||
data = copied["data"]
|
||||
if isinstance(data.get("id"), str):
|
||||
data["id"] = "<generated-id>"
|
||||
for field in ("createdAt", "updatedAt"):
|
||||
if data.get(field) is not None:
|
||||
data[field] = "<datetime>"
|
||||
return cast(JSONValue, copied)
|
||||
|
||||
|
||||
def _database_config(port: int, database: str) -> dict[str, Any]:
|
||||
return {
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"user": "xiaozhi_test",
|
||||
"password": "isolated-test-only",
|
||||
"db": database,
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": True,
|
||||
}
|
||||
|
||||
|
||||
def _database_row(port: int, database: str, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
|
||||
async def query() -> dict[str, Any]:
|
||||
connection = await asyncmy.connect(**_database_config(port, database))
|
||||
try:
|
||||
async with connection.cursor(DictCursor) as cursor:
|
||||
await cursor.execute(sql, params)
|
||||
row = await cursor.fetchone()
|
||||
return {} if row is None else {str(key): value for key, value in row.items()}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(query())
|
||||
|
||||
|
||||
def _database_scalar(port: int, database: str, sql: str, params: tuple[Any, ...]) -> int:
|
||||
row = _database_row(port, database, sql, params)
|
||||
return int(next(iter(row.values()), 0))
|
||||
|
||||
|
||||
def _build_report(runner: ContractRunner) -> dict[str, Any]:
|
||||
passed = sum(result.passed for result in runner.results)
|
||||
failed = len(runner.results) - passed
|
||||
categories: dict[str, dict[str, int]] = {}
|
||||
for result in runner.results:
|
||||
category = categories.setdefault(result.category, {"passed": 0, "failed": 0})
|
||||
category["passed" if result.passed else "failed"] += 1
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"services": runner.bases,
|
||||
"fixture_ids": {"admin": str(ADMIN_ID), "normal": str(NORMAL_ID)},
|
||||
"summary": {"total": len(runner.results), "passed": passed, "failed": failed, "skipped": 0},
|
||||
"categories": categories,
|
||||
"results": [_redact_report_value(asdict(result)) for result in runner.results],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
|
||||
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
|
||||
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
|
||||
parser.add_argument("--mysql-port", type=int, default=13316)
|
||||
parser.add_argument("--output", type=Path, default=Path("compatibility/contract-results.json"))
|
||||
args = parser.parse_args()
|
||||
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
|
||||
try:
|
||||
runner.run_read_only_cases()
|
||||
runner.run_validation_cases()
|
||||
runner.run_server_auth_and_config()
|
||||
runner.run_ota_cases()
|
||||
runner.run_external_mock_case()
|
||||
runner.run_correct_word_crud()
|
||||
runner.run_ota_upload_download()
|
||||
finally:
|
||||
runner.close()
|
||||
report = _build_report(runner)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
|
||||
summary = report["summary"]
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
for result in runner.results:
|
||||
if not result.passed:
|
||||
print(f"FAIL {result.name}: {result.difference}")
|
||||
sys.exit(1 if summary["failed"] else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
app = FastAPI(title="manager-api repeatable external-service mock")
|
||||
|
||||
_requests: list[dict[str, Any]] = []
|
||||
_datasets: dict[str, dict[str, Any]] = {}
|
||||
_documents: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
async def _capture(request: Request, body: Any = None) -> None:
|
||||
_requests.append(
|
||||
{
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query": dict(request.query_params),
|
||||
"headers": {
|
||||
key.lower(): value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() in {"authorization", "content-type"}
|
||||
},
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "UP"}
|
||||
|
||||
|
||||
@app.get("/__requests")
|
||||
async def requests() -> dict[str, Any]:
|
||||
return {"requests": list(_requests)}
|
||||
|
||||
|
||||
@app.delete("/__requests")
|
||||
async def reset_requests() -> dict[str, bool]:
|
||||
_requests.clear()
|
||||
return {"reset": True}
|
||||
|
||||
|
||||
@app.post("/api/commands/{client_id}")
|
||||
async def mqtt_command(client_id: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
method = body.get("payload", {}).get("method") if isinstance(body, Mapping) else None
|
||||
if method == "tools/list":
|
||||
response = {
|
||||
"success": True,
|
||||
"data": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "contract.echo",
|
||||
"description": "repeatable contract fixture",
|
||||
"inputSchema": {"type": "object"},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
elif method == "tools/call":
|
||||
response = {"success": True, "data": {"content": [{"type": "text", "text": '{"echo":true}'}]}}
|
||||
else:
|
||||
response = {"success": True, "data": {"clientId": client_id}}
|
||||
return JSONResponse(response)
|
||||
|
||||
|
||||
@app.post("/api/devices/status")
|
||||
async def mqtt_status(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
client_ids = body.get("clientIds", []) if isinstance(body, Mapping) else []
|
||||
return JSONResponse({"success": True, "data": {str(value): True for value in client_ids}})
|
||||
|
||||
|
||||
@app.post("/api/call/{operation}")
|
||||
async def mqtt_call(operation: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
return JSONResponse({"status": "success", "message": operation})
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "chatcmpl-contract",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "contract summary"}}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/datasets")
|
||||
async def list_datasets(request: Request) -> JSONResponse:
|
||||
await _capture(request)
|
||||
dataset_id = request.query_params.get("id")
|
||||
values = list(_datasets.values())
|
||||
if dataset_id:
|
||||
values = [value for value in values if value["id"] == dataset_id]
|
||||
return JSONResponse({"code": 0, "data": values})
|
||||
|
||||
|
||||
@app.post("/api/v1/datasets")
|
||||
async def create_dataset(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
identifier = "rag-contract-" + hashlib.sha256(
|
||||
json.dumps(body, ensure_ascii=False, sort_keys=True).encode()
|
||||
).hexdigest()[:12]
|
||||
value = {
|
||||
"id": identifier,
|
||||
"name": body.get("name"),
|
||||
"description": body.get("description"),
|
||||
"tenant_id": "tenant-contract",
|
||||
"avatar": body.get("avatar"),
|
||||
"document_count": 0,
|
||||
"chunk_count": 0,
|
||||
"token_num": 0,
|
||||
}
|
||||
_datasets[identifier] = value
|
||||
return JSONResponse({"code": 0, "data": value})
|
||||
|
||||
|
||||
@app.put("/api/v1/datasets/{dataset_id}")
|
||||
async def update_dataset(dataset_id: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
value = _datasets.setdefault(dataset_id, {"id": dataset_id})
|
||||
value.update(body)
|
||||
return JSONResponse({"code": 0, "data": value})
|
||||
|
||||
|
||||
@app.delete("/api/v1/datasets/{dataset_id}")
|
||||
async def delete_dataset(dataset_id: str, request: Request) -> JSONResponse:
|
||||
await _capture(request)
|
||||
_datasets.pop(dataset_id, None)
|
||||
return JSONResponse({"code": 0, "data": True})
|
||||
|
||||
|
||||
@app.post("/api/v1/datasets/{dataset_id}/documents")
|
||||
async def upload_document(
|
||||
dataset_id: str,
|
||||
request: Request,
|
||||
file: Annotated[UploadFile, File()],
|
||||
) -> JSONResponse:
|
||||
content = await file.read()
|
||||
body: dict[str, Any] = {
|
||||
"filename": file.filename,
|
||||
"size": len(content),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
await _capture(request, body)
|
||||
identifier = "doc-contract-" + str(body["sha256"])[:12]
|
||||
value = {
|
||||
"id": identifier,
|
||||
"dataset_id": dataset_id,
|
||||
"name": file.filename,
|
||||
"size": len(content),
|
||||
"run": "UNSTART",
|
||||
"progress": 0.0,
|
||||
"chunk_count": 0,
|
||||
"token_count": 0,
|
||||
}
|
||||
_documents[identifier] = value
|
||||
return JSONResponse({"code": 0, "data": [value]})
|
||||
|
||||
|
||||
@app.get("/api/v1/datasets/{dataset_id}/documents")
|
||||
async def list_documents(dataset_id: str, request: Request) -> JSONResponse:
|
||||
await _capture(request)
|
||||
document_id = request.query_params.get("id")
|
||||
values = [value for value in _documents.values() if value["dataset_id"] == dataset_id]
|
||||
if document_id:
|
||||
values = [value for value in values if value["id"] == document_id]
|
||||
return JSONResponse({"code": 0, "data": {"docs": values, "total": len(values)}})
|
||||
|
||||
|
||||
@app.post("/api/v1/datasets/{dataset_id}/chunks")
|
||||
async def parse_documents(dataset_id: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
for document_id in body.get("document_ids", []):
|
||||
if document_id in _documents:
|
||||
_documents[document_id].update(run="DONE", progress=1.0, chunk_count=2, token_count=17)
|
||||
return JSONResponse({"code": 0, "data": True})
|
||||
|
||||
|
||||
@app.delete("/api/v1/datasets/{dataset_id}/documents")
|
||||
async def delete_documents(dataset_id: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
await _capture(request, body)
|
||||
for document_id in body.get("ids", body.get("document_ids", [])):
|
||||
_documents.pop(document_id, None)
|
||||
return JSONResponse({"code": 0, "data": True})
|
||||
|
||||
|
||||
@app.post("/voice-clone")
|
||||
async def clone_voice(request: Request) -> JSONResponse:
|
||||
content = await request.body()
|
||||
await _capture(request, {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()})
|
||||
return JSONResponse({"voice_id": "voice-contract", "status": "success"})
|
||||
|
||||
|
||||
@app.post("/transient/{failures}")
|
||||
async def transient(failures: int, request: Request) -> JSONResponse:
|
||||
await _capture(request)
|
||||
count = sum(1 for item in _requests if item["path"] == request.url.path)
|
||||
if count <= failures:
|
||||
await asyncio.sleep(0)
|
||||
return JSONResponse({"error": "retryable"}, status_code=503)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/binary")
|
||||
async def binary(request: Request) -> JSONResponse:
|
||||
await _capture(request)
|
||||
content = b"contract-binary\x00\xff"
|
||||
return JSONResponse({"base64": base64.b64encode(content).decode(), "size": len(content)})
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from tests.compatibility.seed_contract_data import ADMIN_TOKEN, NORMAL_TOKEN, SERVER_SECRET
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
name: str
|
||||
method: str
|
||||
path: str
|
||||
headers: dict[str, str]
|
||||
json_body: dict[str, Any] | None = None
|
||||
raw_ota: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurement:
|
||||
service: str
|
||||
scenario: str
|
||||
requests: int
|
||||
concurrency: int
|
||||
warmup_requests: int
|
||||
errors: int
|
||||
elapsed_seconds: float
|
||||
throughput_requests_per_second: float
|
||||
latency_ms_min: float
|
||||
latency_ms_p50: float
|
||||
latency_ms_p95: float
|
||||
latency_ms_max: float
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
SCENARIOS = (
|
||||
Scenario("representative-read", "GET", "/user/info", _bearer(ADMIN_TOKEN)),
|
||||
Scenario(
|
||||
"representative-crud-update",
|
||||
"PUT",
|
||||
"/device/update/contract-device-1",
|
||||
_bearer(NORMAL_TOKEN),
|
||||
{"autoUpdate": 1, "alias": None},
|
||||
),
|
||||
Scenario("runtime-configuration", "POST", "/config/server-base", _bearer(SERVER_SECRET)),
|
||||
Scenario(
|
||||
"ota-check-and-signing",
|
||||
"POST",
|
||||
"/ota/",
|
||||
{"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-performance"},
|
||||
{"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _percentile(values: list[float], percentile: float) -> float:
|
||||
ordered = sorted(values)
|
||||
if not ordered:
|
||||
return 0.0
|
||||
position = (len(ordered) - 1) * percentile
|
||||
lower, upper = math.floor(position), math.ceil(position)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
||||
|
||||
|
||||
async def _request_once(client: httpx.AsyncClient, base: str, scenario: Scenario) -> tuple[float, bool]:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await client.request(
|
||||
scenario.method,
|
||||
base + scenario.path,
|
||||
headers=scenario.headers,
|
||||
json=scenario.json_body,
|
||||
)
|
||||
valid = response.status_code == 200
|
||||
if valid:
|
||||
payload = response.json()
|
||||
valid = (
|
||||
isinstance(payload, dict)
|
||||
and ("server_time" in payload if scenario.raw_ota else payload.get("code") == 0)
|
||||
)
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
valid = False
|
||||
return (time.perf_counter() - started) * 1000, valid
|
||||
|
||||
|
||||
async def measure(
|
||||
service: str,
|
||||
base: str,
|
||||
scenario: Scenario,
|
||||
*,
|
||||
requests: int,
|
||||
concurrency: int,
|
||||
warmup: int,
|
||||
) -> Measurement:
|
||||
limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)
|
||||
timeout = httpx.Timeout(30.0)
|
||||
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
|
||||
for _ in range(warmup):
|
||||
_, valid = await _request_once(client, base, scenario)
|
||||
if not valid:
|
||||
raise RuntimeError(f"warmup failed for {service}/{scenario.name}")
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def bounded_request() -> tuple[float, bool]:
|
||||
async with semaphore:
|
||||
return await _request_once(client, base, scenario)
|
||||
|
||||
started = time.perf_counter()
|
||||
values = await asyncio.gather(*(bounded_request() for _ in range(requests)))
|
||||
elapsed = time.perf_counter() - started
|
||||
latencies = [latency for latency, _ in values]
|
||||
errors = sum(not valid for _, valid in values)
|
||||
return Measurement(
|
||||
service=service,
|
||||
scenario=scenario.name,
|
||||
requests=requests,
|
||||
concurrency=concurrency,
|
||||
warmup_requests=warmup,
|
||||
errors=errors,
|
||||
elapsed_seconds=round(elapsed, 6),
|
||||
throughput_requests_per_second=round(requests / elapsed, 3),
|
||||
latency_ms_min=round(min(latencies), 3),
|
||||
latency_ms_p50=round(_percentile(latencies, 0.50), 3),
|
||||
latency_ms_p95=round(_percentile(latencies, 0.95), 3),
|
||||
latency_ms_max=round(max(latencies), 3),
|
||||
)
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> dict[str, Any]:
|
||||
services = {
|
||||
"java": args.java_base.rstrip("/"),
|
||||
"fastapi": args.fastapi_base.rstrip("/"),
|
||||
}
|
||||
results: list[Measurement] = []
|
||||
for scenario in SCENARIOS:
|
||||
for service, base in services.items():
|
||||
results.append(
|
||||
await measure(
|
||||
service,
|
||||
base,
|
||||
scenario,
|
||||
requests=args.requests,
|
||||
concurrency=args.concurrency,
|
||||
warmup=args.warmup,
|
||||
)
|
||||
)
|
||||
comparisons: list[dict[str, Any]] = []
|
||||
for scenario in SCENARIOS:
|
||||
java = next(value for value in results if value.service == "java" and value.scenario == scenario.name)
|
||||
fastapi = next(value for value in results if value.service == "fastapi" and value.scenario == scenario.name)
|
||||
comparisons.append(
|
||||
{
|
||||
"scenario": scenario.name,
|
||||
"p50_ratio_fastapi_over_java": round(fastapi.latency_ms_p50 / java.latency_ms_p50, 3),
|
||||
"p95_ratio_fastapi_over_java": round(fastapi.latency_ms_p95 / java.latency_ms_p95, 3),
|
||||
"throughput_ratio_fastapi_over_java": round(
|
||||
fastapi.throughput_requests_per_second / java.throughput_requests_per_second,
|
||||
3,
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"parameters": {
|
||||
"requests_per_service_per_scenario": args.requests,
|
||||
"concurrency": args.concurrency,
|
||||
"sequential_warmup_requests": args.warmup,
|
||||
"scenario_order": [scenario.name for scenario in SCENARIOS],
|
||||
"service_order": ["java", "fastapi"],
|
||||
},
|
||||
"results": [asdict(result) for result in results],
|
||||
"comparisons": comparisons,
|
||||
"summary": {
|
||||
"measurements": len(results),
|
||||
"requests_measured": sum(result.requests for result in results),
|
||||
"errors": sum(result.errors for result in results),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
|
||||
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
|
||||
parser.add_argument("--requests", type=int, default=60)
|
||||
parser.add_argument("--concurrency", type=int, default=6)
|
||||
parser.add_argument("--warmup", type=int, default=10)
|
||||
parser.add_argument("--output", type=Path, default=Path("compatibility/performance-results.json"))
|
||||
args = parser.parse_args()
|
||||
if args.requests <= 0 or args.concurrency <= 0 or args.warmup < 0:
|
||||
parser.error("requests/concurrency must be positive and warmup must be non-negative")
|
||||
report = asyncio.run(run(args))
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(report["summary"], ensure_ascii=False))
|
||||
if report["summary"]["errors"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from tests.compatibility.differential_runner import ContractRunner, _build_report
|
||||
|
||||
TARGET_ROOT = Path(__file__).resolve().parents[2]
|
||||
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
|
||||
PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}")
|
||||
|
||||
SAFE_PATH_VALUES = {
|
||||
"agentId": "contract-agent-1",
|
||||
"audioId": "contract-audio",
|
||||
"deviceCode": "000000",
|
||||
"deviceId": "contract-device-1",
|
||||
"dictType": "FIRMWARE_TYPE",
|
||||
"document_id": "contract-document",
|
||||
"dataset_id": "contract-dataset",
|
||||
"fileId": "contract-words",
|
||||
"id": "contract-id",
|
||||
"macAddress": "AA:BB:CC:DD:EE:01",
|
||||
"modelId": "contract-model",
|
||||
"modelType": "LLM",
|
||||
"provideCode": "OpenAI",
|
||||
"sessionId": "contract-session",
|
||||
"snapshotId": "contract-snapshot",
|
||||
"status": "1",
|
||||
"userId": "contract-user",
|
||||
"uuid": "contract-unknown-token",
|
||||
}
|
||||
|
||||
|
||||
def _safe_path(template: str) -> str:
|
||||
return PATH_PARAMETER.sub(lambda match: SAFE_PATH_VALUES.get(match.group(1), "contract-value"), template)
|
||||
|
||||
|
||||
def _load_routes() -> list[dict[str, Any]]:
|
||||
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
|
||||
routes = cast(list[dict[str, Any]], manifest["routes"])
|
||||
if manifest["count"] != 154 or len(routes) != 154:
|
||||
raise RuntimeError("Java route manifest is not closed at 154 routes")
|
||||
return routes
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
|
||||
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
|
||||
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
|
||||
parser.add_argument("--mysql-port", type=int, default=13316)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("compatibility/route-surface-results.json"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
|
||||
try:
|
||||
for route in _load_routes():
|
||||
method = str(route["method"])
|
||||
template = str(route["path"])
|
||||
runner.request_pair(
|
||||
f"{method} {template}",
|
||||
f"route-surface:{route['auth']}",
|
||||
method,
|
||||
_safe_path(template),
|
||||
exact_headers=("content-type",),
|
||||
)
|
||||
finally:
|
||||
runner.close()
|
||||
|
||||
report = _build_report(runner)
|
||||
report["coverage"] = {
|
||||
"java_routes": len(runner.results),
|
||||
"request_profile": "missing-auth-or-safe-invalid-input",
|
||||
"side_effect_policy": "no successful write request is issued",
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
summary = report["summary"]
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
for result in runner.results:
|
||||
if not result.passed:
|
||||
print(f"FAIL {result.name}: {result.difference}")
|
||||
sys.exit(1 if summary["failed"] else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncmy # type: ignore[import-untyped]
|
||||
from asyncmy.cursors import DictCursor # type: ignore[import-untyped]
|
||||
|
||||
ADMIN_ID = 9_007_199_254_740_993
|
||||
NORMAL_ID = 9_007_199_254_740_994
|
||||
EXPIRED_ID = 9_007_199_254_740_995
|
||||
ADMIN_TOKEN = "contract-admin-token" # noqa: S105 - isolated fixture credential
|
||||
NORMAL_TOKEN = "contract-normal-token" # noqa: S105 - isolated fixture credential
|
||||
EXPIRED_TOKEN = "contract-expired-token" # noqa: S105 - isolated fixture credential
|
||||
SERVER_SECRET = "contract-server-secret" # noqa: S105 - isolated fixture credential
|
||||
MQTT_SIGNATURE_KEY = "contract-mqtt-signature-key"
|
||||
MIGRATION_TIMESTAMP_TABLES = (
|
||||
"ai_model_provider",
|
||||
"sys_dict_data",
|
||||
"sys_dict_type",
|
||||
)
|
||||
|
||||
|
||||
def _database_config(port: int, database: str) -> dict[str, Any]:
|
||||
return {
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"user": "xiaozhi_test",
|
||||
"password": "isolated-test-only",
|
||||
"db": database,
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": False,
|
||||
}
|
||||
|
||||
|
||||
async def _query_params(port: int, database: str, codes: tuple[str, ...]) -> dict[str, str | None]:
|
||||
connection = await asyncmy.connect(**_database_config(port, database))
|
||||
try:
|
||||
async with connection.cursor(DictCursor) as cursor:
|
||||
placeholders = ",".join(["%s"] * len(codes))
|
||||
await cursor.execute(
|
||||
f"SELECT param_code,param_value FROM sys_params WHERE param_code IN ({placeholders})", # noqa: S608
|
||||
codes,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {str(row["param_code"]): row["param_value"] for row in rows}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
async def _seed_database(
|
||||
port: int,
|
||||
database: str,
|
||||
crypto_params: dict[str, str | None],
|
||||
mock_port: int,
|
||||
) -> None:
|
||||
connection = await asyncmy.connect(**_database_config(port, database))
|
||||
now = datetime(2026, 7, 20, 12, 34, 56)
|
||||
try:
|
||||
async with connection.cursor(DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id FROM ai_agent_correct_word_file WHERE file_name LIKE 'contract-run-%'"
|
||||
)
|
||||
generated_file_ids = [str(row["id"]) for row in await cursor.fetchall()]
|
||||
if generated_file_ids:
|
||||
placeholders = ",".join(["%s"] * len(generated_file_ids))
|
||||
await cursor.execute(
|
||||
f"DELETE FROM ai_agent_correct_word_mapping WHERE file_id IN ({placeholders})", # noqa: S608
|
||||
generated_file_ids,
|
||||
)
|
||||
await cursor.execute(
|
||||
f"DELETE FROM ai_agent_correct_word_item WHERE file_id IN ({placeholders})", # noqa: S608
|
||||
generated_file_ids,
|
||||
)
|
||||
await cursor.execute(
|
||||
f"DELETE FROM ai_agent_correct_word_file WHERE id IN ({placeholders})", # noqa: S608
|
||||
generated_file_ids,
|
||||
)
|
||||
await cursor.execute("DELETE FROM ai_ota WHERE id='contract-ota-run'")
|
||||
|
||||
for identifier, username, super_admin in (
|
||||
(ADMIN_ID, "contract-admin", 1),
|
||||
(NORMAL_ID, "contract-user", 0),
|
||||
(EXPIRED_ID, "contract-expired", 0),
|
||||
):
|
||||
await cursor.execute(
|
||||
"INSERT INTO sys_user(id,username,password,super_admin,status,create_date,updater,creator,"
|
||||
"update_date) "
|
||||
"VALUES(%s,%s,%s,%s,1,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
|
||||
"username=fixture.username,password=fixture.password,super_admin=fixture.super_admin,status=1,"
|
||||
"create_date=fixture.create_date,updater=fixture.updater,creator=fixture.creator,"
|
||||
"update_date=fixture.update_date",
|
||||
(
|
||||
identifier,
|
||||
username,
|
||||
"$2a$10$contract.fixture.not.used",
|
||||
super_admin,
|
||||
now,
|
||||
ADMIN_ID,
|
||||
ADMIN_ID,
|
||||
now,
|
||||
),
|
||||
)
|
||||
for identifier, user_id, token, expires in (
|
||||
(ADMIN_ID, ADMIN_ID, ADMIN_TOKEN, datetime(2099, 1, 1)),
|
||||
(NORMAL_ID, NORMAL_ID, NORMAL_TOKEN, datetime(2099, 1, 1)),
|
||||
(EXPIRED_ID, EXPIRED_ID, EXPIRED_TOKEN, datetime(2020, 1, 1)),
|
||||
):
|
||||
await cursor.execute(
|
||||
"INSERT INTO sys_user_token(id,user_id,token,expire_date,update_date,create_date) "
|
||||
"VALUES(%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE token=fixture.token,"
|
||||
"expire_date=fixture.expire_date,update_date=fixture.update_date,create_date=fixture.create_date",
|
||||
(identifier, user_id, token, expires, now, now),
|
||||
)
|
||||
|
||||
parameter_values: dict[str, str | None] = {
|
||||
**crypto_params,
|
||||
"server.secret": SERVER_SECRET,
|
||||
"server.auth.enabled": "true",
|
||||
"server.websocket": "ws://127.0.0.1:18080/xiaozhi/v1/",
|
||||
"server.mqtt_gateway": "mqtt://127.0.0.1:1883",
|
||||
"server.mqtt_manager_api": f"127.0.0.1:{mock_port}",
|
||||
"server.mqtt_signature_key": MQTT_SIGNATURE_KEY,
|
||||
"server.ota": "http://127.0.0.1:18082/xiaozhi/ota/",
|
||||
"server.frontend.url": "http://127.0.0.1:8001",
|
||||
}
|
||||
for code, value in parameter_values.items():
|
||||
await cursor.execute(
|
||||
"UPDATE sys_params SET param_value=%s,update_date=%s WHERE param_code=%s",
|
||||
(value, now, code),
|
||||
)
|
||||
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_agent(id,user_id,agent_code,agent_name,system_prompt,summary_memory,chat_history_conf,"
|
||||
"lang_code,language,sort,creator,created_at,updater,updated_at) "
|
||||
"VALUES(%s,%s,%s,%s,NULL,%s,1,%s,%s,7,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
|
||||
"user_id=fixture.user_id,agent_code=fixture.agent_code,agent_name=fixture.agent_name,"
|
||||
"system_prompt=NULL,summary_memory=fixture.summary_memory,chat_history_conf=1,"
|
||||
"lang_code=fixture.lang_code,language=fixture.language,sort=7,creator=fixture.creator,"
|
||||
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
|
||||
(
|
||||
"contract-agent-1",
|
||||
NORMAL_ID,
|
||||
"contract-agent-code",
|
||||
"Contract Agent",
|
||||
"fixture memory",
|
||||
"zh-CN",
|
||||
"zh",
|
||||
NORMAL_ID,
|
||||
now,
|
||||
NORMAL_ID,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_device(id,user_id,mac_address,last_connected_at,auto_update,board,alias,agent_id,"
|
||||
"app_version,sort,creator,create_date,updater,update_date) "
|
||||
"VALUES(%s,%s,%s,%s,1,%s,NULL,%s,%s,3,%s,%s,%s,%s) "
|
||||
"AS fixture ON DUPLICATE KEY UPDATE user_id=fixture.user_id,mac_address=fixture.mac_address,"
|
||||
"last_connected_at=fixture.last_connected_at,auto_update=1,board=fixture.board,alias=NULL,"
|
||||
"agent_id=fixture.agent_id,app_version=fixture.app_version,sort=3,creator=fixture.creator,"
|
||||
"create_date=fixture.create_date,updater=fixture.updater,update_date=fixture.update_date",
|
||||
(
|
||||
"contract-device-1",
|
||||
NORMAL_ID,
|
||||
"AA:BB:CC:DD:EE:01",
|
||||
now,
|
||||
"esp32s3",
|
||||
"contract-agent-1",
|
||||
"1.2.3",
|
||||
NORMAL_ID,
|
||||
now,
|
||||
NORMAL_ID,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_model_provider(id,model_type,provider_code,name,fields,sort,creator,create_date,"
|
||||
"updater,update_date) "
|
||||
"VALUES(%s,%s,%s,%s,CAST(%s AS JSON),9,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
|
||||
"model_type=fixture.model_type,provider_code=fixture.provider_code,name=fixture.name,"
|
||||
"fields=fixture.fields,sort=9,creator=fixture.creator,create_date=fixture.create_date,"
|
||||
"updater=fixture.updater,update_date=fixture.update_date",
|
||||
(
|
||||
"contract-provider",
|
||||
"LLM",
|
||||
"contract",
|
||||
"Contract Provider",
|
||||
'[{"key":"api_key","label":"API Key","type":"string"}]',
|
||||
ADMIN_ID,
|
||||
now,
|
||||
ADMIN_ID,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_agent_correct_word_file(id,file_name,word_count,content,creator,created_at,updater,"
|
||||
"updated_at) "
|
||||
"VALUES(%s,%s,2,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
|
||||
"file_name=fixture.file_name,word_count=2,content=fixture.content,creator=fixture.creator,"
|
||||
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
|
||||
(
|
||||
"contract-words",
|
||||
"合同词表.txt",
|
||||
"小智,小志\nESP32,esp32",
|
||||
NORMAL_ID,
|
||||
now,
|
||||
NORMAL_ID,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await cursor.execute("DELETE FROM ai_agent_correct_word_item WHERE file_id=%s", ("contract-words",))
|
||||
for word_id, source, target in (
|
||||
("contract-word-1", "小智", "小志"),
|
||||
("contract-word-2", "ESP32", "esp32"),
|
||||
):
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_agent_correct_word_item(id,file_id,source_word,target_word) VALUES(%s,%s,%s,%s)",
|
||||
(word_id, "contract-words", source, target),
|
||||
)
|
||||
await cursor.execute(
|
||||
"INSERT INTO ai_agent_correct_word_mapping(id,agent_id,file_id,creator,created_at,updater,updated_at) "
|
||||
"VALUES(%s,%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE creator=fixture.creator,"
|
||||
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
|
||||
("contract-word-map", "contract-agent-1", "contract-words", NORMAL_ID, now, NORMAL_ID, now),
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
async def _sync_migration_timestamps(port: int) -> None:
|
||||
"""Copy baseline-generated timestamps for rows shared by both schemas.
|
||||
|
||||
Several Liquibase seed changesets use the database current timestamp. The
|
||||
Java and FastAPI schemas are migrated a few seconds apart, which would make
|
||||
otherwise identical read responses differ. The isolated contract fixture
|
||||
treats Java as the behavior baseline and aligns only the audit timestamps of
|
||||
matching migration rows; it does not copy business data or change IDs.
|
||||
"""
|
||||
|
||||
connection = await asyncmy.connect(**_database_config(port, "manager_fastapi_test"))
|
||||
try:
|
||||
async with connection.cursor(DictCursor) as cursor:
|
||||
for table in MIGRATION_TIMESTAMP_TABLES:
|
||||
await cursor.execute(
|
||||
f"UPDATE manager_fastapi_test.`{table}` AS target " # noqa: S608
|
||||
f"JOIN manager_java_test.`{table}` AS baseline ON baseline.id=target.id "
|
||||
"SET target.create_date=baseline.create_date,target.update_date=baseline.update_date"
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
async def seed(port: int, mock_port: int) -> None:
|
||||
crypto = await _query_params(
|
||||
port,
|
||||
"manager_java_test",
|
||||
("server.public_key", "server.private_key"),
|
||||
)
|
||||
if not crypto.get("server.public_key") or not crypto.get("server.private_key"):
|
||||
raise RuntimeError("Java baseline must be started once before seeding so its SM2 keypair exists")
|
||||
for database in ("manager_java_test", "manager_fastapi_test"):
|
||||
await _seed_database(port, database, crypto, mock_port)
|
||||
await _sync_migration_timestamps(port)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mysql-port", type=int, default=13316)
|
||||
parser.add_argument("--mock-port", type=int, default=18084)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(seed(args.mysql_port, args.mock_port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
|
||||
async def _copy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while data := await reader.read(64 * 1024):
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
writer.write_eof()
|
||||
|
||||
|
||||
async def _handle(
|
||||
downstream_reader: asyncio.StreamReader,
|
||||
downstream_writer: asyncio.StreamWriter,
|
||||
target_host: str,
|
||||
target_port: int,
|
||||
) -> None:
|
||||
try:
|
||||
upstream_reader, upstream_writer = await asyncio.open_connection(target_host, target_port)
|
||||
except OSError:
|
||||
downstream_writer.close()
|
||||
await downstream_writer.wait_closed()
|
||||
return
|
||||
|
||||
try:
|
||||
tasks = {
|
||||
asyncio.create_task(_copy(downstream_reader, upstream_writer)),
|
||||
asyncio.create_task(_copy(upstream_reader, downstream_writer)),
|
||||
}
|
||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*done, *pending, return_exceptions=True)
|
||||
finally:
|
||||
upstream_writer.close()
|
||||
downstream_writer.close()
|
||||
await asyncio.gather(
|
||||
upstream_writer.wait_closed(),
|
||||
downstream_writer.wait_closed(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
async def _serve(listen_port: int, target_host: str, target_port: int) -> None:
|
||||
server = await asyncio.start_server(
|
||||
lambda reader, writer: _handle(reader, writer, target_host, target_port),
|
||||
host="0.0.0.0", # noqa: S104 - isolated container-to-host test bridge
|
||||
port=listen_port,
|
||||
)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Ephemeral TCP bridge for isolated container tests")
|
||||
parser.add_argument("--listen-port", type=int, required=True)
|
||||
parser.add_argument("--target-host", default="127.0.0.1")
|
||||
parser.add_argument("--target-port", type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(_serve(args.listen_port, args.target_host, args.target_port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.compatibility.authenticated_route_runner import (
|
||||
DYNAMIC_RESPONSES,
|
||||
JAVA_MANIFEST,
|
||||
MULTIPART_ROUTES,
|
||||
NO_BODY_ROUTES,
|
||||
OTA_DOWNLOAD_UUID_ROUTE,
|
||||
RANDOM_PASSWORD_ROUTE,
|
||||
SINGLE_CONSTRAINT_PAYLOADS,
|
||||
_normalize_ota_download_uuid,
|
||||
_normalize_random_password,
|
||||
_valid_random_password,
|
||||
_valid_uuid4,
|
||||
_validate_dynamic_response,
|
||||
build_cases,
|
||||
)
|
||||
from tests.compatibility.differential_runner import ContractRunner
|
||||
from tests.compatibility.seed_contract_data import (
|
||||
ADMIN_TOKEN,
|
||||
MIGRATION_TIMESTAMP_TABLES,
|
||||
SERVER_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def test_authenticated_route_cases_are_fresh_and_close_all_java_routes() -> None:
|
||||
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
|
||||
expected = {(str(route["method"]), str(route["path"])) for route in manifest["routes"]}
|
||||
cases = build_cases()
|
||||
|
||||
assert manifest["count"] == len(expected) == len(cases) == 154
|
||||
assert {case.key for case in cases} == expected
|
||||
assert len({(case.method, case.path) for case in cases}) == 154
|
||||
|
||||
|
||||
def test_authenticated_route_cases_use_safe_auth_and_body_profiles() -> None:
|
||||
for case in build_cases():
|
||||
headers = case.request_kwargs.get("headers", {})
|
||||
if case.auth == "database-token":
|
||||
assert headers == {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
elif case.auth == "server-secret":
|
||||
assert headers == {"Authorization": f"Bearer {SERVER_SECRET}"}
|
||||
else:
|
||||
assert headers == {}
|
||||
|
||||
if case.method in {"GET", "DELETE"}:
|
||||
assert "json" not in case.request_kwargs
|
||||
elif case.key in MULTIPART_ROUTES | NO_BODY_ROUTES:
|
||||
assert "json" not in case.request_kwargs
|
||||
elif case.key in SINGLE_CONSTRAINT_PAYLOADS:
|
||||
assert case.request_kwargs.get("json") == SINGLE_CONSTRAINT_PAYLOADS[case.key]
|
||||
else:
|
||||
assert case.request_kwargs.get("json") == {}
|
||||
|
||||
|
||||
def test_array_body_validation_routes_are_not_hidden_by_safe_case_overrides() -> None:
|
||||
cases = {case.key: case for case in build_cases()}
|
||||
object_sent_to_array_routes = {
|
||||
("POST", "/admin/dict/data/delete"),
|
||||
("POST", "/admin/dict/type/delete"),
|
||||
("POST", "/admin/params/delete"),
|
||||
("PUT", "/admin/users/changeStatus/{status}"),
|
||||
("POST", "/agent/template/batch-remove"),
|
||||
("POST", "/correct-word/file/batch-delete"),
|
||||
("POST", "/models/provider/delete"),
|
||||
("POST", "/ttsVoice/delete"),
|
||||
}
|
||||
for key in object_sent_to_array_routes:
|
||||
assert cases[key].request_kwargs.get("json") == {}
|
||||
assert "json" not in cases[("DELETE", "/datasets/batch")].request_kwargs
|
||||
|
||||
|
||||
def test_dynamic_response_normalizers_require_the_full_java_formats() -> None:
|
||||
valid_password = "aA1!bcDEF234" # noqa: S105 - format-only fixture value
|
||||
assert _valid_random_password(valid_password)
|
||||
assert not _valid_random_password("aA1!short")
|
||||
assert not _valid_random_password("aA1_bcDEF234")
|
||||
assert not _valid_random_password("abcdefghijkl")
|
||||
|
||||
valid_uuid = "123e4567-e89b-42d3-a456-426614174000"
|
||||
assert _valid_uuid4(valid_uuid)
|
||||
assert not _valid_uuid4("123e4567-e89b-12d3-a456-426614174000")
|
||||
assert not _valid_uuid4(valid_uuid.upper())
|
||||
|
||||
password_body = {"code": 0, "msg": "success", "data": valid_password}
|
||||
uuid_body = {"code": 0, "msg": "success", "data": valid_uuid}
|
||||
assert _normalize_random_password(password_body) == {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": "<valid-random-password>",
|
||||
}
|
||||
assert _normalize_ota_download_uuid(uuid_body) == {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": "<valid-uuid-v4>",
|
||||
}
|
||||
invalid = {"code": 0, "msg": "success", "data": "not-dynamic"}
|
||||
assert _normalize_random_password(invalid) is invalid
|
||||
assert _normalize_ota_download_uuid(invalid) is invalid
|
||||
assert set(DYNAMIC_RESPONSES) == {RANDOM_PASSWORD_ROUTE, OTA_DOWNLOAD_UUID_ROUTE}
|
||||
|
||||
|
||||
def test_identical_invalid_dynamic_values_fail_both_independent_format_checks() -> None:
|
||||
runner = ContractRunner("http://java.invalid", "http://fastapi.invalid", "http://mock.invalid", 0)
|
||||
try:
|
||||
runner.add_custom("dynamic", "test", {"body": True})
|
||||
invalid = {"code": 0, "msg": "success", "data": "same-invalid-value"}
|
||||
_validate_dynamic_response(
|
||||
runner,
|
||||
java_body=invalid,
|
||||
fastapi_body=invalid,
|
||||
validator=_valid_uuid4,
|
||||
label="uuid_v4",
|
||||
)
|
||||
result = runner.results[-1]
|
||||
assert not result.passed
|
||||
assert result.checks["body"]
|
||||
assert not result.checks["java:uuid_v4"]
|
||||
assert not result.checks["fastapi:uuid_v4"]
|
||||
assert result.difference == (
|
||||
"invalid dynamic response format: java:uuid_v4, fastapi:uuid_v4"
|
||||
)
|
||||
finally:
|
||||
runner.close()
|
||||
|
||||
|
||||
def test_migration_timestamp_fixture_is_limited_to_generated_seed_rows() -> None:
|
||||
assert MIGRATION_TIMESTAMP_TABLES == (
|
||||
"ai_model_provider",
|
||||
"sys_dict_data",
|
||||
"sys_dict_type",
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.compatibility.differential_runner import (
|
||||
CapturedResponse,
|
||||
_redact_report_value,
|
||||
_response_summary,
|
||||
)
|
||||
|
||||
|
||||
def test_recursive_report_redaction_preserves_non_sensitive_shape() -> None:
|
||||
source = {
|
||||
"data": {
|
||||
"private_key": "generated-private-key",
|
||||
"server.secret": "fixture-secret",
|
||||
"mqtt_signature_key": "fixture-signature",
|
||||
"token": "fixture-token",
|
||||
"token_count": 17,
|
||||
"public_key": "public-material",
|
||||
"params": [
|
||||
{"paramCode": "server.secret", "paramValue": "fixture-param-secret"},
|
||||
{"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"},
|
||||
],
|
||||
},
|
||||
"headers": {"authorization": "Bearer fixture"},
|
||||
}
|
||||
|
||||
assert _redact_report_value(source) == {
|
||||
"data": {
|
||||
"private_key": "<redacted>",
|
||||
"server.secret": "<redacted>",
|
||||
"mqtt_signature_key": "<redacted>",
|
||||
"token": "<redacted>",
|
||||
"token_count": 17,
|
||||
"public_key": "public-material",
|
||||
"params": [
|
||||
{"paramCode": "server.secret", "paramValue": "<redacted>"},
|
||||
{"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"},
|
||||
],
|
||||
},
|
||||
"headers": {"authorization": "<redacted>"},
|
||||
}
|
||||
|
||||
|
||||
def test_response_summary_never_embeds_sensitive_values_in_body_or_excerpt() -> None:
|
||||
response = CapturedResponse(
|
||||
status=200,
|
||||
headers={"content-type": "application/json", "content-disposition": None, "content-length": None},
|
||||
body={"token": "fixture-token", "password": "fixture-password"},
|
||||
body_sha256="raw-response-digest",
|
||||
)
|
||||
|
||||
summary = _response_summary(response, response.body)
|
||||
|
||||
assert summary["body"] == {"token": "<redacted>", "password": "<redacted>"}
|
||||
assert "fixture-token" not in summary["body_excerpt"]
|
||||
assert "fixture-password" not in summary["body_excerpt"]
|
||||
Reference in New Issue
Block a user