test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、 check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、 check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、 e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、 dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。 压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个 WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。 度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」 (submitted / created / canExport),而不是 HTTP 200 + code 0。 验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证 ProdGuard 真的会拒绝启动(用完即删)。
|
||||
|
||||
## 为什么这条验证不能省
|
||||
|
||||
`ProdGuard` 的价值**完全**在于"配错时起不来"。如果它其实不生效
|
||||
(比如 profile 判断写错、或者异常被 Spring 吞掉后照样启动),
|
||||
那它就只是一段看起来很尽责的注释 —— 而**没有任何别的方式能发现这件事**:
|
||||
配错的生产环境会照常启动、照常服务,直到某天出事。
|
||||
|
||||
所以要真的用 prod profile 起一次,看它是否 ① 拒绝启动 ② 报的是那几条。
|
||||
|
||||
## 两种情形都试
|
||||
|
||||
1. **什么都没配** → 应该拒绝,且 6 条问题里至少报出 5 条;
|
||||
2. **只修一半**(把 stub 关了、但密钥还是 dev 那把)→ 仍应拒绝,
|
||||
且报错里要点名"用的还是开发环境那把"。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
JAR = os.path.join(ROOT, "backend", "xt-server", "target",
|
||||
"xt-server-1.0.0-SNAPSHOT.jar")
|
||||
|
||||
if not os.path.exists(JAR):
|
||||
raise SystemExit("先 mvn package:" + JAR)
|
||||
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
|
||||
|
||||
|
||||
def kill_tree(proc):
|
||||
"""
|
||||
杀掉整个进程树(Windows 用 taskkill /T)。
|
||||
|
||||
⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程;
|
||||
更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**,
|
||||
所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净,
|
||||
再靠下面的 preclean_port 兜住剩下的。
|
||||
"""
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=20)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def preclean_port(port):
|
||||
"""
|
||||
开跑前清掉这个端口上的残留 java。
|
||||
|
||||
⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** ——
|
||||
残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。
|
||||
不清端口 = 残留永远不会被发现。
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)"
|
||||
".OwningProcess" % port],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
for line in (r.stdout or "").split():
|
||||
pid = line.strip()
|
||||
if pid.isdigit():
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
|
||||
capture_output=True, timeout=20)
|
||||
print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ok(n):
|
||||
PASSED.append(n)
|
||||
print(" [OK] " + n)
|
||||
|
||||
|
||||
def fail(n, d):
|
||||
FAILED.append((n, d))
|
||||
print(" [FAIL] " + n + "\n " + d)
|
||||
|
||||
|
||||
def check(n, cond, d=""):
|
||||
ok(n) if cond else fail(n, d)
|
||||
|
||||
|
||||
def start_prod(extra_env, seconds=45, extra_args=None):
|
||||
"""用 prod profile 起一次,抓输出后杀掉。返回 (是否起来了, 输出)。"""
|
||||
env = dict(os.environ)
|
||||
env.update(extra_env)
|
||||
# 让它在真正的"缺密钥"状态下启动:清掉可能存在的 dev 环境变量
|
||||
for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY", "XT_RSA_PRIVATE_KEY",
|
||||
"PG_PASSWORD", "REDIS_PASSWORD"):
|
||||
env.pop(k, None)
|
||||
env.update(extra_env)
|
||||
preclean_port(18099)
|
||||
p = subprocess.Popen(
|
||||
["java", "-jar", JAR, "--spring.profiles.active=prod",
|
||||
"--server.port=18099"] + (extra_args or []),
|
||||
cwd=os.path.join(ROOT, "backend"), env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
out = []
|
||||
started = False
|
||||
deadline = time.time() + seconds
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
line = p.stdout.readline()
|
||||
if not line:
|
||||
if p.poll() is not None:
|
||||
break
|
||||
continue
|
||||
out.append(line)
|
||||
if "Started XtServerApplication" in line or "Tomcat started" in line:
|
||||
started = True
|
||||
break
|
||||
if p.poll() is not None:
|
||||
break
|
||||
finally:
|
||||
kill_tree(p)
|
||||
try:
|
||||
rest = p.stdout.read() or ""
|
||||
out.append(rest)
|
||||
except Exception:
|
||||
pass
|
||||
return started, "".join(out)
|
||||
|
||||
|
||||
print("=" * 74)
|
||||
print(" ProdGuard:生产环境配错时应当拒绝启动")
|
||||
print("=" * 74)
|
||||
|
||||
print("\n[1] 什么都没配(缺全部密钥)")
|
||||
started, out = start_prod({})
|
||||
check("⭐ **拒绝启动**(真的没起来)", not started,
|
||||
"它居然起来了 —— 那 ProdGuard 等于没有")
|
||||
# 场景 1 里 prod 配置把 stub 写成 false、存储写成 qiniu,
|
||||
# 所以这两条**不该**出现在清单里(它们由场景 3 显式覆盖来验)
|
||||
check("⭐ 六个必需变量都点名了",
|
||||
all(k in out for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY",
|
||||
"XT_RSA_PRIVATE_KEY", "PG_PASSWORD", "REDIS_PASSWORD")),
|
||||
out[:1500])
|
||||
check("⭐ **不再**出现「占位符被当成密钥长度」那种自造假问题"
|
||||
"(`${XT_JWT_SECRET}` 正好 16 字符,不排除就会多报一条「太短」)",
|
||||
"太短" not in out, out[:1500])
|
||||
check("⭐ 报了「配错了也能启动、运行时完全不报错」这句话(让人知道为什么要拦)",
|
||||
"配错了也能启动" in out, out[-400:])
|
||||
|
||||
print("\n[2] 只修一半:密钥仍是 dev 那把")
|
||||
dev_secret = "BC0LUCKD3T4FfgMioHyARklQBa5phAXrYmzJStAwKiqftTJGURd4UblkjXFF+m8ZtaIC6BSeXKN2wuqBlkHPbA=="
|
||||
started2, out2 = start_prod({
|
||||
"XT_JWT_SECRET": dev_secret,
|
||||
"XT_AES_KEY": "CaJuBbvbSKkLGn+d3WfWcmbkZE48NHglssoW53fKbrY=",
|
||||
"XT_HMAC_KEY": "1x75SulC+e+dQYZMEJMoVLf+E6KmgE0lUv6ShfJ6FDzNJdtJpmaxacPmX+/qCzhS",
|
||||
"XT_RSA_PRIVATE_KEY": "x",
|
||||
"PG_PASSWORD": "x", "REDIS_PASSWORD": "x",
|
||||
})
|
||||
check("⭐ 仍**拒绝启动**(不是「有值就行」,而是「不能是 dev 那把」)", not started2,
|
||||
"它起来了 —— 那说明只检查了「非空」")
|
||||
check("⭐ 报错写明「用的还是开发环境那把」并说清后果(可伪造任意 token)",
|
||||
"开发环境那把" in out2 and "伪造" in out2, out2[-600:])
|
||||
|
||||
print("\n[3] 有人为了排障把后门打开、然后忘了关")
|
||||
started3, out3 = start_prod(
|
||||
{"XT_JWT_SECRET": "a" * 80, "XT_AES_KEY": "b" * 44, "XT_HMAC_KEY": "c" * 44,
|
||||
"XT_RSA_PRIVATE_KEY": "d" * 100, "PG_PASSWORD": "x", "REDIS_PASSWORD": "y"},
|
||||
extra_args=["--fill.mp-h5.stub-enabled=true",
|
||||
"--fill.storage.provider=local",
|
||||
"--springdoc.api-docs.enabled=true"])
|
||||
check("⭐ 拒绝启动(密钥齐全也不行 —— 后门/本地存储/接口文档开着就不许上)",
|
||||
not started3, "它起来了")
|
||||
check("⭐ 三条都点名了,并说清了后果",
|
||||
"stub-enabled" in out3 and "storage.provider" in out3 and "api-docs" in out3,
|
||||
out3[:1500])
|
||||
check("⭐ 文案里说清 stub 的后果是「任何人可以登录任何学生」",
|
||||
"任何人可以登录任何学生" in out3, out3[:1500])
|
||||
|
||||
print("\n" + "=" * 74)
|
||||
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
|
||||
print("=" * 74)
|
||||
for n, d in FAILED:
|
||||
print(" x " + n + "\n " + d)
|
||||
sys.stdout.flush()
|
||||
raise SystemExit(1 if FAILED else 0)
|
||||
Reference in New Issue
Block a user