task(00c8cc0d-f2b4-4783-b647-0a1f62f0f650): Add E2E test report & evidence (contract 15 endpoints, BLE 12/12, UI static walkthrough)
This commit is contained in:
344
docs/e2e-evidence/contract_probe.py
Normal file
344
docs/e2e-evidence/contract_probe.py
Normal file
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E 契约层负向探测脚本(S-E2E 任务 00c8cc0d)。
|
||||
|
||||
对接口管理规范 V4.5 的 15 个在用端点发起真实请求,两组负向用例:
|
||||
A 组:缺鉴权三要素头(SecretKey/SystemCode/Token 全缺);
|
||||
B 组:带真实 SecretKey/SystemCode(运行时读 docs/SystemCode_secertkey.txt,不落明文)、缺 Token。
|
||||
|
||||
判定口径(任务书 + core/network/ApiCaller.kt 实证契约):
|
||||
存在性 :HTTP 状态非 404 且非连接错误(000);
|
||||
双层结构 :HTTP 500 且 body 为 JSON、含字符串字段 ExceptionMessage,
|
||||
其值可再解析为含 code 字段的信封 JSON(ASP.NET 异常包装内嵌业务信封);
|
||||
信封解包模拟:内嵌信封 code 必为字符串且取值 ∈ {"200","500","429","403"}(对齐 ApiEnvelope)。
|
||||
|
||||
节流:请求间隔 ≥1.5s;不主动触发 403(连续报错锁定);429 若出现如实记录。
|
||||
|
||||
用法:python3 docs/e2e-evidence/contract_probe.py
|
||||
产出:docs/e2e-evidence/responses/{A|B}_{章节}.txt 原始响应
|
||||
docs/e2e-evidence/contract_results.json 结构化结果
|
||||
docs/e2e-evidence/contract_evidence.md 证据汇总表
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
BASE_URL = "https://jcd.stec.p-q.co"
|
||||
WORKSPACE = Path(__file__).resolve().parents[2]
|
||||
EVIDENCE_DIR = WORKSPACE / "docs" / "e2e-evidence"
|
||||
RESPONSE_DIR = EVIDENCE_DIR / "responses"
|
||||
KEY_FILE = WORKSPACE / "docs" / "SystemCode_secertkey.txt"
|
||||
|
||||
REQUEST_INTERVAL_SEC = 1.5
|
||||
TIMEOUT_SEC = 25
|
||||
VALID_ENVELOPE_CODES = {"200", "500", "429", "403"}
|
||||
|
||||
# 登录/验证码类端点按接口文档 1.2.3 天然不携带 Token。
|
||||
# 探测用参数均为无效占位(10000000000 非法号段 / e2e-probe-* 假 ID / e2e 弱凭据),
|
||||
# 不触达任何真实业务数据,也不触发真实短信下发。
|
||||
ENDPOINTS = [
|
||||
{
|
||||
"sec": "2.2.1", "name": "账号密码登录", "method": "POST",
|
||||
"path": "/OutWebApi/api/LoginByAccount",
|
||||
"body_json": {"LoginName": "e2e_probe_placeholder", "Password": "E2eProbe#2026"},
|
||||
},
|
||||
{
|
||||
"sec": "2.2.2", "name": "发送验证码", "method": "GET",
|
||||
"path": "/OutWebApi/api/LoginSecurityCode",
|
||||
"query": {"MobilePhone": "10000000000"},
|
||||
},
|
||||
{
|
||||
"sec": "2.2.3", "name": "手机验证码登录", "method": "POST",
|
||||
"path": "/OutWebApi/api/LoginByMobile",
|
||||
"body_json": {"Mobile": "10000000000", "VerificationCode": "000000"},
|
||||
},
|
||||
{
|
||||
"sec": "2.3.1", "name": "修改登录密码", "method": "POST",
|
||||
"path": "/OutWebApi/api/ModifyPassword",
|
||||
"body_json": {"Password": "E2eProbe#2026", "ConfirmAnswer": "e2e-probe"},
|
||||
},
|
||||
{"sec": "2.4", "name": "获取当前用户信息", "method": "GET", "path": "/OutWebApi/api/GetUserInfo"},
|
||||
{"sec": "2.5.1.1", "name": "获取当前用户项目列表", "method": "GET", "path": "/OutWebApi/api/GetProjectList"},
|
||||
{
|
||||
"sec": "2.6", "name": "获取APP端任务列表", "method": "GET",
|
||||
"path": "/OutWebApi/api/GetUserPlanGroup",
|
||||
"query": {"PlanDate": "2026-09-04"},
|
||||
},
|
||||
{"sec": "2.7", "name": "获取项目监测计划任务列表", "method": "GET", "path": "/OutWebApi/api/GetPlanGroup"},
|
||||
{
|
||||
"sec": "2.9", "name": "获取测组包含的监测点列表", "method": "GET",
|
||||
"path": "/OutWebApi/api/GetPointBySurveyGroup",
|
||||
"query": {"GroupID": "e2e-probe-group"},
|
||||
},
|
||||
{
|
||||
"sec": "2.10", "name": "获取项目监测计划统计表", "method": "GET",
|
||||
"path": "/OutWebApi/api/GetPlanStatistical",
|
||||
"query": {"ProjectID": "e2e-probe-project"},
|
||||
},
|
||||
{
|
||||
"sec": "2.11", "name": "获取指定监测计划的测组统计表", "method": "GET",
|
||||
"path": "/OutWebApi/api/GetSurveyGroupList",
|
||||
"query": {"PlanID": "e2e-probe-plan"},
|
||||
},
|
||||
{
|
||||
"sec": "2.12", "name": "监测点统计表", "method": "GET",
|
||||
"path": "/OutWebApi/api/GetPointStatistics",
|
||||
"query": {"PlanID": "e2e-probe-plan", "GroupID": "e2e-probe-group"},
|
||||
},
|
||||
{
|
||||
"sec": "2.13", "name": "数据解析(原始文件上传)", "method": "POST_MULTIPART",
|
||||
"path": "/OutWebApi/api/AnalysisMonitorData",
|
||||
"multipart_fields": {
|
||||
"ProjectID": "e2e-probe-project",
|
||||
"WorkPointID": "e2e-probe-workpoint",
|
||||
"SurveyGroupID": "e2e-probe-group",
|
||||
"PlanID": "e2e-probe-plan",
|
||||
"MonitorDate": "2026-09-04",
|
||||
"WorkInfo": "e2e-probe",
|
||||
"Equipment": "e2e-probe",
|
||||
"Weather": "晴",
|
||||
"PointsData": json.dumps(
|
||||
[{"Point": "e2e-probe-point", "PointName": "e2e-probe-point",
|
||||
"CurrentValue": "0", "CurrentChangeValue": "0", "IsInitValue": "false"}],
|
||||
ensure_ascii=False),
|
||||
},
|
||||
"multipart_file": ("E2eProbe.txt", "e2e probe placeholder file\n"),
|
||||
},
|
||||
{
|
||||
"sec": "2.14", "name": "获取基准点", "method": "GET",
|
||||
"path": "/OutWebApi/api/ControlPoint",
|
||||
"query": {"ProjectID": "e2e-probe-project"},
|
||||
},
|
||||
{"sec": "2.15", "name": "获取工点数据", "method": "GET", "path": "/OutWebApi/api/GetWorkPoint"},
|
||||
]
|
||||
|
||||
# 与 core/network/AuthInterceptor 注入头保持同名。
|
||||
HEADER_SECRET_KEY = "SecretKey"
|
||||
HEADER_SYSTEM_CODE = "SystemCode"
|
||||
HEADER_TOKEN = "Token"
|
||||
|
||||
|
||||
def load_keys():
|
||||
"""解析 docs/SystemCode_secertkey.txt(格式:`SystemCode:Mobile` / `SecretKey:<hex>`)。"""
|
||||
keys = {}
|
||||
for line in KEY_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, _, value = line.partition(":")
|
||||
keys[name.strip()] = value.strip()
|
||||
return keys.get("SystemCode", ""), keys.get("SecretKey", "")
|
||||
|
||||
|
||||
def build_request(ep, case, system_code, secret_key):
|
||||
"""按端点与用例组构造 urllib 请求;返回 (request, 描述行)。"""
|
||||
url = BASE_URL + ep["path"]
|
||||
method = ep["method"]
|
||||
headers = {"User-Agent": "stec-cmd-e2e-probe/1.0", "Accept": "application/json, text/*;q=0.9"}
|
||||
if case == "B":
|
||||
headers[HEADER_SYSTEM_CODE] = system_code
|
||||
headers[HEADER_SECRET_KEY] = secret_key
|
||||
# A/B 两组均不带 Token(负向目标即缺 Token 行为)。
|
||||
|
||||
data = None
|
||||
if method == "GET":
|
||||
if ep.get("query"):
|
||||
from urllib.parse import urlencode
|
||||
url += "?" + urlencode(ep["query"])
|
||||
elif method == "POST":
|
||||
body = json.dumps(ep["body_json"], ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
data = body
|
||||
elif method == "POST_MULTIPART":
|
||||
boundary = "----e2eProbe" + uuid.uuid4().hex[:12]
|
||||
parts = []
|
||||
for name, value in ep["multipart_fields"].items():
|
||||
parts.append(
|
||||
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode("utf-8"))
|
||||
filename, content = ep["multipart_file"]
|
||||
parts.append(
|
||||
(f"--{boundary}\r\nContent-Disposition: form-data; name=\"Files\"; "
|
||||
f"filename=\"{filename}\"\r\nContent-Type: text/plain\r\n\r\n").encode("utf-8")
|
||||
+ content.encode("utf-8") + b"\r\n")
|
||||
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
||||
data = b"".join(parts)
|
||||
else:
|
||||
raise ValueError(f"未知方法 {method}")
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="GET" if method == "GET" else "POST")
|
||||
auth = {k: headers.get(k) for k in (HEADER_SECRET_KEY, HEADER_SYSTEM_CODE, HEADER_TOKEN)}
|
||||
return req, auth
|
||||
|
||||
|
||||
def do_request(req):
|
||||
"""发请求并归一返回 (http_status, body_text, err)。连接失败时 status 记 '000'。"""
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC, context=ctx) as resp:
|
||||
return str(resp.status), resp.read().decode("utf-8", errors="replace"), None
|
||||
except urllib.error.HTTPError as e:
|
||||
return str(e.code), e.read().decode("utf-8", errors="replace"), None
|
||||
except Exception as e: # 连接失败/超时/TLS
|
||||
return "000", "", f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def analyze(ep, case, status, body):
|
||||
"""按判定口径分析一个响应,返回结果 dict。"""
|
||||
result = {
|
||||
"sec": ep["sec"], "name": ep["name"], "case": case,
|
||||
"http_status": status,
|
||||
"exists": None, "double_layer": None,
|
||||
"envelope_code": None, "envelope_code_is_str": None,
|
||||
"envelope_msg": None, "envelope_data": None,
|
||||
"exception_type": None, "notes": "",
|
||||
}
|
||||
if status == "000":
|
||||
result["exists"] = False
|
||||
result["notes"] = "连接失败"
|
||||
return result
|
||||
result["exists"] = status != "404"
|
||||
if status == "404":
|
||||
result["notes"] = "端点不存在"
|
||||
return result
|
||||
|
||||
wrapper = None
|
||||
if body.strip().startswith("{"):
|
||||
try:
|
||||
wrapper = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
wrapper = None
|
||||
|
||||
embedded = None
|
||||
if isinstance(wrapper, dict):
|
||||
result["exception_type"] = wrapper.get("ExceptionType")
|
||||
raw = wrapper.get("ExceptionMessage")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
embedded = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
embedded = None
|
||||
elif isinstance(raw, dict):
|
||||
embedded = raw # 个别端点可能直接内嵌对象
|
||||
|
||||
if embedded and isinstance(embedded, dict) and "code" in embedded:
|
||||
result["double_layer"] = (status == "500")
|
||||
code = embedded.get("code")
|
||||
result["envelope_code"] = code
|
||||
result["envelope_code_is_str"] = isinstance(code, str)
|
||||
result["envelope_msg"] = embedded.get("msg")
|
||||
result["envelope_data"] = embedded.get("data")
|
||||
result["notes"] = (
|
||||
"双层结构成立" if result["double_layer"] and result["envelope_code_is_str"]
|
||||
else "内嵌信封可解但形态偏离(见 http_status/code 类型)")
|
||||
else:
|
||||
# 可能是裸业务信封(HTTP 200 + {code,...})或非 JSON(HTML 错误页等)
|
||||
if isinstance(wrapper, dict) and "code" in wrapper:
|
||||
result["double_layer"] = False
|
||||
code = wrapper.get("code")
|
||||
result["envelope_code"] = code
|
||||
result["envelope_code_is_str"] = isinstance(code, str)
|
||||
result["envelope_msg"] = wrapper.get("msg")
|
||||
result["envelope_data"] = wrapper.get("data")
|
||||
result["notes"] = "裸业务信封(未走 ASP.NET 异常包装)"
|
||||
else:
|
||||
result["double_layer"] = False
|
||||
result["notes"] = "响应非可解析双层结构(原始体见存档)"
|
||||
return result
|
||||
|
||||
|
||||
def save_raw(ep, case, status, auth, body, err):
|
||||
stem = f"{case}_{ep['sec'].replace('.', '_')}"
|
||||
keys_desc = "无三要素头" if not auth.get(HEADER_SECRET_KEY) else (
|
||||
f"{HEADER_SYSTEM_CODE}={auth.get(HEADER_SYSTEM_CODE)}, "
|
||||
f"{HEADER_SECRET_KEY}={str(auth.get(HEADER_SECRET_KEY))[:6]}***(脱敏), 无 Token")
|
||||
lines = [
|
||||
f"# 端点 {ep['sec']} {ep['name']} 用例组 {case}" + ("(缺三要素头)" if case == "A" else "(带密钥缺 Token)"),
|
||||
f"# 请求:{ep['method']} {BASE_URL}{ep['path']}",
|
||||
f"# 请求头:{keys_desc}",
|
||||
f"# HTTP 状态:{status}" + (f" 错误:{err}" if err else ""),
|
||||
f"# 存档时间:{time.strftime('%Y-%m-%dT%H:%M:%S%z')}",
|
||||
"=" * 60,
|
||||
body if body else "(空响应体)",
|
||||
]
|
||||
(RESPONSE_DIR / f"{stem}.txt").write_text("\n".join(lines), encoding="utf-8")
|
||||
return RESPONSE_DIR / f"{stem}.txt"
|
||||
|
||||
|
||||
def write_markdown(results):
|
||||
rows = []
|
||||
verdict_a = verdict_b = 0
|
||||
for r in results:
|
||||
ok = (
|
||||
r["exists"] is True
|
||||
and r["http_status"] == "500"
|
||||
and r["double_layer"] is True
|
||||
and r["envelope_code_is_str"] is True
|
||||
and r["envelope_code"] in VALID_ENVELOPE_CODES
|
||||
)
|
||||
if r["case"] == "A" and ok:
|
||||
verdict_a += 1
|
||||
if r["case"] == "B" and ok:
|
||||
verdict_b += 1
|
||||
code = r["envelope_code"]
|
||||
rows.append(
|
||||
"| {sec} | {name} | {case} | {st} | {ex} | {dl} | {code} | {isstr} | {msg} | {note} |".format(
|
||||
sec=r["sec"], name=r["name"], case=r["case"], st=r["http_status"],
|
||||
ex="存在" if r["exists"] else "否",
|
||||
dl="成立" if r["double_layer"] else "不成立",
|
||||
code="`%s`" % code if code is not None else "—",
|
||||
isstr="是" if r["envelope_code_is_str"] else "否",
|
||||
msg=(r["envelope_msg"] or "")[:40] if isinstance(r["envelope_msg"], str) else ("" if r["envelope_msg"] is None else str(r["envelope_msg"])[:40]),
|
||||
note=r["notes"],
|
||||
))
|
||||
md = "\n".join([
|
||||
"# 契约层负向探测证据表(自动生成于 %s)" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"",
|
||||
"- 服务器:%s(接口管理规范 V4.5 测试环境)" % BASE_URL,
|
||||
"- 用例组 A:缺三要素头(SecretKey/SystemCode/Token 全缺);用例组 B:带 docs/SystemCode_secertkey.txt 密钥、缺 Token。",
|
||||
"- 判定:存在性=HTTP≠404/000;双层结构=HTTP 500 且 ExceptionMessage 内嵌信封 JSON;信封解包=code 为字符串且 ∈ {200,500,429,403}。",
|
||||
"- 通过:A 组 %d/15,B 组 %d/15。" % (verdict_a, verdict_b),
|
||||
"- 原始响应存档:`docs/e2e-evidence/responses/`(密钥已脱敏)。",
|
||||
"",
|
||||
"| 章节 | 端点 | 用例 | HTTP | 存在 | 双层结构 | 信封code | code为字符串 | 信封msg | 备注 |",
|
||||
"|---|---|---|---|---|---|---|---|---|---|",
|
||||
] + rows + [""])
|
||||
(EVIDENCE_DIR / "contract_evidence.md").write_text(md, encoding="utf-8")
|
||||
return verdict_a, verdict_b
|
||||
|
||||
|
||||
def main():
|
||||
RESPONSE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
system_code, secret_key = load_keys()
|
||||
if not secret_key:
|
||||
raise SystemExit("密钥文件解析失败:%s" % KEY_FILE)
|
||||
print("密钥加载完成(SystemCode=%s, SecretKey=%s…脱敏)" % (system_code, secret_key[:6]))
|
||||
|
||||
results = []
|
||||
for case in ("A", "B"):
|
||||
for ep in ENDPOINTS:
|
||||
req, auth = build_request(ep, case, system_code, secret_key)
|
||||
status, body, err = do_request(req)
|
||||
raw_path = save_raw(ep, case, status, auth, body, err)
|
||||
r = analyze(ep, case, status, body)
|
||||
if err:
|
||||
r["notes"] += f"({err})"
|
||||
r["raw_file"] = str(raw_path.relative_to(WORKSPACE))
|
||||
results.append(r)
|
||||
print("[{case}] {sec:<8} {st:<3} 双层={dl} code={code} {name}".format(
|
||||
case=case, sec=ep["sec"], st=status,
|
||||
dl=r["double_layer"], code=r["envelope_code"], name=ep["name"]))
|
||||
time.sleep(REQUEST_INTERVAL_SEC)
|
||||
|
||||
(EVIDENCE_DIR / "contract_results.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
verdict_a, verdict_b = write_markdown(results)
|
||||
print("完成:A 组通过 %d/15,B 组通过 %d/15;证据见 docs/e2e-evidence/" % (verdict_a, verdict_b))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user