Files
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

459 lines
25 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 -*-
"""角色与授权 E2E(二期批次 1~4)。
这个脚本验的不是"接口返回 200",而是**"改完之后,系统真的按新授权做事"** ——
权限系统最容易出的错是"看起来配好了、其实没生效",而这个脚本专门盯这一类:
| # | 要证明的事 | 怎么证明 |
|---|---|---|
| 1 | 自定义角色的**权限码真的在挡** | 给自定义角色只授 `task:view` → 该账号 GET /tasks 通、POST /tasks 得 40301 |
| 2 | 改授权**立即生效**(不必重新登录) | 用**同一个 token** 改完授权马上再请求,权限随之变化 |
| 3 | 停用角色**立即生效** | 停用后同一个 token 立刻失去该角色的权限 |
| 4 | 菜单跟着角色走 | `/auth/me` 的 menus 随角色配置增减(登录响应里也必须有,否则"刷新一次才看得见") |
| 5 | 内置角色**只读** | 改/删/授权/停用内置角色一律 40001 |
| 6 | 越权防线在**服务端** | 学校租户授平台级权限码 → 40301;建"平台运营"类角色 → 40301;发 PLATFORM_ADMIN → 40301 |
| 7 | 内置角色的权限**以代码为准** | 人为删掉库里的一行授权 → 接口返回的仍是代码那一套;跑一次同步任务 → 库里那行回来 |
| 8 | 防自锁 | 管理员把自己唯一一条 `role:manage` / `role` 菜单去掉 → 40001(而不是真的锁死) |
| 9 | 删除守卫 | 还有账号在用的角色删不掉(40045);解绑后能删,且账号立刻失去权限 |
跑法:python scripts/e2e-m10-role.py
前置:后端 8080 在跑、xt-pg / xt-redis 在跑(与其它 e2e 相同)。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://localhost:8080"
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_USERS: list[int] = []
CREATED_ROLES: list[int] = []
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None,
payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def clear_login_fail(*accounts: str) -> None:
"""清掉登录失败计数:脚本反复跑会把账号锁掉,而锁定看起来像"密码错了"。"""
for a in accounts:
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:login:fail:%s > NUL 2>&1' % a)
def login(username: str, password: str, tenant: str | None = "demo"):
_, b = R.login(BASE, username, password, tenant)
if b.get("code") != 0:
return None, b
return b["data"]["token"], b["data"]
def role_by_code(token: str, code: str) -> dict:
for r in (body_of("GET", "/api/v1/roles", token).get("data") or []):
if r.get("roleCode") == code:
return r
return {}
def create_role(token: str, code: str, name: str, user_type: int,
perms: list[str], menus: list[str]) -> dict:
b = body_of("POST", "/api/v1/roles", token, {
"roleCode": code, "roleName": name, "userType": user_type,
"permCodes": perms, "menus": menus,
})
if b.get("code") == 0 and b.get("data"):
CREATED_ROLES.append(b["data"])
return b
def create_user(token: str, username: str, real_name: str, phone: str,
role_codes: list[str], user_type: int) -> dict:
b = body_of("POST", "/api/v1/users", token, {
"username": username, "realName": real_name, "phone": phone,
"roleCodes": role_codes, "userType": user_type,
})
if b.get("code") == 0 and (b.get("data") or {}).get("id"):
CREATED_USERS.append(b["data"]["id"])
return b
def cleanup() -> None:
"""只删本脚本造的东西(演示租户里别人的数据一律不碰)。"""
for uid in CREATED_USERS:
R.sql(f"delete from sys_user_role where user_id={uid};")
R.sql(f"delete from sys_user where id={uid};")
for rid in CREATED_ROLES:
R.sql(f"delete from sys_role_permission where role_id={rid};")
R.sql(f"delete from sys_role_menu where role_id={rid};")
R.sql(f"delete from sys_role where id={rid};")
def main() -> int:
ap = argparse.ArgumentParser(description="角色与授权 E2E")
ap.parse_args()
print("=" * 82)
print(" 角色与授权 E2E(自定义角色真的生效 / 内置角色只读 / 越权与自锁防线)")
print("=" * 82)
suffix = os.urandom(3).hex()
role_a_code = f"E2E_ROLE_A_{suffix.upper()}"
role_b_code = f"E2E_ROLE_B_{suffix.upper()}"
user_a = f"e2e_role_a_{suffix}"
user_b = f"e2e_role_b_{suffix}"
clear_login_fail("admin", "platform", user_a, user_b)
at, adm = login("admin", "Admin@123")
if not at:
print(f" x 管理员登录失败:{adm}")
return 2
admin_uid = adm["user"]["id"]
# ══════════════ 1. 权限码目录 ══════════════
print("\n[1] 权限码目录(角色授权界面的勾选项来源)")
b = body_of("GET", "/api/v1/permissions", at)
groups = b.get("data") or []
check("权限码目录可读且按分组返回", b.get("code") == 0 and len(groups) >= 5,
json.dumps(b, ensure_ascii=False)[:200])
items = [i for g in groups for i in (g.get("items") or [])]
check("目录里 28 个权限码一个不少(删掉 join:audit / cleanup:run 之后)",
len(items) == 28, f"实际 {len(items)}")
by_code = {i.get("code"): i for i in items}
check("每条权限码都带中文名与适用端",
all(i.get("label") and i.get("platform") for i in items))
check("⭐ 平台级权限被标出来(tenant:manage → platformOnly=true)",
by_code.get("tenant:manage", {}).get("platformOnly") is True)
check("role:manage 不是平台级(学校管理员可以授予)",
by_code.get("role:manage", {}).get("platformOnly") is False)
# ══════════════ 2. 内置角色:只读 + 权限来自代码 ══════════════
print("\n[2] 内置角色:看得见、改不了")
roles = body_of("GET", "/api/v1/roles", at).get("data") or []
check("角色列表可读(内置 4 个 + 可能的自定义角色)", len(roles) >= 4, f"实际 {len(roles)} 个")
check("⭐ 内置角色一律 editable=false(权限由代码决定)",
all(r.get("editable") is False for r in roles if r.get("roleType") == 1),
json.dumps([(r.get("roleCode"), r.get("editable")) for r in roles],
ensure_ascii=False)[:220])
sa = role_by_code(at, "SCHOOL_ADMIN")
check("内置角色的权限码取代码定义(含 role:manage / task:manage)",
"role:manage" in (sa.get("permCodes") or []) and "task:manage" in (sa.get("permCodes") or []),
json.dumps(sa, ensure_ascii=False)[:220])
check("内置角色的菜单取代码定义(含 role 菜单)",
"role" in (sa.get("menus") or []))
check("内置角色带 userCount(删除守卫与界面都要用)",
isinstance(sa.get("userCount"), int) and sa.get("userCount") >= 1,
f"userCount={sa.get('userCount')}")
check("⭐ 可分配的内置角色 assignable=true(学校管理员能发给账号)",
sa.get("assignable") is True, f"assignable={sa.get('assignable')}")
# ⚠️ demo 租户里有一行历史遗留的 PLATFORM_ADMIN(早于"平台角色只发平台租户"那次修正)。
# 它正好是这条断言的样本:**看得见、发不出去** —— 界面据此不显示它。
pa = role_by_code(at, "PLATFORM_ADMIN")
if pa:
check("⭐ 学校租户里的平台运营角色 assignable=false(看得见但发不出去)",
pa.get("assignable") is False, f"assignable={pa.get('assignable')}")
else:
check("学校租户里没有平台运营角色(也没问题)", True)
# ══════════════ 3. 建自定义角色:服务端校验 ══════════════
print("\n[3] 建自定义角色(含服务端白名单校验)")
b = create_role(at, role_a_code, "E2E 年级审核员", 3,
["task:view", "material:manage", "progress:viewOwn"],
["dashboard", "task"])
check("自定义角色创建成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
role_a = role_by_code(at, role_a_code)
check("列表里能看到它,且 editable=true", role_a.get("editable") is True)
check("权限码原样保存", sorted(role_a.get("permCodes") or [])
== sorted(["task:view", "material:manage", "progress:viewOwn"]))
check("菜单原样保存", sorted(role_a.get("menus") or []) == sorted(["dashboard", "task"]))
check("userType 落库(账号的用户类型由它推导)", role_a.get("userType") == 3)
check("新建角色默认 dataScope=4 仅本人(最保守)", role_a.get("dataScope") == 4)
check("新建角色 userCount=0", role_a.get("userCount") == 0)
check("角色码重复 → 40001",
create_role(at, role_a_code, "重名", 3, ["task:view"], ["task"]).get("code") == 40001)
check("⭐ 占用内置角色码 → 40001(不能造一个假 SCHOOL_ADMIN)",
create_role(at, "SCHOOL_ADMIN", "假管理员", 2,
["task:view"], ["task"]).get("code") == 40001)
check("角色码格式不合法(小写)→ 40001",
create_role(at, f"e2e_bad_{suffix}", "小写码", 3,
["task:view"], ["task"]).get("code") == 40001)
check("⭐ 学校租户授平台级权限 → 40301",
create_role(at, f"E2E_ESC_{suffix.upper()}", "越权角色", 2,
["tenant:manage"], ["tenant"]).get("code") == 40301,
json.dumps(create_role(at, f"E2E_ESC2_{suffix.upper()}", "越权角色2", 2,
["tenant:manage"], ["tenant"]), ensure_ascii=False)[:200])
check("不存在的权限码 → 40001",
create_role(at, f"E2E_P_{suffix.upper()}", "坏权限", 3,
["no:such:perm"], ["task"]).get("code") == 40001)
check("不存在的菜单键 → 40001",
create_role(at, f"E2E_M_{suffix.upper()}", "坏菜单", 3,
["task:view"], ["no-such-menu"]).get("code") == 40001)
check("⭐ 学校租户配平台专用菜单(租户管理)→ 40301",
create_role(at, f"E2E_MT_{suffix.upper()}", "平台菜单", 3,
["task:view"], ["tenant"]).get("code") == 40301)
check("⭐ 学校租户建「平台运营」类角色 → 40301",
create_role(at, f"E2E_U1_{suffix.upper()}", "平台类角色", 1,
["task:view"], ["task"]).get("code") == 40301)
# ══════════════ 4. 发出去 + 权限真的在挡 ══════════════
print("\n[4] 把自定义角色发给账号,验证权限码真的在挡")
phone_a = "139" + "".join(c for c in suffix if c.isdigit()).ljust(8, "0")[:8]
b = create_user(at, user_a, "E2E角色甲", phone_a, [role_a_code], 3)
check("给账号发自定义角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
uid_a = (b.get("data") or {}).get("id")
pwd_a = (b.get("data") or {}).get("initialPassword")
check("新增账号返回初始密码", bool(pwd_a))
check("⭐ 用户类型与角色不符 → 40001(角色说 3,传 4)",
create_user(at, f"{user_a}_x", "E2E角色甲x", "13900000001",
[role_a_code], 4).get("code") == 40001)
check("⭐ 学校租户发 PLATFORM_ADMIN → 40301(原有防线未回归)",
create_user(at, f"{user_a}_p", "E2E越权", "13900000002",
["PLATFORM_ADMIN"], 1).get("code") == 40301)
ta, prof_a = login(user_a, pwd_a)
check("自定义角色账号能登录", bool(ta), json.dumps(prof_a, ensure_ascii=False)[:200])
check("⭐ 登录响应里就带着自定义角色的权限(否则要刷新一次才看得见按钮)",
"task:view" in (prof_a.get("permissions") or []),
json.dumps(prof_a.get("permissions"), ensure_ascii=False)[:220])
check("登录响应里带着自定义角色的菜单",
"task" in (prof_a.get("menus") or []) and "role" not in (prof_a.get("menus") or []),
json.dumps(prof_a.get("menus"), ensure_ascii=False)[:220])
b = body_of("GET", "/api/v1/auth/me", ta)
check("/auth/me 的 permissions 与登录响应一致",
"task:view" in (b.get("data", {}).get("permissions") or []),
json.dumps(b, ensure_ascii=False)[:220])
b = body_of("GET", "/api/v1/tasks", ta)
check("有 task:view → 任务列表可读", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("POST", "/api/v1/tasks", ta, {
"taskName": "不该建出来", "batchNo": "202610", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"})
check("⭐ 没有 task:manage → 建任务被 40301 拦住",
b.get("code") == 40301, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("GET", "/api/v1/roles", ta)
check("⭐ 没有 role:manage / user:view → 读角色列表 40301",
b.get("code") == 40301, f"code={b.get('code')}")
# ══════════════ 5. 改授权:立即生效(同一个 token)══════════════
print("\n[5] 改授权立即生效(不需要重新登录)")
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
{"permCodes": ["roster:view"]})
check("管理员改角色权限成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("GET", "/api/v1/tasks", ta)
check("⭐ 用**同一个 token** 立刻失去 task:view(40301,不必重新登录)",
b.get("code") == 40301, f"code={b.get('code')}")
b = body_of("GET", "/api/v1/auth/me", ta)
check("同一 token 的 /auth/me 权限同步变化",
"task:view" not in (b.get("data", {}).get("permissions") or []),
json.dumps(b.get("data", {}).get("permissions"), ensure_ascii=False)[:200])
body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
{"permCodes": ["task:view", "material:manage"]})
b = body_of("GET", "/api/v1/tasks", ta)
check("权限加回来 → 立刻又能读(缓存双向都生效)", b.get("code") == 0, f"code={b.get('code')}")
# ══════════════ 6. 菜单随角色走 ══════════════
print("\n[6] 菜单跟着角色配置走")
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "export"]})
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
check("⭐ 菜单改成 export → /auth/me 里出现 export、task 消失",
"export" in menus and "task" not in menus, json.dumps(menus, ensure_ascii=False)[:200])
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "task"]})
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
check("菜单改回来 → task 回来", "task" in menus)
# ══════════════ 7. 停用角色立即生效 ══════════════
print("\n[7] 停用角色:立刻不再给权限")
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=0", at)
check("停用成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("GET", "/api/v1/tasks", ta)
check("⭐ 同一个 token 立刻失去权限(40301)", b.get("code") == 40301, f"code={b.get('code')}")
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=1", at)
check("重新启用 → 权限回来",
body_of("GET", "/api/v1/tasks", ta).get("code") == 0)
check("非法状态值 → 40001",
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=9", at).get("code") == 40001)
# ══════════════ 8. 内置角色不可改 ══════════════
print("\n[8] 内置角色:改 / 删 / 授权 / 停用 一律拒绝")
sid = sa["id"]
check("改内置角色 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}", at,
{"roleName": "改个名", "userType": 2}).get("code") == 40001)
check("改内置角色授权 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/permissions", at,
{"permCodes": ["task:view"]}).get("code") == 40001)
check("改内置角色菜单 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/menus", at,
{"menus": ["task"]}).get("code") == 40001)
check("停用内置角色 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/status?status=0", at).get("code") == 40001)
check("删内置角色 → 40001",
body_of("DELETE", f"/api/v1/roles/{sid}", at).get("code") == 40001)
check("改一个不存在的角色 → 40001",
body_of("PUT", "/api/v1/roles/99999999", at,
{"roleName": "x", "userType": 3}).get("code") == 40001)
# ══════════════ 9. 账号列表里的角色名(读库)══════════════
print("\n[9] 账号列表的角色名读库(自定义角色的名字不在代码里)")
b = body_of("GET", f"/api/v1/users?keyword={user_a}", at)
row = next((u for u in (b.get("data", {}).get("list") or []) if u.get("id") == uid_a), {})
check("⭐ 自定义角色显示的是库里的角色名",
row.get("roleNames") == ["E2E 年级审核员"],
json.dumps(row.get("roleNames"), ensure_ascii=False)[:200])
# ══════════════ 10. 删除守卫 ══════════════
print("\n[10] 删除守卫:还有账号在用就删不掉")
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
check("⭐ 还有账号在用 → 40045", b.get("code") == 40045,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/users/{uid_a}", at, {
"realName": "E2E角色甲", "phone": phone_a,
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
check("把该账号换成内置角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
check("解绑后可以删除", b.get("code") == 0, f"code={b.get('code')} msg={b.get('msg')}")
check("删除后列表里不再有它", not role_by_code(at, role_a_code))
ta2, _ = login(user_a, pwd_a)
perms = (body_of("GET", "/api/v1/auth/me", ta2).get("data", {}) or {}).get("permissions") or []
# ⚠️ 判据要选**只有自定义角色才有**的那条(material:manage)。
# 第一版断言的是 roster:view / audit:first —— 而 HEAD_TEACHER 本来就有这两条,
# 于是"换角色成功"被读成了"权限没清掉"(断言写错,不是功能错)。
check("⭐ 换角色后重新登录:自定义角色的权限没了、新角色的权限在",
"material:manage" not in perms and "task:view" in perms,
json.dumps(perms, ensure_ascii=False)[:220])
# ══════════════ 11. 防自锁 ══════════════
print("\n[11] 防自锁:不能把自己管理角色的最后一条路去掉")
b = create_role(at, role_b_code, "E2E 角色管理员", 2,
["role:manage", "user:manage", "user:view"],
["dashboard", "role", "user"])
check("建「角色管理员」角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
role_b = role_by_code(at, role_b_code)
b = create_user(at, user_b, "E2E角色乙", "13900000003", [role_b_code], 2)
check("发给账号成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
uid_b = (b.get("data") or {}).get("id")
pwd_b = (b.get("data") or {}).get("initialPassword")
tb, prof_b = login(user_b, pwd_b)
check("该账号能登录,且带着 role:manage",
"role:manage" in (prof_b.get("permissions") or []),
json.dumps(prof_b.get("permissions"), ensure_ascii=False)[:220])
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
{"permCodes": ["user:manage", "user:view"]})
check("⭐ 去掉自己唯一的 role:manage → 40001(防自锁)", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/menus", tb,
{"menus": ["dashboard", "user"]})
check("⭐ 去掉自己唯一的 role 菜单 → 40001(否则菜单里再也点不到本页)",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/status?status=0", tb)
check("⭐ 停用自己唯一的角色 → 40001", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
"realName": "E2E角色乙", "phone": "13900000003",
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
check("⭐ 把自己换成普通角色(会丢掉账号管理)→ 40001",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
{"permCodes": ["role:manage", "user:manage", "user:view", "task:view"]})
check("加权限(不减少)不受防自锁影响", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
"realName": "E2E角色乙", "phone": "13900000003",
"roleCodes": [role_b_code], "userType": 2})
check("改自己的其它字段(角色不变)不受影响", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
# ══════════════ 12. 内置角色授权同步(跨租户定时任务)══════════════
print("\n[12] 内置角色的权限以代码为准 + 每日同步回填")
role_id_sa = sa["id"]
R.sql(f"delete from sys_role_permission where role_id={role_id_sa} and perm_code='task:clone';")
check("夹具:库里那一行已删掉",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "0")
sa2 = role_by_code(at, "SCHOOL_ADMIN")
check("⭐ 接口返回的仍是代码那一套(库里缺了也不影响显示与判定)",
"task:clone" in (sa2.get("permCodes") or []))
_, ops = R.login(BASE, "platform", "Platform@123", None)
ops_token = (ops.get("data") or {}).get("token")
check("平台运营账号可登录(同步任务是运维动作)", bool(ops_token))
b = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
check("手工触发「内置角色授权同步」", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("⭐ 同步后库里那行回来了",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
check("⭐ 再跑一次不产生重复行(幂等)",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
# ══════════════ 13. 越权:跨租户拿不到别人的角色 ══════════════
print("\n[13] 跨租户防线")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}", ops_token,
{"roleName": "平台改名", "userType": 2})
check("⭐ 平台运营不选学校就改学校的角色 → 40001(必须先选定学校)",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("GET", "/api/v1/roles", None)
check("未登录读角色列表 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
cleanup()
print("\n" + "=" * 82)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 82)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
sys.stdout.flush()
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())