- 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
244 lines
11 KiB
Python
244 lines
11 KiB
Python
#!/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()
|