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:
阿猫
2026-09-04 03:25:13 +08:00
parent b8e4c24fd2
commit d6d6bae71d
46 changed files with 3964 additions and 0 deletions

View 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 字段的信封 JSONASP.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,...})或非 JSONHTML 错误页等)
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/15B 组 %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/15B 组通过 %d/15证据见 docs/e2e-evidence/" % (verdict_a, verdict_b))
if __name__ == "__main__":
main()