Files
lovife 30c3714d35 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。
2026-09-30 09:02:57 +08:00

194 lines
7.5 KiB
Python
Raw Permalink 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.
# -*- 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)