tradingSystem/scripts/test_batch18_units.py

201 lines
8.5 KiB
Python
Raw Normal View History

2026-08-26 16:51:11 +08:00
# -*- coding: utf-8 -*-
"""
第十八批: 登录与权限 (交易系统管理员 / 交易员) 的纯逻辑 不联网不连库
=====================================================================
本批只测 app/web/auth.py 里的纯函数, 不导入 app.web.main (那会拉起 fastapi 与一堆
基础设施依赖)覆盖四件事:
1. 角色判定 role_from_permissions: 管理员标记命中/未中交易员准入闸放行与拦截;
2. 会话票 make_session / verify_session: 签发-校验往返, 篡改/过期/换密钥/空密钥一律拒;
3. bshop 返回解析 parse_bshop_login: 喂真实样例抽出手机号/用户名/权限/组织, code!=200 判失败;
4. 接口鉴权 authz_decision + is_public_path: 写操作默认拒绝交易员白名单外的写接口
一律要管理员 (专门钉死 /api/macro/adoptPOST /api/proposals.../archive .../cancel
结尾段区分), GET 默认放行仅少数运维读接口要管理员; 公开白名单放行首页与登录接口
运行: python scripts/test_batch18_units.py
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.web import auth as A # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# 与用户给的 bshop 登录成功返回同构的样例 (裁剪了权限条数, 字段与层级一致)
SAMPLE = {
"code": 200, "msg": "登录成功",
"data": {
"access_token": "SMxzzyfoIJogItsmiR9Qv0NdnFAoKE-F5WdJPDhzZOA",
"token_type": "bearer", "expires_in": 604800,
"user": {"phone": "18795373439", "username": "18795373439", "wechat_bound": True},
"permissions": [
{"system_key": "band", "menu_key": "-", "system_name": "波段系统后端", "menu_name": "波段系统后端"},
{"system_key": "band", "menu_key": "bkld", "system_name": "波段系统后端", "menu_name": "板块轮动"},
{"system_key": "band", "menu_key": "yhyqx", "system_name": "波段系统后端", "menu_name": "用户与权限"},
{"system_key": "band_web", "menu_key": "traded", "system_name": "波段系统网页端", "menu_name": "波段监控"},
],
"orgs": [{"id": 277097889622, "org_name": "高姐"},
{"id": 489281497140, "org_name": "天盟资本"}],
},
}
# ---------------------------------------------------------------- 一、角色判定
@case("角色: 命中管理员标记 -> 交易系统管理员")
def _role_admin():
r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
assert r == A.ROLE_ADMIN, r
@case("角色: 只有普通权限 -> 交易员")
def _role_trader():
r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "pms:admin", "")
assert r == A.ROLE_TRADER, r
@case("角色: 配了交易员准入且占其一 -> 交易员")
def _role_trader_gate_pass():
r = A.role_from_permissions(["band:ccgl"], "pms:admin", "band:ccgl,pms:-")
assert r == A.ROLE_TRADER, r
@case("角色: 配了交易员准入却一条不占 -> None (无权进入)")
def _role_denied():
r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-")
assert r is None, r
@case("角色: 管理员标记可多条, 命中任一即算")
def _role_admin_multi():
r = A.role_from_permissions(["band:yhyqx"], "pms:admin, band:yhyqx", "")
assert r == A.ROLE_ADMIN, r
# ---------------------------------------------------------------- 二、会话票
@case("会话: 签发-校验往返, payload 字段齐全")
def _sess_roundtrip():
tok = A.make_session("18795373439", "老王", A.ROLE_ADMIN, "s3cr3t", 12, now=1000.0)
p = A.verify_session(tok, "s3cr3t", now=1001.0)
assert p and p["phone"] == "18795373439" and p["username"] == "老王", p
assert p["role"] == A.ROLE_ADMIN and p["exp"] == 1000 + 12 * 3600, p
@case("会话: 篡改正文或签名 -> None")
def _sess_tampered():
tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 12, now=1000.0)
b, sig = tok.split(".", 1)
assert A.verify_session(b + "x." + sig, "s3cr3t", now=1001.0) is None
assert A.verify_session(b + "." + sig + "x", "s3cr3t", now=1001.0) is None
@case("会话: 过期 / 换密钥 / 空密钥 / 空票 一律拒")
def _sess_reject():
tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 1, now=1000.0)
assert A.verify_session(tok, "s3cr3t", now=1000.0 + 3600 + 1) is None, "过期没拒"
assert A.verify_session(tok, "other", now=1001.0) is None, "换密钥没拒"
assert A.verify_session(tok, "", now=1001.0) is None, "空密钥没拒"
assert A.verify_session("", "s3cr3t", now=1001.0) is None, "空票没拒"
# ---------------------------------------------------------------- 三、bshop 解析
@case("解析: 真实样例 -> 手机号/用户名/权限/组织")
def _parse_ok():
r = A.parse_bshop_login(SAMPLE)
assert r["ok"] is True, r
assert r["phone"] == "18795373439" and r["username"] == "18795373439", r
assert "band:bkld" in r["permissions"] and "band:-" in r["permissions"], r["permissions"]
assert "band:yhyqx" in r["permissions"], r["permissions"]
assert len(r["orgs"]) == 2 and r["orgs"][0]["org_name"] == "高姐", r["orgs"]
@case("解析: 样例接角色 —— 默认标记判交易员, 复用 band:yhyqx 判管理员")
def _parse_then_role():
r = A.parse_bshop_login(SAMPLE)
assert A.role_from_permissions(r["permissions"], "pms:admin", "") == A.ROLE_TRADER
assert A.role_from_permissions(r["permissions"], "band:yhyqx", "") == A.ROLE_ADMIN
@case("解析: code!=200 / 缺手机号 / 非字典 -> 失败")
def _parse_fail():
assert A.parse_bshop_login({"code": 401, "msg": "密码错误"})["ok"] is False
assert A.parse_bshop_login({"code": 200, "data": {"user": {}}})["ok"] is False
assert A.parse_bshop_login("nope")["ok"] is False
# ---------------------------------------------------------------- 四、接口鉴权
@case("鉴权: 交易员白名单 4 条写接口放行 (any)")
def _authz_trader_ok():
for m, path in [("POST", "/api/commands"),
("POST", "/api/commands/CMD-1/cancel"),
("POST", "/api/commands/CMD-1/replan"),
("POST", "/api/proposals/PRP-1/decide")]:
assert A.authz_decision(m, path) == "any", (m, path, A.authz_decision(m, path))
@case("鉴权: 写操作默认拒绝 —— 白名单外一律要管理员")
def _authz_admin_default():
admin_paths = [
("POST", "/api/params"),
("POST", "/api/ops/reconcile"),
("POST", "/api/strategies"),
("POST", "/api/strategies/S1/status"),
("POST", "/api/industry/import"),
("POST", "/api/ws-channel/clear-resync"),
("POST", "/api/instructions/INS-1/cancel"),
("POST", "/api/macro/adopt"), # 评审点出的漏网写接口, 兜住
("POST", "/api/proposals"), # 人工补录=造数调试口, 归管理员
("POST", "/api/commands/CMD-1/archive"), # 结尾段 archive != cancel
("POST", "/api/proposals/PRP-1/archive"),
]
for m, path in admin_paths:
assert A.authz_decision(m, path) == "admin", (m, path, A.authz_decision(m, path))
@case("鉴权: GET 默认放行, 仅三条运维读接口要管理员")
def _authz_gets():
for path in ["/api/ops/rebuild-preflight", "/api/ops/rebuild-accept",
"/api/ops/downstream-schema"]:
assert A.authz_decision("GET", path) == "admin", path
for path in ["/api/overview", "/api/positions", "/api/ledger", "/api/proposals"]:
assert A.authz_decision("GET", path) == "any", path
@case("公开白名单: 首页/静态/登录接口放行, 业务接口不放行")
def _public():
for p in ["/", "/health", "/favicon.ico", "/static", "/static/vendor/vue.global.js",
"/api/auth/login", "/api/auth/logout", "/api/me"]:
assert A.is_public_path(p) is True, p
for p in ["/api/params", "/api/overview", "/api/commands"]:
assert A.is_public_path(p) is False, p
def main():
passed, failed = 0, []
for name, fn in RESULTS:
try:
fn()
passed += 1
print(f"{name}")
except Exception as e:
failed.append((name, e))
print(f"{name}: {type(e).__name__}: {e}")
traceback.print_exc()
print()
if failed:
print(f"FAILED {len(failed)}/{len(RESULTS)}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()