- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
403 lines
22 KiB
Python
403 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
user_scopes(数据范围)隔离实测 — 在测试库 pms_dev + 测试环境 API(:3000) 上跑
|
||
|
||
覆盖验收 1-6:
|
||
1. 造两个项目(同组织)+ 两个用户(同组织、role_level=0)+ 一个总管理员
|
||
2. 未配置 scope 的用户 → 能看到两个项目的设备(= 现状,证明向后兼容)
|
||
3. 配置 scope 只允许项目 A 的用户 → 只能看到项目 A 的设备(列表 + 详情都测)
|
||
4. 总管理员 → 看到全部(不受 scope 影响)
|
||
5. 配置一个不属于该组织的项目 id / 组织 id / 不存在的 id → 被 BadRequest 拒绝
|
||
6. 清理临时数据(脚本 finally 里无条件执行)
|
||
|
||
用法:
|
||
set PMS_TEST_JWT_SECRET=<测试环境 .env 里的 JWT_SECRET>
|
||
python scope_isolation_check.py
|
||
"""
|
||
import os
|
||
import sys
|
||
import time
|
||
import json
|
||
import random
|
||
import urllib.request
|
||
import urllib.error
|
||
|
||
import pymysql
|
||
import jwt # PyJWT
|
||
|
||
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
|
||
API = os.environ.get("PMS_TEST_API_BASE", "http://10.8.0.252:3000/api")
|
||
SECRET = os.environ.get("PMS_TEST_JWT_SECRET", "")
|
||
|
||
TAG = "SCOPETEST"
|
||
SUFFIX = f"{int(time.time()) % 100000:05d}"
|
||
PASS, FAIL = [], []
|
||
|
||
|
||
def ok(name, cond, detail=""):
|
||
(PASS if cond else FAIL).append(name)
|
||
print(f" [{'PASS' if cond else 'FAIL'}] {name} {detail}")
|
||
|
||
|
||
def token(uid, phone):
|
||
now = int(time.time())
|
||
return jwt.encode({"sub": uid, "phone": phone, "exp": now + 3600, "iat": now},
|
||
SECRET, algorithm="HS256")
|
||
|
||
|
||
def call(method, path, tok=None, body=None):
|
||
"""返回 (status, json_or_text)"""
|
||
req = urllib.request.Request(API + path, method=method)
|
||
req.add_header("Content-Type", "application/json")
|
||
if tok:
|
||
req.add_header("Authorization", "Bearer " + tok)
|
||
data = json.dumps(body).encode() if body is not None else None
|
||
try:
|
||
with urllib.request.urlopen(req, data, timeout=20) as r:
|
||
raw = r.read().decode()
|
||
return r.status, (json.loads(raw) if raw else None)
|
||
except urllib.error.HTTPError as e:
|
||
raw = e.read().decode()
|
||
try:
|
||
return e.code, json.loads(raw)
|
||
except Exception:
|
||
return e.code, raw
|
||
|
||
|
||
def main():
|
||
if not SECRET:
|
||
print("缺少 PMS_TEST_JWT_SECRET 环境变量(取 /data/pms_dev/api-server/.env 的 JWT_SECRET)")
|
||
sys.exit(2)
|
||
|
||
conn = pymysql.connect(**DB, autocommit=True)
|
||
cur = conn.cursor()
|
||
ids = {}
|
||
try:
|
||
# ---------------- 0. 预清理(防上次异常退出残留) ----------------
|
||
cleanup(cur)
|
||
|
||
# ---------------- 1. 造夹具 ----------------
|
||
print("\n=== [1] 造夹具 ===")
|
||
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG-{SUFFIX}",))
|
||
org = cur.lastrowid
|
||
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG2-{SUFFIX}",))
|
||
org2 = cur.lastrowid
|
||
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-A-{SUFFIX}"))
|
||
pa = cur.lastrowid
|
||
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-B-{SUFFIX}"))
|
||
pb = cur.lastrowid
|
||
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org2, f"{TAG}-C-{SUFFIX}"))
|
||
pc = cur.lastrowid
|
||
|
||
def add_cabinet(pid, seed):
|
||
imei = f"86{seed}{random.randint(1000000, 9999999)}"[:15]
|
||
# abstract_id 必须给值:h5 设备列表行结构体是 String(DB 可空),
|
||
# 留 NULL 会让该接口 500(历史缺陷,非本次改动引入)
|
||
cur.execute("INSERT INTO cabinets (project_id, imei, name, status, auth_str, abstract_id) "
|
||
"VALUES (%s,%s,%s,1,%s,%s)",
|
||
(pid, imei, f"{TAG}-CAB-{seed}", "AbCd1234", f"10-{SUFFIX}{seed}"))
|
||
cid = cur.lastrowid
|
||
cur.execute("INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (%s,1)", (cid,))
|
||
bid = cur.lastrowid
|
||
comp = None
|
||
for ch in range(1, 7):
|
||
cur.execute("INSERT INTO compartments (cabin_board_id, channel_id) VALUES (%s,%s)", (bid, ch))
|
||
if ch == 1:
|
||
comp = cur.lastrowid
|
||
# 每个柜子造 1 条充电订单 + 1 条设备日志 + 1 条充电记录(用于验证列表接口的范围收窄)
|
||
cur.execute("INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_soc, energy, status) "
|
||
"VALUES (%s,%s,%s,50,100,1)", (comp, cid, f"{TAG}-BAT-{seed}"))
|
||
order_id = cur.lastrowid
|
||
cur.execute("INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s,1,%s)",
|
||
(cid, f"{TAG}-LOG-{seed}"))
|
||
cur.execute("INSERT INTO charge_records (compartment_id, cabinet_id, energy, status) VALUES (%s,%s,10,0)",
|
||
(comp, cid))
|
||
return cid, imei, order_id
|
||
|
||
cab_a, imei_a, order_a = add_cabinet(pa, "1")
|
||
cab_b, imei_b, order_b = add_cabinet(pb, "2")
|
||
cab_c, imei_c, order_c = add_cabinet(pc, "3")
|
||
|
||
def add_user(role, organization_id, label):
|
||
phone = f"19{random.randint(100000000, 999999999)}"
|
||
cur.execute("INSERT INTO users (phone, name, password, role, organization_id, status) "
|
||
"VALUES (%s,%s,%s,%s,%s,1)",
|
||
(phone, f"{TAG}-{label}-{SUFFIX}", "not-a-real-hash", role, organization_id))
|
||
return cur.lastrowid, phone
|
||
|
||
u_open, ph_open = add_user(0, org, "OPEN") # 未配置 scope
|
||
u_a, ph_a = add_user(0, org, "SCOPEA") # 配 scope=项目A
|
||
u_admin, ph_admin = add_user(2, None, "ADMIN") # 总管理员
|
||
|
||
ids = dict(org=org, org2=org2, pa=pa, pb=pb, pc=pc,
|
||
cab_a=cab_a, cab_b=cab_b, cab_c=cab_c,
|
||
order_a=order_a, order_b=order_b, order_c=order_c,
|
||
u_open=u_open, u_a=u_a, u_admin=u_admin)
|
||
print(f" org={org} org2={org2} projA={pa} projB={pb} projC(org2)={pc}")
|
||
print(f" cabA={cab_a}/{imei_a} cabB={cab_b}/{imei_b} cabC={cab_c}/{imei_c}")
|
||
print(f" user_open={u_open} user_scopeA={u_a} admin={u_admin}")
|
||
|
||
t_open = token(u_open, ph_open)
|
||
t_a = token(u_a, ph_a)
|
||
t_admin = token(u_admin, ph_admin)
|
||
|
||
# ---------------- 2. 未配置 scope = 不限制(向后兼容) ----------------
|
||
print("\n=== [2] 未配置 scope 的用户 → 不限制(两个项目都可见)===")
|
||
st, body = call("GET", "/cabinets/filter?page_size=100", t_open)
|
||
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
|
||
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
|
||
ok("未配置 scope 可见项目A设备", cab_a in got)
|
||
ok("未配置 scope 可见项目B设备", cab_b in got)
|
||
ok("未配置 scope 不可见跨组织(项目C)设备", cab_c not in got)
|
||
|
||
# ---------------- 5. 非法 scope_id 必须 400 ----------------
|
||
print("\n=== [5] 非法 scope_id(跨组织/不存在)→ BadRequest ===")
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": pc, "permission_type": 1}]})
|
||
print(f" PUT scopes(项目C, 属组织2) -> {st} {body}")
|
||
ok("跨组织项目被拒 400", st == 400 and "不属于该用户所属组织" in str(body))
|
||
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": 999999999, "permission_type": 1}]})
|
||
print(f" PUT scopes(不存在项目) -> {st} {body}")
|
||
ok("不存在项目被拒 400", st == 400 and "不存在" in str(body))
|
||
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 1, "scope_id": org2, "permission_type": 1}]})
|
||
print(f" PUT scopes(组织级=别人的组织) -> {st} {body}")
|
||
ok("组织级跨组织被拒 400", st == 400)
|
||
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 9}]})
|
||
print(f" PUT scopes(permission_type=9) -> {st} {body}")
|
||
ok("非法 permission_type 被拒 400", st == 400)
|
||
|
||
st, body = call("PUT", "/users/999999999/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
|
||
print(f" PUT scopes(用户不存在) -> {st} {body}")
|
||
ok("目标用户不存在 -> 404", st == 404)
|
||
|
||
# ---------------- 3. 配置 scope=项目A → 收窄 ----------------
|
||
print("\n=== [3] 配置 scope 只允许项目 A → 只能看到项目 A ===")
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
|
||
print(f" PUT /users/{u_a}/scopes {{项目A}} -> {st} {body}")
|
||
ok("配置项目范围成功", st == 200)
|
||
|
||
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
|
||
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
|
||
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
|
||
print(f" data ids = {got}")
|
||
ok("列表:项目A设备可见", cab_a in got)
|
||
ok("列表:项目B设备不可见", cab_b not in got)
|
||
ok("列表:跨组织设备不可见", cab_c not in got)
|
||
|
||
st, body = call("GET", f"/cabinets/{cab_a}", t_a)
|
||
print(f" GET /cabinets/{cab_a}(范围内详情) -> {st} data.id={body['data']['id'] if st == 200 else body}")
|
||
ok("详情:范围内设备 200", st == 200 and body["data"]["id"] == cab_a)
|
||
|
||
st, body = call("GET", f"/cabinets/{cab_b}", t_a)
|
||
print(f" GET /cabinets/{cab_b}(范围外详情) -> {st} {body}")
|
||
ok("详情:范围外设备被拒 403", st == 403)
|
||
|
||
st, body = call("GET", f"/cabinets/{cab_c}", t_a)
|
||
print(f" GET /cabinets/{cab_c}(跨组织详情) -> {st} {body}")
|
||
ok("详情:跨组织设备被拒 403", st == 403)
|
||
|
||
st, body = call("GET", f"/projects/{pb}/cabinets", t_a)
|
||
print(f" GET /projects/{pb}/cabinets(范围外项目的设备列表) -> {st} {body}")
|
||
ok("范围外项目的设备列表被拒 403", st == 403)
|
||
|
||
st, body = call("GET", f"/projects/{pa}/cabinets", t_a)
|
||
print(f" GET /projects/{pa}/cabinets(范围内项目) -> {st} rows={len(body['data']) if st == 200 else body}")
|
||
ok("范围内项目设备列表 200", st == 200 and len(body["data"]) == 1)
|
||
|
||
st, body = call("GET", "/cabinets/realtime-status?imei=" + imei_b, t_a)
|
||
print(f" GET /cabinets/realtime-status?imei=(项目B) -> {st} {body}")
|
||
ok("范围外设备实时状态被拒 403", st == 403)
|
||
|
||
# 订单/记录/日志/能耗 列表接口(叠加了范围条件)—— 必须只出现项目 A 的数据
|
||
orders_filter_failed = False
|
||
st_filter_failed = False
|
||
st, body = call("GET", "/charge-orders?page_size=50", t_a)
|
||
order_ids = [o["id"] for o in body["data"]["list"]] if st == 200 else []
|
||
print(f" GET /charge-orders -> {st} total={body['data']['total'] if st == 200 else body} ids={order_ids}")
|
||
if st != 200:
|
||
# 诊断:换成「总管理员」再打一次。若同样 500 ⇒ 与数据范围无关(环境/结构问题)
|
||
st_admin, body_admin = call("GET", "/charge-orders?page_size=50", t_admin)
|
||
print(f" [诊断] 总管理员 GET /charge-orders -> {st_admin} {body_admin}")
|
||
ok("订单 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
|
||
orders_filter_failed = True
|
||
else:
|
||
ok("订单列表可访问", st == 200)
|
||
ok("订单列表:项目A订单可见", order_a in order_ids)
|
||
ok("订单列表:项目B订单不可见", order_b not in order_ids)
|
||
ok("订单列表:跨组织订单不可见", order_c not in order_ids)
|
||
|
||
st, body = call("GET", "/charge-records?page_size=50", t_a)
|
||
rec_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
|
||
print(f" GET /charge-records -> {st} total={body['data']['total'] if st == 200 else body} cabinets={rec_cabs}")
|
||
ok("充电记录列表可访问", st == 200)
|
||
ok("充电记录:仅项目A柜子", cab_a in rec_cabs and cab_b not in rec_cabs and cab_c not in rec_cabs)
|
||
|
||
st, body = call("GET", "/device-logs?page_size=50", t_a)
|
||
log_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
|
||
print(f" GET /device-logs -> {st} total={body['data']['total'] if st == 200 else body} cabinets={log_cabs}")
|
||
if st != 200:
|
||
st_admin, body_admin = call("GET", "/device-logs?page_size=50", t_admin)
|
||
print(f" [诊断] 总管理员 GET /device-logs -> {st_admin} {body_admin}")
|
||
ok("设备日志 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
|
||
st_filter_failed = True
|
||
else:
|
||
ok("设备日志列表可访问", st == 200)
|
||
ok("设备日志:仅项目A柜子", cab_a in log_cabs and cab_b not in log_cabs and cab_c not in log_cabs)
|
||
|
||
# —— 端点因历史 TIMESTAMP/DATETIME 漂移 500 时,直接用「实现生成的同一条 SQL」在库里验证收窄 ——
|
||
if st_filter_failed or orders_filter_failed:
|
||
print("\n [SQL 级验证] 用实现生成的同一条 WHERE 条件直接查库:")
|
||
sql_orders = (
|
||
"SELECT co.id FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
|
||
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
|
||
" WHERE p.organization_id = %s)"
|
||
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
|
||
)
|
||
cur.execute(sql_orders, (org, pa))
|
||
got_orders = [r[0] for r in cur.fetchall()]
|
||
print(f" 订单 SQL(组织={org} ∧ 项目={pa}) -> {got_orders}")
|
||
ok("SQL 级:订单只命中项目A", order_a in got_orders and order_b not in got_orders and order_c not in got_orders)
|
||
|
||
sql_logs = (
|
||
"SELECT dl.cabinet_id FROM device_logs dl WHERE 1=1"
|
||
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
|
||
" WHERE p.organization_id = %s)"
|
||
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
|
||
)
|
||
cur.execute(sql_logs, (org, pa))
|
||
got_logs = [r[0] for r in cur.fetchall()]
|
||
print(f" 设备日志 SQL(组织={org} ∧ 项目={pa}) -> {got_logs}")
|
||
ok("SQL 级:设备日志只命中项目A柜子", cab_a in got_logs and cab_b not in got_logs and cab_c not in got_logs)
|
||
|
||
for path in ("/energy-stats/summary", "/energy-stats/projects", "/energy-stats/cabinets", "/energy-stats/trend"):
|
||
st, body = call("GET", path, t_a)
|
||
print(f" GET {path} -> {st} {json.dumps(body, ensure_ascii=False)[:160] if st == 200 else body}")
|
||
ok(f"范围内用户可访问 {path}", st == 200)
|
||
|
||
st, body = call("GET", "/energy-stats/cabinets", t_a)
|
||
rank_ids = [r["cabinet_id"] for r in body["data"]] if st == 200 else []
|
||
print(f" 能耗排行 cabinet_ids = {rank_ids}")
|
||
ok("能耗排行:仅项目A柜子", cab_a in rank_ids and cab_b not in rank_ids and cab_c not in rank_ids)
|
||
|
||
# 小程序设备列表(h5)
|
||
st, body = call("GET", "/cabinets", t_a)
|
||
ids_h5 = [c["id"] for c in body["data"]] if st == 200 else []
|
||
print(f" GET /cabinets (小程序列表) -> {st} ids={ids_h5}")
|
||
ok("小程序列表:项目A可见", cab_a in ids_h5)
|
||
ok("小程序列表:项目B不可见", cab_b not in ids_h5)
|
||
|
||
# 写操作:范围外设备指令下发(设备必然离线,但归属校验在更前面)
|
||
st, body = call("POST", "/charge/start", t_a, {"compartment_id": 0})
|
||
print(f" POST /charge/start (空仓体) -> {st} {body}")
|
||
ok("写操作接口有归属校验路径(非 500)", st in (400, 403, 404))
|
||
|
||
# ---------------- 3b. 组织级 scope = 自己组织(不额外收窄) ----------------
|
||
print("\n=== [3b] 组织级 scope = 本人所属组织(合法,且不额外收窄)===")
|
||
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 1, "scope_id": org, "permission_type": 1}]})
|
||
print(f" PUT scopes(组织级=本组织) -> {st} {body}")
|
||
ok("组织级=本组织 被接受", st == 200)
|
||
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
|
||
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
|
||
ok("组织级范围下项目A仍可见", cab_a in got)
|
||
ok("组织级范围下项目B仍可见(组织级=整个组织)", cab_b in got)
|
||
|
||
# ---------------- 3c. 脏数据:一条有效都没有 → 不锁死 ----------------
|
||
print("\n=== [3c] 库内脏数据(指向不存在/跨组织/设备级)→ 忽略且不锁死 ===")
|
||
st, _ = call("PUT", f"/users/{u_a}/scopes", t_admin, {"scopes": []})
|
||
ok("清空范围成功", st == 200)
|
||
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
|
||
(u_a, 999999999))
|
||
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
|
||
(u_a, pc))
|
||
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,3,%s,1)",
|
||
(u_a, cab_b))
|
||
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
|
||
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
|
||
print(f" GET /cabinets/filter(脏数据下) -> {st} ids={got}")
|
||
ok("脏数据全部被忽略后按「不限制」处理(项目B可见,不锁死)", cab_a in got and cab_b in got)
|
||
st, body = call("GET", f"/users/{u_a}/scopes", t_admin)
|
||
print(f" GET /users/{u_a}/scopes -> {st} {body['data'] if st == 200 else body}")
|
||
# 3 条脏数据:不存在项目 / 跨组织项目 / 设备级(设备级**永不参与鉴权** ⇒ valid=false)
|
||
ok("脏数据被标记 valid=false",
|
||
st == 200 and len(body["data"]) == 3 and all(not s["valid"] for s in body["data"]))
|
||
cur.execute("DELETE FROM user_scopes WHERE user_id=%s", (u_a,))
|
||
|
||
# ---------------- 4. 总管理员豁免 ----------------
|
||
print("\n=== [4] 总管理员 → 看到全部(不受 scope 影响)===")
|
||
st, body = call("PUT", f"/users/{u_admin}/scopes", t_admin,
|
||
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
|
||
print(f" (故意给总管理员配 scope=项目A) PUT scopes -> {st} {body}")
|
||
st, body = call("GET", "/cabinets/filter?page_size=100", t_admin)
|
||
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
|
||
ok("总管理员仍可见项目A设备", cab_a in got)
|
||
ok("总管理员仍可见项目B设备(豁免)", cab_b in got)
|
||
ok("总管理员仍可见跨组织设备(豁免)", cab_c in got)
|
||
st, body = call("GET", f"/cabinets/{cab_b}", t_admin)
|
||
ok("总管理员可看任意设备详情", st == 200)
|
||
finally:
|
||
# ---------------- 6. 清理 ----------------
|
||
print("\n=== [6] 清理临时数据 ===")
|
||
try:
|
||
cleanup(cur)
|
||
print(" 已清理(scopes/users/cabinets/boards/compartments/projects/organizations)")
|
||
cur.execute(f"SELECT COUNT(*) FROM organizations WHERE name LIKE '{TAG}%'")
|
||
print(" 残留组织数 =", cur.fetchone()[0])
|
||
cur.execute(f"SELECT COUNT(*) FROM users WHERE name LIKE '{TAG}%'")
|
||
print(" 残留用户数 =", cur.fetchone()[0])
|
||
cur.execute(f"SELECT COUNT(*) FROM projects WHERE name LIKE '{TAG}%'")
|
||
print(" 残留项目数 =", cur.fetchone()[0])
|
||
cur.execute(f"SELECT COUNT(*) FROM cabinets WHERE name LIKE '{TAG}%'")
|
||
print(" 残留设备数 =", cur.fetchone()[0])
|
||
except Exception as e: # noqa: BLE001
|
||
print(" 清理失败:", e)
|
||
cur.close()
|
||
conn.close()
|
||
|
||
print(f"\n=== 结果:{len(PASS)} passed / {len(FAIL)} failed ===")
|
||
if FAIL:
|
||
print("FAILED:", FAIL)
|
||
sys.exit(1)
|
||
|
||
|
||
def cleanup(cur):
|
||
"""按名称前缀清理本脚本造的数据(幂等)"""
|
||
cur.execute(f"SELECT id FROM users WHERE name LIKE '{TAG}%'")
|
||
uids = [r[0] for r in cur.fetchall()]
|
||
if uids:
|
||
fmt = ",".join(["%s"] * len(uids))
|
||
cur.execute(f"DELETE FROM user_scopes WHERE user_id IN ({fmt})", uids)
|
||
cur.execute(f"DELETE FROM user_roles WHERE user_id IN ({fmt})", uids)
|
||
cur.execute(f"DELETE FROM users WHERE id IN ({fmt})", uids)
|
||
cur.execute(f"SELECT id FROM cabinets WHERE name LIKE '{TAG}%'")
|
||
cids = [r[0] for r in cur.fetchall()]
|
||
if cids:
|
||
fmt = ",".join(["%s"] * len(cids))
|
||
# 先清依赖业务数据(订单/记录/日志/仓体/仓板),再删柜子,避免外键报错
|
||
cur.execute(f"DELETE FROM charge_orders WHERE cabinet_id IN ({fmt})", cids)
|
||
cur.execute(f"DELETE FROM charge_records WHERE cabinet_id IN ({fmt})", cids)
|
||
cur.execute(f"DELETE FROM device_logs WHERE cabinet_id IN ({fmt})", cids)
|
||
cur.execute(f"DELETE FROM compartments WHERE cabin_board_id IN "
|
||
f"(SELECT id FROM cabin_boards WHERE cabinet_id IN ({fmt}))", cids)
|
||
cur.execute(f"DELETE FROM cabin_boards WHERE cabinet_id IN ({fmt})", cids)
|
||
cur.execute(f"DELETE FROM cabinets WHERE id IN ({fmt})", cids)
|
||
cur.execute(f"SELECT id FROM projects WHERE name LIKE '{TAG}%'")
|
||
pids = [r[0] for r in cur.fetchall()]
|
||
if pids:
|
||
fmt = ",".join(["%s"] * len(pids))
|
||
cur.execute(f"DELETE FROM projects WHERE id IN ({fmt})", pids)
|
||
cur.execute(f"DELETE FROM organizations WHERE name LIKE '{TAG}%'")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|