"""Exercise the PLM POST/GET protocol and save the real report 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 urlencode, 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 == "GET": target = urlsplit(url) message = b"GET\n" + target.path.encode() + b"\n" + target.query.encode() + b"\n" + message else: 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() != "text/markdown": raise ValueError("Successful response must be text/markdown") 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") markdown = response.content.decode("utf-8") 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(response.content).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.query_url = url + "?" + urlencode({"requestId": self.request_id}) 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.query_url if method == "GET" else self.url body = b"" if method == "GET" else 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("GET") 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 = [] first = self.send("GET") while first.status_code == 429: self.wait(first) first = self.send("GET") if first.status_code in {404, 503}: try: first = self.send("POST") except httpx.HTTPError: # A timed-out POST may already have persisted the request. first = self.send("GET") first = self.resolve(first) metadata = report_metadata(first, self.request_id) (self.output / "report.md").write_bytes(first.content) checks.append("报告为 UTF-8 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 返回原报告,幂等校验通过") queried = self.resolve(self.send("GET")) if report_metadata(queried, self.request_id) != metadata or queried.content != first.content: raise ValueError("GET report differs from the original report") checks.append("GET 报告正文、评估 ID、分数、等级及模型版本一致") 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())