test_admin_api.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. # -*- coding: utf-8 -*-
  2. """admin 端 24 个接口的端到端验证。
  3. 覆盖:
  4. 1. 登录:token 落 Redis(yoshop:admin:token:{token},值为管理员 JSON、无 password、TTL 7 天)
  5. 2. 未登录 / 错误 token 访问业务接口 → 401
  6. 3. admin.user/detail、renew(改回原值,不污染数据)
  7. 4. store/index、recycle、edit(原值回写)、recovery→move 回收站往返、add(免费版拒绝)
  8. 5. store.api:树列表、新增、编辑、删除(子级保护)
  9. 6. store.menu:树列表、详情、新增、编辑、set_apis(空值拒绝)、删除(子级保护)
  10. 7. setting.cache/clear(只清 data,不清登录态)
  11. 8. setting.science/info 结构
  12. 9. setting.timer/test 与 yoshop_system_process 一致性
  13. 10. logout 后 token 失效
  14. 前置:Java 服务在 18080;admin 账号口令临时改为 123456(脚本只管业务,口令由外部脚本改)。
  15. """
  16. import json
  17. import re
  18. import socket
  19. import sys
  20. import pymysql
  21. import requests
  22. BASE = "http://127.0.0.1:18080"
  23. REDIS = ("192.168.1.177", 6379, "@@Qinys12346..")
  24. PREFIX = "yoshop:admin:token:"
  25. STORE_PREFIX = "yoshop:store:token:"
  26. YML = r"F:/shop/java-rewrite/yoshop-java/src/main/resources/application.yml"
  27. DB_PWD = re.findall(r"password:\s*(.+)", open(YML, encoding="utf-8").read())[0].strip().strip("\"'")
  28. S = requests.Session()
  29. S.trust_env = False
  30. passed = failed = 0
  31. def check(name, cond, extra=""):
  32. global passed, failed
  33. if cond:
  34. passed += 1
  35. print(" [ok] %s" % name)
  36. else:
  37. failed += 1
  38. print(" [FAIL] %s %s" % (name, extra))
  39. # ---------------------------------------------------------------- 最小 Redis 客户端
  40. def resp(sock, *args):
  41. data = b"*%d\r\n" % len(args)
  42. for a in args:
  43. if isinstance(a, bytes):
  44. b = a
  45. elif isinstance(a, str):
  46. b = a.encode()
  47. else:
  48. b = str(a).encode()
  49. data += b"$%d\r\n%s\r\n" % (len(b), b)
  50. sock.sendall(data)
  51. return read(sock)
  52. def read(sock):
  53. line = readline(sock)
  54. t, rest = line[:1], line[1:]
  55. if t in (b"+", b"-"):
  56. return rest.decode(errors="replace")
  57. if t == b":":
  58. return int(rest)
  59. if t == b"$":
  60. n = int(rest)
  61. if n == -1:
  62. return None
  63. buf = b""
  64. while len(buf) < n + 2:
  65. buf += sock.recv(n + 2 - len(buf))
  66. return buf[:n].decode(errors="replace")
  67. if t == b"*":
  68. n = int(rest)
  69. return None if n == -1 else [read(sock) for _ in range(n)]
  70. return line.decode(errors="replace")
  71. def readline(sock):
  72. buf = b""
  73. while not buf.endswith(b"\r\n"):
  74. c = sock.recv(1)
  75. if not c:
  76. return buf
  77. buf += c
  78. return buf[:-2]
  79. class Redis:
  80. def __enter__(self):
  81. self.s = socket.create_connection((REDIS[0], REDIS[1]), timeout=5)
  82. resp(self.s, "AUTH", REDIS[2])
  83. return self
  84. def __exit__(self, *a):
  85. self.s.close()
  86. def __call__(self, *args):
  87. return resp(self.s, *args)
  88. # ---------------------------------------------------------------- HTTP
  89. def api(method, path, token=None, body=None, params=None):
  90. headers = {"Content-Type": "application/json"}
  91. if token:
  92. headers["Access-Token"] = token
  93. url = BASE + path
  94. if method == "GET":
  95. r = S.get(url, headers=headers, params=params or {}, timeout=60)
  96. else:
  97. r = S.post(url, headers=headers, json=body or {}, params=params or {}, timeout=60)
  98. try:
  99. return r.json()
  100. except Exception:
  101. return {"status": -1, "message": r.text[:200], "data": []}
  102. def main():
  103. conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD,
  104. database="shop", autocommit=True, charset="utf8mb4")
  105. cur = conn.cursor()
  106. cur.execute("select store_id, remark, sort, is_recycle from yoshop_store where store_id = 10001")
  107. store_snapshot = cur.fetchone()
  108. cur.execute("select store_user_id, user_name from yoshop_store_user where store_id = 10001 and is_super = 1")
  109. user_snapshot = cur.fetchone()
  110. temp_ids = {"api": [], "menu": []}
  111. try:
  112. with Redis() as r:
  113. print("== 1. 登录与登录态 ==")
  114. res = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"})
  115. check("登录成功", res.get("status") == 200 and res.get("message") == "登录成功",
  116. json.dumps(res, ensure_ascii=False))
  117. data = res.get("data") or {}
  118. token = data.get("token")
  119. check("返回 userId / token", data.get("userId") == 10001 and isinstance(token, str) and len(token) == 32,
  120. json.dumps(data, ensure_ascii=False))
  121. if not token:
  122. return 1
  123. key = PREFIX + token
  124. raw = r("GET", key)
  125. val = json.loads(raw) if raw else {}
  126. if isinstance(val, str):
  127. val = json.loads(val)
  128. check("value 顶层是管理员字段", val.get("adminUserId") == 10001 and val.get("userName") == "admin",
  129. json.dumps(val, ensure_ascii=False)[:160])
  130. check("不含 password", "password" not in val, sorted(val.keys()))
  131. ttl = r("TTL", key)
  132. check("TTL = 604800", isinstance(ttl, int) and 604795 <= ttl <= 604800, "ttl=%s" % ttl)
  133. print("== 2. 未登录 / 错误 token ==")
  134. no = api("GET", "/admin/store/index")
  135. check("无 token → 401", no.get("status") == 401 and no.get("message") == "请先登录后再访问",
  136. json.dumps(no, ensure_ascii=False)[:160])
  137. bad = api("GET", "/admin/store/index", token="0" * 32)
  138. check("错误 token → 401", bad.get("status") == 401, json.dumps(bad, ensure_ascii=False)[:160])
  139. print("== 3. admin.user/detail、renew ==")
  140. detail = api("GET", "/admin/admin.user/detail", token=token)
  141. ui = (detail.get("data") or {}).get("userInfo") or {}
  142. check("detail 返回当前管理员", detail.get("status") == 200 and ui.get("adminUserId") == 10001,
  143. json.dumps(detail, ensure_ascii=False)[:200])
  144. check("detail 不含 password", "password" not in ui, sorted(ui.keys()))
  145. renew = api("POST", "/admin/admin.user/renew", token=token,
  146. body={"form": {"userName": "admin", "password": "", "passwordConfirm": ""}})
  147. check("renew 空密码不修改 → 200", renew.get("status") == 200, json.dumps(renew, ensure_ascii=False)[:160])
  148. mismatch = api("POST", "/admin/admin.user/renew", token=token,
  149. body={"form": {"userName": "admin", "password": "a123456", "passwordConfirm": "b123456"}})
  150. check("两次密码不一致 → 确认密码不正确", mismatch.get("status") == 500
  151. and mismatch.get("message") == "确认密码不正确", json.dumps(mismatch, ensure_ascii=False)[:160])
  152. still = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"})
  153. check("口令未被改坏(123456 仍可登录)", still.get("status") == 200,
  154. json.dumps(still, ensure_ascii=False)[:160])
  155. if still.get("status") == 200:
  156. r("DEL", PREFIX + still["data"]["token"])
  157. print("== 4. 商城管理 ==")
  158. idx = api("GET", "/admin/store/index", token=token)
  159. lst = (idx.get("data") or {}).get("list") or {}
  160. rows = lst.get("data") or []
  161. check("store/index 200 且分页结构正确",
  162. idx.get("status") == 200 and lst.get("perPage") == 15 and isinstance(rows, list),
  163. json.dumps(idx, ensure_ascii=False)[:200])
  164. first = rows[0] if rows else {}
  165. check("列表字段驼峰且带 superUser",
  166. first.get("storeId") == 10001 and (first.get("superUser") or {}).get("userName") == "admin"
  167. and "logoImage" not in first, json.dumps(first, ensure_ascii=False)[:240])
  168. add = api("POST", "/admin/store/add", token=token)
  169. check("store/add 免费版拒绝", add.get("status") == 500
  170. and add.get("message") == "很抱歉,免费版暂不支持多开商城", json.dumps(add, ensure_ascii=False)[:160])
  171. edit = api("POST", "/admin/store/edit", token=token,
  172. body={"storeId": 10001, "form": {"userName": user_snapshot[1],
  173. "password": "", "passwordConfirm": "",
  174. "remark": store_snapshot[1], "sort": store_snapshot[2]}})
  175. check("store/edit 原值回写 → 200", edit.get("status") == 200, json.dumps(edit, ensure_ascii=False)[:200])
  176. rec = api("POST", "/admin/store/recovery", token=token, body={"storeId": 10001})
  177. cur.execute("select is_recycle from yoshop_store where store_id = 10001")
  178. recycled = cur.fetchone()[0]
  179. check("recovery 移入回收站(is_recycle=1)", rec.get("status") == 200 and recycled == 1,
  180. "status=%s is_recycle=%s" % (rec.get("status"), recycled))
  181. rec_list = api("GET", "/admin/store/recycle", token=token)
  182. rec_rows = ((rec_list.get("data") or {}).get("list") or {}).get("data") or []
  183. check("回收站列表能看到该商城", any(x.get("storeId") == 10001 for x in rec_rows),
  184. json.dumps(rec_rows, ensure_ascii=False)[:160])
  185. mv = api("POST", "/admin/store/move", token=token, body={"storeId": 10001})
  186. cur.execute("select is_recycle from yoshop_store where store_id = 10001")
  187. moved = cur.fetchone()[0]
  188. check("move 移出回收站(is_recycle=0)", mv.get("status") == 200 and moved == 0,
  189. "status=%s is_recycle=%s" % (mv.get("status"), moved))
  190. print("== 5. API 权限点 ==")
  191. tree = api("GET", "/admin/store.api/index", token=token)
  192. tree_rows = (tree.get("data") or {}).get("list") or []
  193. check("store.api/index 返回树", tree.get("status") == 200 and isinstance(tree_rows, list) and tree_rows,
  194. json.dumps(tree, ensure_ascii=False)[:200])
  195. check("根节点有 children", any("children" in x for x in tree_rows), "根节点数=%d" % len(tree_rows))
  196. add_api = api("POST", "/admin/store.api/add", token=token,
  197. body={"form": {"name": "临时权限A", "url": "tmp/a", "parentId": 0, "sort": 900}})
  198. cur.execute("select api_id from yoshop_store_api where url = 'tmp/a' order by api_id desc limit 1")
  199. row = cur.fetchone()
  200. api_id = row[0] if row else None
  201. if api_id:
  202. temp_ids["api"].append(api_id)
  203. check("store.api/add 成功", add_api.get("status") == 200 and api_id is not None,
  204. json.dumps(add_api, ensure_ascii=False)[:160])
  205. child = api("POST", "/admin/store.api/add", token=token,
  206. body={"form": {"name": "临时权限A-子", "url": "tmp/a-child", "parentId": api_id, "sort": 901}})
  207. cur.execute("select api_id from yoshop_store_api where url = 'tmp/a-child' order by api_id desc limit 1")
  208. row = cur.fetchone()
  209. child_id = row[0] if row else None
  210. if child_id:
  211. temp_ids["api"].append(child_id)
  212. check("新增子权限成功", child.get("status") == 200 and child_id is not None,
  213. json.dumps(child, ensure_ascii=False)[:160])
  214. edit_api = api("POST", "/admin/store.api/edit", token=token,
  215. body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": 0, "sort": 902}})
  216. check("store.api/edit 成功", edit_api.get("status") == 200, json.dumps(edit_api, ensure_ascii=False)[:160])
  217. cycle = api("POST", "/admin/store.api/edit", token=token,
  218. body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": child_id}})
  219. check("上级选到自己的子级被拒", cycle.get("status") == 500
  220. and cycle.get("message") == "上级权限不允许设置为当前子权限",
  221. json.dumps(cycle, ensure_ascii=False)[:160])
  222. del_parent = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id})
  223. check("有子级时删除被拒", del_parent.get("status") == 500
  224. and del_parent.get("message") == "当前权限下存在子权限,请先删除",
  225. json.dumps(del_parent, ensure_ascii=False)[:160])
  226. del_child = api("POST", "/admin/store.api/delete", token=token, body={"apiId": child_id})
  227. del_parent2 = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id})
  228. check("删子级 + 删父级成功", del_child.get("status") == 200 and del_parent2.get("status") == 200,
  229. "%s / %s" % (del_child.get("status"), del_parent2.get("status")))
  230. if del_parent2.get("status") == 200:
  231. temp_ids["api"] = []
  232. print("== 6. 后台菜单 ==")
  233. menus = api("GET", "/admin/store.menu/index", token=token)
  234. menu_rows = (menus.get("data") or {}).get("list") or []
  235. check("store.menu/index 返回树", menus.get("status") == 200 and bool(menu_rows),
  236. json.dumps(menus, ensure_ascii=False)[:200])
  237. check("菜单带 apiIds / 不含 menuApi",
  238. any("apiIds" in x for x in menu_rows) and all("menuApi" not in x for x in menu_rows),
  239. sorted(menu_rows[0].keys()) if menu_rows else "")
  240. first_menu_id = menu_rows[0]["menuId"]
  241. info = api("GET", "/admin/store.menu/info", token=token, params={"menuId": first_menu_id})
  242. info_row = (info.get("data") or {}).get("info") or {}
  243. check("store.menu/info 返回详情", info.get("status") == 200 and info_row.get("menuId") == first_menu_id,
  244. json.dumps(info, ensure_ascii=False)[:200])
  245. check("详情不含 apiIds/children", "apiIds" not in info_row and "children" not in info_row,
  246. sorted(info_row.keys()))
  247. add_menu = api("POST", "/admin/store.menu/add", token=token,
  248. body={"form": {"name": "临时菜单A", "path": "/tmp-a", "type": 10, "isPage": 1,
  249. "parentId": 0, "sort": 900}})
  250. cur.execute("select menu_id from yoshop_store_menu where path = '/tmp-a' order by menu_id desc limit 1")
  251. row = cur.fetchone()
  252. menu_id = row[0] if row else None
  253. if menu_id:
  254. temp_ids["menu"].append(menu_id)
  255. check("store.menu/add 成功", add_menu.get("status") == 200 and menu_id is not None,
  256. json.dumps(add_menu, ensure_ascii=False)[:160])
  257. edit_menu = api("POST", "/admin/store.menu/edit", token=token,
  258. body={"menuId": menu_id, "form": {"name": "临时菜单A改", "parentId": 0, "sort": 901}})
  259. check("store.menu/edit 成功", edit_menu.get("status") == 200, json.dumps(edit_menu, ensure_ascii=False)[:160])
  260. empty_apis = api("POST", "/admin/store.menu/set_apis", token=token,
  261. body={"menuId": menu_id, "form": {"apiIds": []}})
  262. check("set_apis 空数组被拒", empty_apis.get("status") == 500
  263. and empty_apis.get("message") == "API权限不能为空",
  264. json.dumps(empty_apis, ensure_ascii=False)[:160])
  265. bind = api("POST", "/admin/store.menu/set_apis", token=token,
  266. body={"menuId": menu_id, "form": {"apiIds": [1, 2]}})
  267. cur.execute("select api_id from yoshop_store_menu_api where menu_id = %s order by api_id", (menu_id,))
  268. bound = [x[0] for x in cur.fetchall()]
  269. check("set_apis 绑定成功", bind.get("status") == 200 and bound == [1, 2],
  270. "status=%s bound=%s" % (bind.get("status"), bound))
  271. del_menu = api("POST", "/admin/store.menu/delete", token=token, body={"menuId": menu_id})
  272. cur.execute("select count(*) from yoshop_store_menu_api where menu_id = %s", (menu_id,))
  273. left = cur.fetchone()[0]
  274. check("删除菜单并清关联", del_menu.get("status") == 200 and left == 0,
  275. "status=%s left=%s" % (del_menu.get("status"), left))
  276. if del_menu.get("status") == 200:
  277. temp_ids["menu"] = []
  278. print("== 7. 清理缓存 ==")
  279. clear = api("POST", "/admin/setting.cache/clear", token=token, body={"form": {"item": ["data"]}})
  280. check("cache/clear 返回 200", clear.get("status") == 200, json.dumps(clear, ensure_ascii=False)[:160])
  281. check("非强制模式不清登录态", r("GET", key) is not None, "key=%s" % key)
  282. print("== 8. 环境检测 ==")
  283. sci = api("GET", "/admin/setting.science/info", token=token)
  284. info_ = (sci.get("data") or {}).get("scienceInfo") or {}
  285. groups = ["server", "phpinfo", "writeable"]
  286. check("science/info 三段结构", sci.get("status") == 200 and all(g in info_ for g in groups),
  287. sorted(info_.keys()))
  288. items = (info_.get("server") or []) + (info_.get("writeable") or [])
  289. check("检测项形状 {name,key,value,status,remark}",
  290. items and all({"name", "key", "value", "status", "remark"} <= set(x.keys()) for x in items),
  291. json.dumps(items[:1], ensure_ascii=False)[:200])
  292. print("== 9. 定时任务探活 ==")
  293. cur.execute("select last_working_time from yoshop_system_process where `key` = 'timer'")
  294. row = cur.fetchone()
  295. last = row[0] if row else 0
  296. alive = __import__("time").time() - last <= 10
  297. timer = api("POST", "/admin/setting.timer/test", token=token)
  298. expect = "恭喜您,定时任务已开启" if alive else "很抱歉,定时任务未开启"
  299. check("timer/test 结果与 system_process 一致", timer.get("message") == expect,
  300. "expect=%s got=%s" % (expect, json.dumps(timer, ensure_ascii=False)[:160]))
  301. print("== 10. 登出 ==")
  302. out = api("POST", "/admin/passport/logout", token=token)
  303. check("logout 200", out.get("status") == 200, json.dumps(out, ensure_ascii=False)[:160])
  304. check("Redis 登录态已删除", r("GET", key) is None, "key=%s" % key)
  305. after = api("GET", "/admin/store/index", token=token)
  306. check("旧 token → 401", after.get("status") == 401, json.dumps(after, ensure_ascii=False)[:160])
  307. print("== 11. 商家 token 不能访问 admin ==")
  308. store_key = r("KEYS", STORE_PREFIX + "*")
  309. if store_key:
  310. fake = store_key[0].decode().replace(STORE_PREFIX, "") if isinstance(store_key[0], bytes) \
  311. else str(store_key[0]).replace(STORE_PREFIX, "")
  312. cross = api("GET", "/admin/store/index", token=fake)
  313. check("商家 token 访问 /admin → 401", cross.get("status") == 401,
  314. json.dumps(cross, ensure_ascii=False)[:160])
  315. else:
  316. print(" [skip] 当前无商家登录态可借用")
  317. finally:
  318. # 还原商城数据
  319. cur.execute("update yoshop_store set remark = %s, sort = %s, is_recycle = %s where store_id = 10001",
  320. (store_snapshot[1], store_snapshot[2], store_snapshot[3]))
  321. cur.execute("update yoshop_store_user set user_name = %s where store_user_id = %s",
  322. (user_snapshot[1], user_snapshot[0]))
  323. # 清理临时数据
  324. for api_id in temp_ids["api"]:
  325. cur.execute("delete from yoshop_store_menu_api where api_id = %s", (api_id,))
  326. cur.execute("delete from yoshop_store_api where api_id = %s", (api_id,))
  327. for menu_id in temp_ids["menu"]:
  328. cur.execute("delete from yoshop_store_menu_api where menu_id = %s", (menu_id,))
  329. cur.execute("delete from yoshop_store_menu where menu_id = %s", (menu_id,))
  330. cur.execute("delete from yoshop_store_api where url in ('tmp/a', 'tmp/a-child')")
  331. cur.execute("delete from yoshop_store_menu where path = '/tmp-a'")
  332. conn.close()
  333. print("\n通过 %d / 失败 %d" % (passed, failed))
  334. return 1 if failed else 0
  335. if __name__ == "__main__":
  336. sys.exit(main())