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,574 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""开放接口 E2E(《03-接口设计》§14)—— 供认定机构对接。
|
||||
|
||||
## 验收重点:鉴权是这一批接口的**全部价值所在**
|
||||
|
||||
开放接口把数据交给**外部系统**,所以每一个"能拿到数据"的前提都必须被证明是有门槛的:
|
||||
|
||||
① **签名**:`HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)`,
|
||||
且 body 是**原始 JSON**(字段顺序一变签名就不一样);
|
||||
② **时间窗**:偏差 > 5 分钟拒绝(挡"把昨天抓到的包今天重放");
|
||||
③ **nonce 防重放**:5 分钟内同一个 nonce 只能用一次
|
||||
(且必须是**原子占位** —— 先验签再占位的话,一个错误签名就能把 nonce 消耗掉);
|
||||
④ **不泄露明文证件号**(契约 §14.2 明确要求)——
|
||||
这条要**直接查响应里有没有 18 位数字串**,而不是看代码"应该是脱敏的";
|
||||
⑤ **scope 校验**:只有 `submission:read` 的接入方拿不到文件链接;
|
||||
⑥ **归属校验**:拿 A 任务的路径去拉 B 任务的填报单 → 403
|
||||
(不校验的话,任何一家机构能拉走全部租户的数据)。
|
||||
|
||||
## 还有一件"契约没写但必须有"的事
|
||||
|
||||
§14 定义了"机构怎么调我们",却没定义 **appKey / appSecret 从哪来** ——
|
||||
没有管理接口,§14 的四个接口在真实流程里**跑不起来**(只能靠改库,
|
||||
而 appSecret 是密文列)。所以本脚本先建接入方,再验四个接口(见 F-67)。
|
||||
|
||||
跑法:python scripts/e2e-m8-open.py
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
CREATED_TASK_IDS = []
|
||||
CREATED_APP_IDS = []
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-open-roster.xlsx")
|
||||
|
||||
|
||||
def _load(name, fn):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
|
||||
A = _load("e2e-m5-audit", "e2e-m5-audit.py")
|
||||
BASE = "http://localhost:8080"
|
||||
|
||||
STU = 996201
|
||||
TEACHER = 996202
|
||||
APP_KEY = "e2e_jxjd_open"
|
||||
APP_KEY_NOFILE = "e2e_only_submission"
|
||||
|
||||
|
||||
def ok(name):
|
||||
PASSED.append(name)
|
||||
print(" [OK] " + name)
|
||||
|
||||
|
||||
def fail(name, detail):
|
||||
FAILED.append((name, detail))
|
||||
print(" [FAIL] " + name + "\n " + detail)
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
if cond:
|
||||
ok(name)
|
||||
else:
|
||||
fail(name, detail or "断言不成立")
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def sql(stmt):
|
||||
_, out = R.sql(stmt)
|
||||
return out
|
||||
|
||||
|
||||
def body_of(method, path, token=None, payload=None):
|
||||
_, b = R.call(method, BASE + path, token, payload)
|
||||
return b
|
||||
|
||||
|
||||
def mp(uid):
|
||||
cfg = R.read_dev_config()
|
||||
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
|
||||
|
||||
|
||||
def open_call(method, path, secret=None, app_key=None, body=None, headers=None,
|
||||
token=None, skew_seconds=0, nonce=None, sign_override=None):
|
||||
"""按契约发一个开放接口请求。
|
||||
|
||||
body 传**字符串**(原文)—— 签名覆盖的就是它,所以这里刻意不先序列化。
|
||||
"""
|
||||
url = BASE + path
|
||||
raw = body if isinstance(body, str) else (json.dumps(body, ensure_ascii=False)
|
||||
if body is not None else "")
|
||||
data = raw.encode("utf-8") if raw else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
hdrs = dict(headers or {})
|
||||
if token:
|
||||
hdrs["Authorization"] = "Bearer " + token
|
||||
else:
|
||||
ts = str(int(time.time()) + skew_seconds)
|
||||
nc = nonce or uuid.uuid4().hex[:8]
|
||||
hdrs.setdefault("X-App-Key", app_key or APP_KEY)
|
||||
hdrs.setdefault("X-Timestamp", ts)
|
||||
hdrs.setdefault("X-Nonce", nc)
|
||||
if secret is not None:
|
||||
want = sign_override if sign_override is not None else hmac_sign(
|
||||
secret, (app_key or APP_KEY) + ts + nc + raw)
|
||||
hdrs.setdefault("X-Sign", want)
|
||||
for k, v in hdrs.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
txt = resp.read().decode("utf-8")
|
||||
try:
|
||||
return resp.status, json.loads(txt)
|
||||
except Exception:
|
||||
return resp.status, {"_raw": txt[:200]}
|
||||
except urllib.error.HTTPError as e:
|
||||
txt = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return e.code, json.loads(txt)
|
||||
except Exception:
|
||||
return e.code, {"_raw": txt[:200]}
|
||||
except Exception as e:
|
||||
return -1, {"_error": str(e)[:200]}
|
||||
|
||||
|
||||
def hmac_sign(secret, data):
|
||||
return hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def contains_plain_idcard(obj, ids):
|
||||
"""递归找响应里有没有**明文证件号**(脱敏值里有 *,所以要找完整的 18 位)。"""
|
||||
s = json.dumps(obj, ensure_ascii=False)
|
||||
for i in ids:
|
||||
if i in s:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="开放接口 E2E")
|
||||
ap.parse_args()
|
||||
|
||||
print("=" * 74)
|
||||
print(" 开放接口 E2E(§14)—— 供认定机构对接")
|
||||
print("=" * 74)
|
||||
|
||||
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
|
||||
if adm.get("code") != 0:
|
||||
print(" x 管理员登录失败:" + str(adm))
|
||||
return 2
|
||||
at = adm["data"]["token"]
|
||||
|
||||
print("\n[0] 摆夹具(任务 + 名册 + 一份已终审通过的单子)")
|
||||
suffix = os.urandom(3).hex()
|
||||
idcard = R.with_checksum("36102920080707081")
|
||||
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||
"taskName": "E2E开放 " + suffix, "batchNo": "202609", "majorName": "数控加工",
|
||||
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
print(" x 建任务失败:" + str(b))
|
||||
return 2
|
||||
task_id = b["data"]["id"]
|
||||
CREATED_TASK_IDS.append(task_id)
|
||||
|
||||
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
|
||||
classes = []
|
||||
|
||||
def walk(nodes):
|
||||
for n in nodes or []:
|
||||
if n.get("orgType") == 3:
|
||||
classes.append(n)
|
||||
walk(n.get("children"))
|
||||
|
||||
walk(b.get("data") or [])
|
||||
org = classes[0]
|
||||
teacher_tok = ensure_teacher(TEACHER, "openT" + suffix, "开放班主任", org["id"])
|
||||
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at,
|
||||
{"orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER}]})
|
||||
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
|
||||
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||
_build_roster([("开放甲", idcard)])
|
||||
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
|
||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||
check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180])
|
||||
R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True})
|
||||
|
||||
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
|
||||
'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU)
|
||||
tok = mp(STU)
|
||||
_, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
|
||||
{"taskId": task_id, "realName": "开放甲", "idCardNo": idcard})
|
||||
if b.get("code") != 0:
|
||||
print(" x 门禁核验失败:" + json.dumps(b, ensure_ascii=False)[:200])
|
||||
return 2
|
||||
_, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id})
|
||||
sid = b["data"]["id"]
|
||||
res = A.prepare_student(tok, sid, task_id, {
|
||||
"work_unit": "无", "is_retake": False, "is_student": True,
|
||||
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01",
|
||||
"occupation": "车工", "worktype": "数控车工", "job_level": "四级"
|
||||
}, dup_first_file=False)
|
||||
check("夹具:学生已提交", ((res.get("data") or {}).get("submitted")) is True,
|
||||
json.dumps(res, ensure_ascii=False)[:200])
|
||||
# ⚠️ **审核放在"建接入方"之后**:推送只在"终审通过那一刻已存在的接入方"上触发,
|
||||
# 而真实流程也是"机构先接入、之后审核通过才推送"。
|
||||
# 我第一版把审核放在最前面,于是推送记录永远是 0 条 ——
|
||||
# 而那个现象看起来像"推送功能没做"。
|
||||
|
||||
# ── 1. 建接入方(契约没写,但没有它 §14 跑不起来,见 F-67)──
|
||||
print("\n[1] 接入方管理(契约漏了这一层,见 F-67)")
|
||||
made = body_of("POST", "/api/v1/open-apps", at, {
|
||||
"appName": "江西机电认定中心 " + suffix, "appKey": APP_KEY,
|
||||
"callbackUrl": "http://127.0.0.1:9/callback-not-listening",
|
||||
"pushSecret": "push-secret-" + suffix
|
||||
})
|
||||
check("能新建接入方", made.get("code") == 0, json.dumps(made, ensure_ascii=False)[:250])
|
||||
app = made.get("data") or {}
|
||||
if app.get("id"):
|
||||
CREATED_APP_IDS.append(app["id"])
|
||||
secret = app.get("appSecret")
|
||||
check("⭐ 返回 appSecret(**只这一次**)", isinstance(secret, str) and len(secret) >= 20,
|
||||
"长度=%s" % (len(secret) if secret else None))
|
||||
check("列表**不回** appSecret", all(
|
||||
x.get("appSecret") is None
|
||||
for x in (body_of("GET", "/api/v1/open-apps", at).get("data") or [])),
|
||||
"见列表响应")
|
||||
check("⭐ 库里存的是密文(HMAC 要能还原,所以不能只存哈希)",
|
||||
sql("select app_secret_cipher from collect_open_app where app_key='%s';" % APP_KEY)
|
||||
.strip() != secret,
|
||||
"密文前 20 位=" + sql("select app_secret_cipher from collect_open_app "
|
||||
"where app_key='%s';" % APP_KEY).strip()[:20])
|
||||
dup = body_of("POST", "/api/v1/open-apps", at, {"appName": "重复", "appKey": APP_KEY})
|
||||
check("appKey 重复 → 明确报错", dup.get("code") != 0,
|
||||
"code=%s msg=%s" % (dup.get("code"), dup.get("msg")))
|
||||
|
||||
# 第二个接入方:只有 submission:read(用来验 scope)
|
||||
made2 = body_of("POST", "/api/v1/open-apps", at, {
|
||||
"appName": "只有填报单权限 " + suffix, "appKey": APP_KEY_NOFILE,
|
||||
"scopes": "submission:read"
|
||||
})
|
||||
app2 = made2.get("data") or {}
|
||||
if app2.get("id"):
|
||||
CREATED_APP_IDS.append(app2["id"])
|
||||
secret2 = app2.get("appSecret")
|
||||
check("能建只带 submission:read 的接入方", made2.get("code") == 0,
|
||||
json.dumps(made2, ensure_ascii=False)[:200])
|
||||
|
||||
# ── 1b. 让这一份**在接入方已存在之后**通过终审(推送就此触发)──
|
||||
print("\n[1b] 终审通过(此时接入方已存在,推送应被触发)")
|
||||
a1 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, teacher_tok,
|
||||
{"remark": "开放接口夹具:初审", "actionSource": "BUTTON"})
|
||||
a2 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, at,
|
||||
{"finalOpinion": "开放接口夹具:终审", "actionSource": "BUTTON"})
|
||||
check("终审通过", a2.get("code") == 0 and (a2.get("data") or {}).get("status") == 3,
|
||||
json.dumps(a2, ensure_ascii=False)[:200])
|
||||
# 推送是 afterCommit 回调 + 外部 HTTP,给它一点时间(回调地址故意不通,会失败并安排重试)
|
||||
time.sleep(3)
|
||||
|
||||
# ── 2. 签名校验 ──
|
||||
print("\n[2] 签名校验(这一批接口的价值全在这里)")
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
|
||||
check("正确签名 → 通过", r.get("code") == 0,
|
||||
"http=%s %s" % (st, json.dumps(r, ensure_ascii=False)[:200]))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
|
||||
secret="wrong-secret")
|
||||
check("⭐ 密钥不对 → 40101", r.get("code") == 40101,
|
||||
"http=%s code=%s msg=%s" % (st, r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
|
||||
app_key="no-such-app", secret=secret)
|
||||
check("⭐ 不存在的 appKey → 40101(且不透露它是否存在)",
|
||||
r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
skew_seconds=-400)
|
||||
check("⭐ 时间戳偏差 > 5 分钟 → 拒绝(挡「昨天抓的包今天重放」)",
|
||||
r.get("code") == 40101 and "分钟" in (r.get("msg") or ""),
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
skew_seconds=400)
|
||||
check("时间戳快 6 分钟也拒(比的是**偏差绝对值**,不是「不能太旧」)",
|
||||
r.get("code") == 40101, "code=%s" % r.get("code"))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
headers={"X-Sign": "0" * 64})
|
||||
check("签名格式对但值不对 → 拒绝", r.get("code") == 40101,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None,
|
||||
headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""})
|
||||
check("缺头 → 能照着做的错误消息(提示两种鉴权方式)",
|
||||
r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
|
||||
# nonce 防重放
|
||||
fixed_nonce = uuid.uuid4().hex[:8]
|
||||
st, r1 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
nonce=fixed_nonce)
|
||||
check("第一次用这个 nonce → 通过", r1.get("code") == 0, json.dumps(r1, ensure_ascii=False)[:150])
|
||||
st, r2 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
nonce=fixed_nonce)
|
||||
check("⭐ 同一个 nonce 第二次 → 拒绝(防重放)",
|
||||
r2.get("code") == 40101 and "nonce" in (r2.get("msg") or ""),
|
||||
"code=%s msg=%s" % (r2.get("code"), r2.get("msg")))
|
||||
# 错误签名**不该**消耗 nonce(否则能被用来拒绝服务)
|
||||
bad_nonce = uuid.uuid4().hex[:8]
|
||||
open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret="wrong",
|
||||
nonce=bad_nonce)
|
||||
st, r3 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret,
|
||||
nonce=bad_nonce)
|
||||
check("⭐ 错误签名不该消耗 nonce(否则能被用来拒绝服务)", r3.get("code") == 0,
|
||||
"code=%s msg=%s" % (r3.get("code"), r3.get("msg")))
|
||||
|
||||
# ── 3. §14.1 令牌 ──
|
||||
print("\n[3] §14.1 换取访问令牌")
|
||||
tk = open_call("POST", "/api/v1/open/v1/auth/token", body={"appKey": APP_KEY,
|
||||
"appSecret": secret})
|
||||
check("换令牌成功", (tk[1] or {}).get("code") == 0,
|
||||
json.dumps(tk[1], ensure_ascii=False)[:250])
|
||||
tokv = (tk[1] or {}).get("data") or {}
|
||||
access = tokv.get("accessToken")
|
||||
check("返回 accessToken", isinstance(access, str) and len(access) > 20, str(access)[:20])
|
||||
check("⭐ expireSeconds = 7200(契约 §14.1)", tokv.get("expireSeconds") == 7200,
|
||||
str(tokv.get("expireSeconds")))
|
||||
check("返回 scope 列表", isinstance(tokv.get("scope"), list) and len(tokv.get("scope")) >= 1,
|
||||
json.dumps(tokv.get("scope"), ensure_ascii=False))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
|
||||
check("⭐ 令牌能直接调数据接口(不用再算 HMAC)", r.get("code") == 0,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id,
|
||||
token="not-a-real-token")
|
||||
check("假令牌 → 40101", r.get("code") == 40101, "code=%s" % r.get("code"))
|
||||
st, r = open_call("POST", "/api/v1/open/v1/auth/token",
|
||||
body={"appKey": APP_KEY, "appSecret": "wrong"})
|
||||
check("appSecret 不对 → 40101(不是把令牌发出去)", r.get("code") == 40101,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
|
||||
# ── 4. §14.2 填报单 ──
|
||||
print("\n[4] §14.2 拉取填报单")
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
|
||||
data = r.get("data") or {}
|
||||
check("拉到数据", (data.get("total") or 0) >= 1, json.dumps(data, ensure_ascii=False)[:250])
|
||||
row = (data.get("list") or [{}])[0]
|
||||
check("带姓名与脱敏证件号", row.get("realName") == "开放甲" and row.get("idCardMask"),
|
||||
json.dumps(row, ensure_ascii=False)[:250])
|
||||
check("⭐ **响应里没有明文证件号**(契约 §14.2 的硬要求)",
|
||||
contains_plain_idcard(r, [idcard]) is None,
|
||||
"找到了明文:" + str(contains_plain_idcard(r, [idcard])))
|
||||
check("默认只返回已通过的(status=3)",
|
||||
all(x.get("status") == 3 for x in data.get("list") or []),
|
||||
json.dumps([x.get("status") for x in data.get("list") or []], ensure_ascii=False))
|
||||
check("带职业/工种/等级", row.get("occupation") == "车工",
|
||||
json.dumps(row, ensure_ascii=False)[:200])
|
||||
check("带 taskCode / taskName", bool(data.get("taskCode")) and bool(data.get("taskName")),
|
||||
"%s / %s" % (data.get("taskCode"), data.get("taskName")))
|
||||
# since 增量
|
||||
future = "2099-01-01T00:00:00"
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%s" % (task_id, future),
|
||||
secret=secret)
|
||||
check("⭐ since 之后的(未来时间)→ 0 条(增量拉取生效)",
|
||||
((r.get("data") or {}).get("total") or 0) == 0,
|
||||
json.dumps(r.get("data"), ensure_ascii=False)[:200])
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%d" % (task_id, 0),
|
||||
secret=secret)
|
||||
check("毫秒时间戳形式的 since 也认(调用方语言不同,两种都给)",
|
||||
(r.get("code") == 0 and ((r.get("data") or {}).get("total") or 0) >= 1),
|
||||
json.dumps(r.get("data"), ensure_ascii=False)[:200])
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=不是时间" % task_id,
|
||||
secret=secret)
|
||||
check("⭐ since 格式不对 → 明确报错(不静默当成「不筛」,那会把全量推给对方)",
|
||||
r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/submissions", secret=secret)
|
||||
check("不存在的任务 → 明确报错", r.get("code") != 0, "code=%s" % r.get("code"))
|
||||
|
||||
# ── 5. §14.3 文件 ──
|
||||
print("\n[5] §14.3 拉取材料文件")
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid),
|
||||
secret=secret)
|
||||
fdata = r.get("data") or {}
|
||||
check("拉到文件", len(fdata.get("files") or []) >= 1,
|
||||
json.dumps(fdata, ensure_ascii=False)[:250])
|
||||
f0 = (fdata.get("files") or [{}])[0]
|
||||
check("带 materialCode / standardName / url / expireSeconds",
|
||||
f0.get("materialCode") and f0.get("standardName") and f0.get("url")
|
||||
and f0.get("expireSeconds") == 1800,
|
||||
json.dumps(f0, ensure_ascii=False)[:300])
|
||||
check("⭐ 文件里也没有明文证件号", contains_plain_idcard(r, [idcard]) is None,
|
||||
"找到了明文")
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files" % task_id, secret=secret)
|
||||
check("不传 submissionIds → 明确报错", r.get("code") != 0,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
ids201 = ",".join(str(i) for i in range(1, 202))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%s" % (task_id, ids201),
|
||||
secret=secret)
|
||||
check("⭐ 超过 200 份 → 40011(契约 §14.3 的上限)", r.get("code") == 40011,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=abc" % task_id,
|
||||
secret=secret)
|
||||
check("submissionIds 里不是数字 → 明确报错", r.get("code") != 0,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
# 归属校验:用别的任务的填报单 id 去拉
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/files?submissionIds=%d" % sid,
|
||||
secret=secret)
|
||||
check("⭐ 用别的任务路径拉这份文件 → 被拦住(不校验就能拉走全部数据)",
|
||||
r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
|
||||
# ── 6. scope ──
|
||||
print("\n[6] scope 权限")
|
||||
# ⚠️ 必须带**它自己的** appKey:不带的话 open_call 会用第一个接入方的 key,
|
||||
# 于是签名对不上 —— 表现是"签名不通过",很容易被误判成实现有 bug
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret2,
|
||||
app_key=APP_KEY_NOFILE)
|
||||
check("只有 submission:read 的接入方能拉填报单", r.get("code") == 0,
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid),
|
||||
secret=secret2, app_key=APP_KEY_NOFILE)
|
||||
check("⭐ 同一个接入方拉文件 → 40301(没有 file:read)",
|
||||
r.get("code") == 40301 and "file:read" in (r.get("msg") or ""),
|
||||
"code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
|
||||
# ── 7. 停用立刻生效 ──
|
||||
print("\n[7] 停用立刻生效(不用等令牌过期)")
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
|
||||
check("停用前令牌可用", r.get("code") == 0, "code=%s" % r.get("code"))
|
||||
tg = body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at)
|
||||
check("停用接入方", tg.get("code") == 0 and (tg.get("data") or {}).get("enabled") is False,
|
||||
json.dumps(tg, ensure_ascii=False)[:200])
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access)
|
||||
check("⭐ 停用后**未过期的令牌也立刻失效**(令牌校验每次回查接入方)",
|
||||
r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
|
||||
check("停用后 HMAC 也不通", r.get("code") == 40101, "code=%s" % r.get("code"))
|
||||
body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at)
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret)
|
||||
check("重新启用后恢复", r.get("code") == 0, "code=%s" % r.get("code"))
|
||||
|
||||
# ── 8. §14.4 推送与重试 ──
|
||||
print("\n[8] §14.4 推送(含重试与人工重推)")
|
||||
pl = body_of("GET", "/api/v1/open-apps/push-logs", at).get("data") or []
|
||||
check("⭐ 终审通过时**自动**建了推送记录(审批链路真的接上了)",
|
||||
len(pl) >= 1, "共 %d 条:%s" % (len(pl), json.dumps(pl, ensure_ascii=False)[:250]))
|
||||
if pl:
|
||||
mine = [x for x in pl if x.get("submissionId") == sid]
|
||||
check("有这份填报单的推送记录", len(mine) >= 1,
|
||||
json.dumps(pl, ensure_ascii=False)[:300])
|
||||
if mine:
|
||||
rec = mine[0]
|
||||
check("⭐ 回调地址不通 → 记了失败原因与 HTTP 结果(不是安静地什么都不做)",
|
||||
rec.get("attempts") and rec.get("attempts") >= 1
|
||||
and (rec.get("lastError") or rec.get("lastHttpCode")),
|
||||
json.dumps(rec, ensure_ascii=False)[:300])
|
||||
check("⭐ 失败后安排了下次重试(契约 §14.4 的 1/5/30/120/600 秒)",
|
||||
rec.get("status") == 2 and rec.get("nextRetryAt"),
|
||||
json.dumps(rec, ensure_ascii=False)[:250])
|
||||
rp = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret,
|
||||
body={"submissionId": sid})
|
||||
check("⭐ 人工重推可用(契约 §14.4 的「支持人工重推」)",
|
||||
(rp[1] or {}).get("code") == 0, json.dumps(rp[1], ensure_ascii=False)[:250])
|
||||
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
|
||||
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
|
||||
json.dumps(rp2[1], ensure_ascii=False)[:200])
|
||||
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
|
||||
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
|
||||
"where submission_id=%d limit 1;" % sid) or "")
|
||||
or idcard not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
|
||||
"where submission_id=%d limit 1;" % sid) or ""),
|
||||
"payload=" + (sql("select coalesce(payload_json,'') from collect_open_push_log "
|
||||
"where submission_id=%d limit 1;" % sid) or "")[:200])
|
||||
|
||||
# ── 9. 权限:学生/未登录不能碰管理接口 ──
|
||||
print("\n[9] 管理接口的权限")
|
||||
b = body_of("GET", "/api/v1/open-apps", mp(996299))
|
||||
check("学生看接入方列表 → 403", b.get("code") in (40101, 40301, 403),
|
||||
"code=%s" % b.get("code"))
|
||||
b = body_of("GET", "/api/v1/open-apps")
|
||||
check("未登录看接入方列表 → 40101", b.get("code") == 40101, "code=%s" % b.get("code"))
|
||||
# 开放接口本身是匿名的(@PublicApi),但没有凭据必须拒绝
|
||||
st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None,
|
||||
headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""})
|
||||
check("⭐ 开放接口是匿名的,但**没有凭据一律拒绝**",
|
||||
r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg")))
|
||||
|
||||
cleanup()
|
||||
print("\n" + "=" * 74)
|
||||
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
|
||||
print("=" * 74)
|
||||
for n, det in FAILED:
|
||||
print(" x " + n + "\n " + det)
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
def ensure_teacher(uid, username, name, org_id):
|
||||
sql("""
|
||||
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
|
||||
VALUES (%d, 1, '%s', '%s',
|
||||
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', %d, 3, 1)
|
||||
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name,
|
||||
org_id = EXCLUDED.org_id,
|
||||
username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
user_type = EXCLUDED.user_type,
|
||||
status = 1
|
||||
""" % (uid, username, name, org_id))
|
||||
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
|
||||
"and deleted=0 limit 1;").strip()
|
||||
sql("delete from sys_user_role where user_id=%d;" % uid)
|
||||
sql("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (%d, %s, 1);"
|
||||
% (uid, role_id))
|
||||
_, b = R.login(BASE, username, "Admin@123", "demo")
|
||||
if b.get("code") != 0:
|
||||
raise SystemExit(" 班主任 %s 登录失败:%s" % (username, b))
|
||||
return b["data"]["token"]
|
||||
|
||||
|
||||
def _build_roster(rows):
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "开放班"
|
||||
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
|
||||
"报考职业", "报考工种", "报考等级", "是否补考",
|
||||
"补考理论", "补考实操", "补考综合", "是否在校生",
|
||||
"在读学校", "所学专业", "入学时间"]
|
||||
for c, t in enumerate(header, start=1):
|
||||
ws.cell(row=1, column=c, value=t)
|
||||
for i, (name, idc) in enumerate(rows):
|
||||
vals = [i + 1, name, idc, "中专", "1380013770%d" % i, "无",
|
||||
"车工", "数控车工", "四级", "否", "", "", "", "是",
|
||||
"示范职业学校", "数控加工", "2024-09-01"]
|
||||
for c, v in enumerate(vals, start=1):
|
||||
if v != "":
|
||||
ws.cell(row=i + 2, column=c, value=v)
|
||||
wb.save(TMP_ROSTER)
|
||||
|
||||
|
||||
def cleanup():
|
||||
for tid in CREATED_TASK_IDS:
|
||||
R.sql("delete from collect_open_push_log where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_audit_record where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_notify_record where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_file where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_submission where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_roster where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_task_org where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_material_def where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_task where id=%d;" % tid)
|
||||
R.sql("delete from collect_open_push_log where app_id in "
|
||||
"(select id from collect_open_app where app_key like 'e2e_%');")
|
||||
R.sql("delete from collect_open_app where app_key like 'e2e_%';")
|
||||
R.sql("delete from collect_message where user_id in (996201, 996299);")
|
||||
R.sql("delete from collect_notify_record where receiver_id in (996201, 996299);")
|
||||
R.sql("delete from sys_user_role where user_id = %d;" % TEACHER)
|
||||
R.sql("delete from sys_user where id = %d;" % TEACHER)
|
||||
if os.path.exists(TMP_ROSTER):
|
||||
os.remove(TMP_ROSTER)
|
||||
print(" 已清理:任务 / 接入方 / 推送记录 / 测试账号 / 临时 Excel")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user