Files
cma-management/backend/scripts/verify_map_versions_e2e.py
T
Hermes CI Fix 40d522cd34 fix(security): retire default-credential surface (yanxue-20260914-cma-defaultcred-code-fix)
- Login.vue: no prefilled default creds; no anonymous entity enumeration on mount

- deploy.sh: readiness polling (0.5s/30s cap) replaces fixed sleep 3; login smoke reads same-source 600 secret file, missing file = explicit abort

- 19 verify/check/gen_report scripts: shared _admin_creds() loader (CMA_ADMIN_ENV -> ops .secrets -> profile copy); no hardcoded creds

- tests: single TEST_USER_PASSWORD fixture constant; docs: pointer to 600 secret file; removed 4 legacy .bak + pycache residue
2026-09-14 17:39:53 +08:00

197 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""战略地图版本管理 生产 E2E 验收脚本 (project-20260908-map-versions-page)
覆盖 DoD: 列表摘要/详情/回滚(自动备份)/备注编辑/跨entity404/发布自动快照
安全: 用临时地图 (title 带标记) → 全程 API → 完成后 DELETE 地图清理 (FK 级联删版本行)
可复跑: venv/bin/python3 backend/scripts/verify_map_versions_e2e.py
"""
import json
import sys
import time
import urllib.request
import os # noqa: E402 (凭据加载器需要)
# ---- 管理员凭据同源加载(2026-09-14 cma-defaultcred-code-fix)----
# 生产凭据只存 600 密钥文件;禁止在脚本/文档/日志里硬编码口令。
# 顺序:$CMA_ADMIN_ENV → wecom-ops 主副本(ops 轮换落点,权威) → 本 profile 副本。
def _admin_creds():
"""返回 (user, password, 来源文件);未找到 → 显式报错退出(不静默退回默认口令)"""
for p in (os.environ.get("CMA_ADMIN_ENV"),
"/root/.hermes/profiles/wecom-ops/.secrets/cma-admin.env",
"/root/.hermes/profiles/wecom-fullstack/.secrets/cma-admin.env"):
if p and os.path.isfile(p):
kv = {}
for line in open(p, encoding="utf-8"):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
kv[k.strip()] = v.strip().strip('"').strip("'")
if kv.get("CMA_ADMIN_USER") and kv.get("CMA_ADMIN_PASS"):
return kv["CMA_ADMIN_USER"], kv["CMA_ADMIN_PASS"], p
raise SystemExit("[错误] 未找到 CMA 管理员凭据:请设 $CMA_ADMIN_ENV,或准备 600 权限密钥文件 "
".secrets/cma-admin.env(键名 CMA_ADMIN_USER / CMA_ADMIN_PASS)")
ADMIN_USER, ADMIN_PASS, CRED_SRC = _admin_creds()
print(f"[creds] 来源={CRED_SRC} 账号={ADMIN_USER}(口令不回显)")
BASE = "http://127.0.0.1:8010/api/cma"
MARK = "【系统验证临时数据-验证后删除】"
PASSED = []
def log(ok, msg):
PASSED.append(ok)
print(("✅" if ok else "❌") + " " + msg)
def api(method, path, token=None, body=None):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"detail": raw[:300]}
def login(entity_id):
st, d = api("POST", "/auth/login", body={"username": ADMIN_USER, "password": ADMIN_PASS, "entity_id": entity_id})
assert st == 200, f"login entity{entity_id} fail: {d}"
return d["token"]
# ── 环境读取 ────────────────────────────────
def main():
# 1) 双账套 token (admin 授权 entity1+2)
tok2 = login(2)
tok1 = login(1)
log(True, "双账套登录 admin (entity1/2)")
# 2) map49 (博海 entity2) 版本列表含新摘要字段
st, d = api("GET", "/maps/49/versions", tok2)
if st == 200 and isinstance(d.get("data"), list) and len(d["data"]) >= 2:
row = d["data"][0]
need = {"id", "map_id", "entity_id", "version", "comment", "created_at",
"dimension_count", "objective_count", "kpi_count", "connection_count", "is_current"}
missing = need - set(row.keys())
log(not missing, f"map49 版本列表 {len(d['data'])}条, 字段完整 missing={missing or '无'}")
v4 = next((x for x in d["data"] if x["version"] == "v4.0"), None)
log(bool(v4 and v4["objective_count"] == 13 and v4["kpi_count"] >= 1),
f"map49 v4.0 摘要目标数={v4 and v4['objective_count']} KPI数={v4 and v4['kpi_count']}")
log(bool(d["data"][0].get("version") in ("v4.0",)),
"map49 列表按 created_at 倒序, 首条=" + str(d["data"][0]["version"]))
v1_id = next(x["id"] for x in d["data"] if x["version"] == "v1.0")
# 详情全量
st, det = api("GET", f"/maps/49/versions/{v1_id}", tok2)
log(st == 200 and isinstance(det.get("dimensions"), list) and isinstance(det.get("canvas_data"), dict),
"map49 v1.0 详情含全量 dimensions/canvas_data")
# 备注编辑 (恢复原样: 发布 v1.0)
st, cm = api("PUT", f"/maps/49/versions/{v1_id}", tok2, {"comment": "发布 v1.0"})
log(st == 200 and (cm.get("comment") == "发布 v1.0" or cm.get("version", {}).get("comment") == "发布 v1.0"),
f"map49 v1.0 备注PUT回原值 {st}")
else:
log(False, f"map49 列表异常 st={st} d={str(d)[:200]}")
# 3) 跨 entity: entity1 token 访问 map49(entity2) → 全部 404
cross_cases = [
("GET", "/maps/49/versions", None),
("GET", "/maps/49/versions/9", None),
("POST", "/maps/49/versions/9/rollback", None),
("PUT", "/maps/49/versions/9", {"comment": "x"}),
]
all404 = True
for m, p, b in cross_cases:
st, _ = api(m, p, tok1, b)
if st != 404:
all404 = False
print(f" cross-entity {m} {p} -> {st}")
log(all404, "跨账套 entity1 访问 map49 列表/详情/回滚/备注 全 404")
# 4) 临时地图全链路 (entity2): 建模板→发布(v1.0)→改内容→回滚(备份v1.1)→备注→详情
tmp_title = "E2E版本管理测试 " + MARK
st, m = api("POST", "/maps/create-with-template", tok2, {"title": tmp_title})
if st != 200:
log(False, f"临时地图创建失败 {st} {m}")
sys.exit(1)
mid = m["id"]
log(True, f"临时地图创建 id={mid} entity2")
st, _ = api("PUT", f"/maps/{mid}", tok2, {"status": "published"})
log(st == 200, "临时地图发布 (触发 _auto_snapshot)")
st, d = api("GET", f"/maps/{mid}/versions", tok2)
rows = d.get("data", [])
log(st == 200 and len(rows) == 1 and rows[0]["version"] == "v1.0" and rows[0]["is_current"] is True,
f"发布后版本列表 v1.0 is_current=True dims={rows[0]['dimension_count'] if rows else '?'}")
v1 = rows[0]
v1_detail = api("GET", f"/maps/{mid}/versions/{v1['id']}", tok2)[1]
# 改内容 (加新目标)
new_dims = json.loads(json.dumps(v1_detail["dimensions"]))
new_dims[0]["objectives"].append({"name": "E2E新目标" + MARK, "icon": "target",
"kpis": ["F_REVENUE"], "krs": [{"name": "KR-E2E", "kpi_code": "F_REVENUE"}]})
st, _ = api("PUT", f"/maps/{mid}", tok2, {"dimensions": new_dims,
"canvas_data": {"connections": [{"from": "finance-0", "to": "finance-1"}]}})
log(st == 200, "临时地图内容更新 (published 状态保存不自动快照)")
# 回滚 → 备份 v1.1
st, rb = api("POST", f"/maps/{mid}/versions/{v1['id']}/rollback", tok2)
log(st == 200 and rb.get("version") == "v1.0" and rb.get("backup_version") == "v1.1",
f"回滚成功 自动备份={rb.get('backup_version')} msg={rb.get('message','')[:40]}")
st, d = api("GET", f"/maps/{mid}/versions", tok2)
rows = d.get("data", [])
ver_set = {x["version"] for x in rows}
backup = next((x for x in rows if x["version"] == "v1.1"), None)
cur = next((x for x in rows if x["version"] == "v1.0"), None)
log(st == 200 and ver_set == {"v1.0", "v1.1"} and backup and backup["comment"] == "回滚前自动备份"
and cur and cur["is_current"] is True,
f"回滚后列表 2条={ver_set}, v1.1=自动备份, v1.0=当前")
# 内容还原验证 (业务字段)
maps_st, maps_d = api("GET", "/maps", tok2)
cur_map = next(x for x in maps_d["data"] if x["id"] == mid)
def norm(dims):
return [{"key": x.get("key"), "objectives": [
{k: o.get(k) for k in ("name", "kpis", "krs")} for o in x.get("objectives", [])]} for x in dims]
log(cur_map["version"] == "v1.0" and norm(cur_map["dimensions"]) == norm(v1_detail["dimensions"])
and cur_map["canvas_data"] == v1_detail["canvas_data"],
"回滚后地图内容语义还原为 v1.0 快照")
# 备注编辑
st, cm = api("PUT", f"/maps/{mid}/versions/{backup['id']}", tok2, {"comment": "E2E备份备注" + MARK})
log(st == 200, "回滚备份行备注编辑成功")
st, d = api("GET", f"/maps/{mid}/versions", tok2)
b2 = next(x for x in d["data"] if x["version"] == "v1.1")
log(("E2E备份备注" + MARK) in (b2.get("comment") or ""), "备注修改后列表可见")
# 详情全量
st, det = api("GET", f"/maps/{mid}/versions/{v1['id']}", tok2)
log(st == 200 and isinstance(det.get("dimensions"), list) and "canvas_data" in det, "版本详情含全量画布")
# 5) 清理: DELETE 临时地图 (FK 级联删版本行) + 回查
st, _ = api("DELETE", f"/maps/{mid}", tok2)
log(st == 200, "清理: 临时地图已删除")
st, d = api("GET", "/maps", tok2)
gone = not any(x["id"] == mid for x in d["data"])
log(gone, "清理后地图列表无临时地图")
st, d2 = api("GET", f"/maps/{mid}/versions", tok2)
log(st == 404, "清理后临时地图版本接口 404 (地图不存在)")
# 6) map49 未被污染
st, d = api("GET", "/maps/49/versions", tok2)
log(st == 200 and d["data"][0]["version"] == "v4.0" and d["data"][0].get("is_current") is True,
"map49 保持 v4.0 当前未受影响")
ok = sum(1 for x in PASSED if x)
print(f"\n结果: {ok}/{len(PASSED)} PASS")
sys.exit(0 if ok == len(PASSED) else 2)
if __name__ == "__main__":
main()