Files
cma-management/backend/scripts/verify_bot_write_tiering_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

244 lines
11 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实证: Bot写操作分级+分层授权 (P0-② 2026-09-03)
链路验证:
A. Bot通道(X-BOT-KEY)创建 kpi_target 高危建议 → status=unapplied
B. 人token apply → KPI目标值变更 + apply_detail(before→after) + applied_by/applied_at
C. 低危直写 bot-bridge /kpi-result (X-BRIDGE-TOKEN) → KPIValue.source_batch 落库
D. bot_audit.log 留痕(两条: 建议创建L2 + kpi-result L2)
数据纪律: 临时KPI/建议/值 全部带【系统验证临时数据-验证后删除】标记,
验证完成后精确清理并回查 COUNT=0。审计日志文件行保留(留痕证据)。
用法: cd /root/cma-management/backend && venv/bin/python3 scripts/verify_bot_write_tiering_e2e.py
"""
import json
import os
import sys
import urllib.request
import urllib.error
# ---- 管理员凭据同源加载(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"
MARK = "【系统验证临时数据-验证后删除】"
KPI_CODE = "E2E_WT_TIER"
SUG_PREFIX = "E2E-验证"
CUR_PERIOD = "2026-09"
BASELINE_TARGET = 100.0
NEW_TARGET = 888.88
LOW_VALUE = 777.0
SOURCE = "e2e-bot-tier-20260903"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("CMA_DB_HOST", "127.0.0.1")
from app.database import get_db # noqa: E402
from app.models import ( # noqa: E402
BotBridgeConfig, KPIDefinition, KPIValue, AISuggestion, OperationLog, Entity,
)
def http(method, path, body=None, headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode())
def main():
failures = []
def check(name, cond, detail=""):
tag = "PASS" if cond else "FAIL"
print(f" [{tag}] {name}" + (f" | {detail}" if detail else ""))
if not cond:
failures.append(name)
# ── 0. 取Bot密钥与bridge token ──
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")) as f:
env = {}
for line in f:
if "=" in line and not line.strip().startswith("#"):
k, _, v = line.strip().partition("=")
env[k] = v.strip().strip('"').strip("'")
keys = json.loads(env["CMA_BOT_API_KEYS"])
fin_key = next(k for k, v in keys.items() if v.get("name") == "财务BOT")
db = next(get_db())
bridge = db.query(BotBridgeConfig).filter_by(is_active=True).first()
bridge_token = bridge.token if bridge else None
db.close()
# ── 1. 登录(人token, entity=1) ──
st, r = http("POST", "/api/cma/auth/login",
{"username": ADMIN_USER, "password": ADMIN_PASS, "entity_id": 1})
check("登录 admin entity=1", st == 200, f"http={st}")
token = r.get("token") if st == 200 else ""
auth = {"Authorization": f"Bearer {token}"}
# ── 2. 临时KPI准备(存在则复用, 目标值重置基线) ──
db = next(get_db())
db.rollback()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == KPI_CODE,
KPIDefinition.entity_id == 1,
).first()
if not kpi:
kpi = KPIDefinition(
entity_id=1, kpi_code=KPI_CODE,
kpi_name=f"{MARK}写分层E2E临时KPI",
dimension="finance", category="financial_report", formula="-",
data_source_type="manual", data_source=f"{MARK}E2E", data_owner="系统验证",
frequency="monthly", unit="万元", target_value=BASELINE_TARGET,
kpi_level="operational", status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
kpi_id = kpi.id
db.close()
else:
kpi_id = kpi.id
kpi.target_value = BASELINE_TARGET
db.commit()
db.refresh(kpi)
db.close()
print(f" 临时KPI id={kpi_id} code={KPI_CODE} baseline={BASELINE_TARGET}")
# ── 3. Bot创建高危建议(kpi_target) ──
sug_title = f"{SUG_PREFIX} 上调临时KPI目标→{NEW_TARGET}"
st, r = http("POST", "/api/cma/bot/ai/suggestions", {
"suggestion_type": "kpi_target", "title": sug_title, "content": "分层授权E2E实证",
"source": "bot", "entity_id": 1, "target_id": kpi_id,
"suggestion_data": {"kpi_id": kpi_id, "target_value": NEW_TARGET},
}, {"X-BOT-KEY": fin_key})
check("A1 Bot创建建议 200", st == 200, f"http={st} body={str(r)[:200]}")
sug_id = r.get("data", {}).get("id") if st == 200 else None
check("A2 status=unapplied", st == 200 and r["data"]["status"] == "unapplied",
f"id={sug_id} source={r.get('data', {}).get('source')}")
# ── 4. 人apply ──
st, r = http("POST", f"/api/cma/ai/suggestions/{sug_id}/apply",
{"action": "kpi_target", "target_value": NEW_TARGET}, auth)
check("B1 apply 200", st == 200, f"http={st} body={str(r)[:200]}")
ad = r.get("data", {}) if st == 200 else {}
detail = (ad.get("apply_detail") or [{}])[0]
check("B2 apply_detail before→after", detail.get("before") == BASELINE_TARGET
and detail.get("after") == NEW_TARGET, str(detail)[:160])
check("B3 applied_by/applied_at", bool(ad.get("applied_by")) and bool(ad.get("applied_at")),
f"by={ad.get('applied_by')} at={ad.get('applied_at')}")
db = next(get_db())
db.rollback()
kpi2 = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
db_target = getattr(kpi2, "target_value", None) if kpi2 else None
check("B4 KPI目标值生效", db_target == NEW_TARGET,
f"db_target={db_target}")
db.close()
# ── 5. 低危直写 /kpi-result ──
st, r = http("POST", "/api/cma/bot-bridge/kpi-result", {
"entity_id": 1, "kpi_code": KPI_CODE, "period": CUR_PERIOD,
"value": LOW_VALUE, "source": SOURCE, "remark": MARK,
}, {"X-BRIDGE-TOKEN": bridge_token})
check("C1 kpi-result 200", st == 200, f"http={st} body={str(r)[:200]}")
db = next(get_db())
db.rollback()
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.period == CUR_PERIOD,
).order_by(KPIValue.id.desc()).first()
check("C2 source_batch落库", val is not None and val.source_batch == SOURCE,
f"source_batch={val.source_batch if val else None} actual={val.actual_value if val else None}")
db.close()
# ── 6. audit留痕 ──
audit_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"logs", "bot_audit.log")
lines = []
if os.path.exists(audit_path):
with open(audit_path, encoding="utf-8") as f:
lines = f.readlines()
audit_str = "".join(lines[-60:])
check("D1 audit含建议创建记录",
"/api/cma/bot/ai/suggestions" in audit_str and '"status": 200' in audit_str,
f"sug_id={sug_id}")
check("D2 audit含kpi-result记录",
"/api/cma/bot-bridge/kpi-result" in audit_str and '"status": 200' in audit_str)
# 取最近一条(倒序)建议创建成功记录验证字段;早于重启的401是pytest进程写文件的噪音
rec = None
for l in reversed(lines[-60:]):
try:
r = json.loads(l)
except Exception:
continue
if r.get("endpoint") == "/api/cma/bot/ai/suggestions" and r.get("status") == 200:
rec = r
break
if rec:
check("D3 建议创建audit字段(bot_name/L2)", rec.get("bot_name") == "财务BOT"
and rec.get("risk_level") == "L2", str(rec)[:200])
# ── 7. 清理(标记精确删除) + 回查 ──
db = next(get_db())
db.rollback()
del_sug = db.query(AISuggestion).filter(
AISuggestion.title.like(f"{SUG_PREFIX}%"), AISuggestion.title.like("%上调临时KPI目标%"),
).delete(synchronize_session=False)
del_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.source_batch == SOURCE,
).delete(synchronize_session=False)
db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply",
OperationLog.target_id == kpi_id).delete(synchronize_session=False)
del_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).delete(synchronize_session=False)
db.commit()
db.rollback()
left_sug = db.query(AISuggestion).filter(
AISuggestion.title.like(f"{SUG_PREFIX}%"), AISuggestion.title.like("%上调临时KPI目标%"),
).count()
left_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.source_batch == SOURCE,
).count()
left_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).count()
check("E1 清理回查=0", left_sug == 0 and left_val == 0 and left_kpi == 0,
f"sug={left_sug} val={left_val} kpi={left_kpi}")
db.close()
print()
if failures:
print(f"E2E RESULT: FAIL ({len(failures)}): {failures}")
sys.exit(1)
print(f"E2E RESULT: PASS | 建议id={sug_id}(已清理) kpi_id={kpi_id}(已清理) 审计行保留于 logs/bot_audit.log")
if __name__ == "__main__":
main()