"""Exercise the JSON report POST protocol and idempotent retries without logging credentials.""" import argparse import base64 import hashlib import hmac import json import math import os import re import secrets import time from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlsplit import httpx ROOT = Path(__file__).resolve().parents[1] DEFAULT_PAYLOAD = ROOT / "docs" / "examples" / "plm-risk-request-20261009.json" DEFAULT_URL = "http://192.168.9.28:18474/api/v1/integrations/risk-assessments/report" LEVELS = { "low": "低风险", "moderately_low": "较低风险", "medium": "中等风险", "high": "高风险", "very_high": "极高风险", } SAFE_HEADERS = ( "content-type", "content-encoding", "x-request-id", "x-assessment-id", "x-model-version", "x-risk-score", "x-risk-level", "retry-after", ) def signed_headers(method: str, url: str, body: bytes, secret: str, client_id: str, request_id: str) -> dict: timestamp, nonce = str(int(time.time())), secrets.token_urlsafe(16) message = timestamp.encode() + b"\n" + nonce.encode() + b"\n" headers = {"X-Client-Id": client_id, "X-Timestamp": timestamp, "X-Nonce": nonce} if method != "POST": raise ValueError("Only the report POST endpoint is supported") message += body headers.update({"Content-Type": "application/json", "Idempotency-Key": request_id}) headers["X-Signature"] = base64.b64encode(hmac.new(secret.encode(), message, hashlib.sha256).digest()).decode() return headers def report_metadata(response: httpx.Response, request_id: str) -> dict: if response.status_code != 200: raise ValueError(f"Expected HTTP 200, received {response.status_code}") if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": raise ValueError("Successful response must be application/json") if response.headers.get("x-request-id") != request_id: raise ValueError("X-Request-Id does not match this request") score = float(response.headers.get("x-risk-score", "nan")) if not math.isfinite(score) or not 0 <= score <= 100: raise ValueError("Invalid X-Risk-Score") level = response.headers.get("x-risk-level") if level not in LEVELS: raise ValueError("Invalid X-Risk-Level") assessment_id, model = response.headers.get("x-assessment-id"), response.headers.get("x-model-version") if not assessment_id or not model: raise ValueError("Missing assessment ID or model version") # httpx already decompresses gzip/deflate. Decode the resulting bytes exactly once. if len(response.content) > 1024 * 1024: raise ValueError("Decompressed report exceeds 1MiB") data = response.json()["data"] if response.json().get("code") != 200 or data.get("requestId") != request_id: raise ValueError("Report JSON does not match this request") markdown = data["reportMarkdown"] if not markdown.strip(): raise ValueError("Empty report") dimensions = sum( bool(re.match(r"^\|\s*(供应商表现|物料品类|产品品类|加工厂质量|标准符合性|节点阶段|缺陷等级)\s*\|", line)) for line in markdown.splitlines() ) if dimensions != 7: raise ValueError(f"Expected 7 dimension rows, received {dimensions}") return { "requestId": request_id, "assessmentId": assessment_id, "score": score, "level": level, "levelLabel": LEVELS[level], "modelVersion": model, "dimensionCount": dimensions, "reportSha256": hashlib.sha256(markdown.encode("utf-8")).hexdigest(), } class PLMReportTest: def __init__( self, client: httpx.Client, url: str, secret: str, payload: dict, output: Path, total_timeout: float = 180 ): self.client, self.url, self.secret, self.payload, self.output = client, url, secret, payload, output self.request_id = payload["requestId"] self.body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") self.events = [] self.deadline = time.monotonic() + total_timeout def send(self, method: str) -> httpx.Response: remaining = self.deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Overall test deadline exceeded; use the saved snapshot to resume") url = self.url body = self.body headers = signed_headers(method, url, body, self.secret, self.payload["sourceSystem"], self.request_id) started = time.monotonic() try: response = self.client.request( method, url, content=body, headers=headers, timeout=httpx.Timeout(min(130, remaining), connect=min(10, remaining)), ) except httpx.HTTPError as exc: self.events.append( { "method": method, "transportError": exc.__class__.__name__, "seconds": round(time.monotonic() - started, 2), } ) raise event = { "method": method, "status": response.status_code, "seconds": round(time.monotonic() - started, 2), "headers": {key: response.headers[key] for key in SAFE_HEADERS if key in response.headers}, } if response.status_code != 200: try: event["error"] = response.json() except ValueError: event["error"] = {"message": "Non-JSON error response"} self.events.append(event) print(f"{method} HTTP {response.status_code} ({event['seconds']}s)", flush=True) return response def wait(self, response: httpx.Response): try: delay = max(1, min(60, int(response.headers.get("retry-after", "5")))) except ValueError: delay = 5 if time.monotonic() + delay >= self.deadline: raise TimeoutError("Retry-After exceeds remaining test deadline") # Short waits keep progress visible even if rate limiting requests a long delay. print(f"Wait {delay}s before retry", flush=True) while delay: step = min(5, delay) time.sleep(step) delay -= step def resolve(self, response: httpx.Response) -> httpx.Response: for _ in range(40): if response.status_code == 200: return response if response.status_code not in {202, 429}: raise ValueError(f"HTTP {response.status_code}; inspect response.json for platform diagnostics") if response.status_code == 202: data = response.json() if data.get("code") != 202 or (data.get("data") or {}).get("requestId") != self.request_id: raise ValueError("Processing response does not match this request") self.wait(response) response = self.send("POST") raise TimeoutError("Polling limit reached; use the saved snapshot to resume") def run(self) -> dict: self.output.mkdir(parents=True, exist_ok=True) (self.output / "request.json").write_bytes(self.body) checks = [] try: first = self.send("POST") except httpx.HTTPError: first = self.send("POST") first = self.resolve(first) metadata = report_metadata(first, self.request_id) (self.output / "report.md").write_bytes(first.json()["data"]["reportMarkdown"].encode("utf-8")) checks.append("JSON 返回完整 Markdown 报告,编号、分数、等级及七个维度校验通过") repeated = self.send("POST") if repeated.status_code == 429: self.wait(repeated) repeated = self.send("POST") repeated = self.resolve(repeated) if report_metadata(repeated, self.request_id) != metadata or repeated.content != first.content: raise ValueError("Repeated POST differs from the original report") checks.append("同编号同内容 POST 返回原报告,幂等校验通过") return { "success": True, "endpoint": self.url, **metadata, "checks": checks, "requestSha256": hashlib.sha256(self.body).hexdigest(), "events": self.events, } def load_secret(client_id: str, use_local_config: bool) -> str: secret = os.environ.get("RISK_CLIENT_SECRET") or os.environ.get("RISK_INTEGRATION_CLIENT_SECRET") if not secret and use_local_config: # Explicit opt-in for running this tool in the risk backend workspace. from dotenv import dotenv_values raw = os.environ.get("RISK_INTEGRATION_CLIENTS") or dotenv_values(ROOT / ".env").get("RISK_INTEGRATION_CLIENTS") secret = json.loads(raw or "{}").get(client_id) if not isinstance(secret, str) or len(secret) < 32: raise ValueError("Set RISK_CLIENT_SECRET or use --local-config to read this backend's .env") return secret def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--url", default=DEFAULT_URL) parser.add_argument("--body-file", type=Path, default=DEFAULT_PAYLOAD) parser.add_argument("--output-dir", type=Path) parser.add_argument("--local-config", action="store_true", help="Read secret from backend .env without printing it") parser.add_argument( "--new-request", action="store_true", help="Use a unique requestId to generate a new assessment" ) args = parser.parse_args() stamp = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d-%H%M%S") output = args.output_dir or ROOT / ".runtime-uploads" / "integration-tests" / (stamp + "-" + secrets.token_hex(3)) output.mkdir(parents=True, exist_ok=True) runner = None try: target = urlsplit(args.url) if target.scheme not in {"http", "https"} or target.query or target.fragment or target.username: raise ValueError("Endpoint must be HTTP(S), without credentials, query or fragment") payload = json.loads(args.body_file.read_text(encoding="utf-8-sig")) if args.new_request: payload["requestId"] = f"PLM-TEST-{payload['assessment']['subjectId']}-{stamp}-{secrets.token_hex(4)}" secret = load_secret(payload["sourceSystem"], args.local_config) with httpx.Client(follow_redirects=False, trust_env=False) as client: runner = PLMReportTest(client, args.url, secret, payload, output) result = runner.run() except (ValueError, KeyError, OSError, TimeoutError, httpx.HTTPError) as exc: result = { "success": False, "errorType": exc.__class__.__name__, "message": str(exc) if not isinstance(exc, (httpx.HTTPError, OSError)) else "Transport or file operation failed", "events": runner.events if runner else [], } (output / "response.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") if result["success"]: summary = [ "# PLM 风险接口测试结果", "", f"- 请求编号:{result['requestId']}", f"- 平台评估 ID:{result['assessmentId']}", f"- 综合安全得分:{result['score']:.2f}", f"- 风险等级:{result['levelLabel']}", f"- 模型版本:{result['modelVersion']}", f"- 维度数量:{result['dimensionCount']}", "", "报告:[report.md](report.md)", "", ] summary.extend(f"- {check}" for check in result["checks"]) summary.extend( [ "", "本次测试沿用接口现有评分规则;HTTP 200 不代表全部维度拥有真实数据。", "当品类为护发素时,当前规则可能无法归类,报告会注明品类维度默认分。", "", ] ) (output / "summary.md").write_text("\n".join(summary), encoding="utf-8") print(f"Score: {result['score']:.2f}; level: {result['level']}; assessment: {result['assessmentId']}") print(f"Outputs: {output.resolve()}") return 0 if result["success"] else 1 if __name__ == "__main__": raise SystemExit(main())