# -*- coding: utf-8 -*- """admin 端 24 个接口的端到端验证。 覆盖: 1. 登录:token 落 Redis(yoshop:admin:token:{token},值为管理员 JSON、无 password、TTL 7 天) 2. 未登录 / 错误 token 访问业务接口 → 401 3. admin.user/detail、renew(改回原值,不污染数据) 4. store/index、recycle、edit(原值回写)、recovery→move 回收站往返、add(免费版拒绝) 5. store.api:树列表、新增、编辑、删除(子级保护) 6. store.menu:树列表、详情、新增、编辑、set_apis(空值拒绝)、删除(子级保护) 7. setting.cache/clear(只清 data,不清登录态) 8. setting.science/info 结构 9. setting.timer/test 与 yoshop_system_process 一致性 10. logout 后 token 失效 前置:Java 服务在 18080;admin 账号口令临时改为 123456(脚本只管业务,口令由外部脚本改)。 """ import json import re import socket import sys import pymysql import requests BASE = "http://127.0.0.1:18080" REDIS = ("192.168.1.177", 6379, "@@Qinys12346..") PREFIX = "yoshop:admin:token:" STORE_PREFIX = "yoshop:store:token:" YML = r"F:/shop/java-rewrite/yoshop-java/src/main/resources/application.yml" DB_PWD = re.findall(r"password:\s*(.+)", open(YML, encoding="utf-8").read())[0].strip().strip("\"'") S = requests.Session() S.trust_env = False passed = failed = 0 def check(name, cond, extra=""): global passed, failed if cond: passed += 1 print(" [ok] %s" % name) else: failed += 1 print(" [FAIL] %s %s" % (name, extra)) # ---------------------------------------------------------------- 最小 Redis 客户端 def resp(sock, *args): data = b"*%d\r\n" % len(args) for a in args: if isinstance(a, bytes): b = a elif isinstance(a, str): b = a.encode() else: b = str(a).encode() data += b"$%d\r\n%s\r\n" % (len(b), b) sock.sendall(data) return read(sock) def read(sock): line = readline(sock) t, rest = line[:1], line[1:] if t in (b"+", b"-"): return rest.decode(errors="replace") if t == b":": return int(rest) if t == b"$": n = int(rest) if n == -1: return None buf = b"" while len(buf) < n + 2: buf += sock.recv(n + 2 - len(buf)) return buf[:n].decode(errors="replace") if t == b"*": n = int(rest) return None if n == -1 else [read(sock) for _ in range(n)] return line.decode(errors="replace") def readline(sock): buf = b"" while not buf.endswith(b"\r\n"): c = sock.recv(1) if not c: return buf buf += c return buf[:-2] class Redis: def __enter__(self): self.s = socket.create_connection((REDIS[0], REDIS[1]), timeout=5) resp(self.s, "AUTH", REDIS[2]) return self def __exit__(self, *a): self.s.close() def __call__(self, *args): return resp(self.s, *args) # ---------------------------------------------------------------- HTTP def api(method, path, token=None, body=None, params=None): headers = {"Content-Type": "application/json"} if token: headers["Access-Token"] = token url = BASE + path if method == "GET": r = S.get(url, headers=headers, params=params or {}, timeout=60) else: r = S.post(url, headers=headers, json=body or {}, params=params or {}, timeout=60) try: return r.json() except Exception: return {"status": -1, "message": r.text[:200], "data": []} def main(): conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD, database="shop", autocommit=True, charset="utf8mb4") cur = conn.cursor() cur.execute("select store_id, remark, sort, is_recycle from yoshop_store where store_id = 10001") store_snapshot = cur.fetchone() cur.execute("select store_user_id, user_name from yoshop_store_user where store_id = 10001 and is_super = 1") user_snapshot = cur.fetchone() temp_ids = {"api": [], "menu": []} try: with Redis() as r: print("== 1. 登录与登录态 ==") res = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"}) check("登录成功", res.get("status") == 200 and res.get("message") == "登录成功", json.dumps(res, ensure_ascii=False)) data = res.get("data") or {} token = data.get("token") check("返回 userId / token", data.get("userId") == 10001 and isinstance(token, str) and len(token) == 32, json.dumps(data, ensure_ascii=False)) if not token: return 1 key = PREFIX + token raw = r("GET", key) val = json.loads(raw) if raw else {} if isinstance(val, str): val = json.loads(val) check("value 顶层是管理员字段", val.get("adminUserId") == 10001 and val.get("userName") == "admin", json.dumps(val, ensure_ascii=False)[:160]) check("不含 password", "password" not in val, sorted(val.keys())) ttl = r("TTL", key) check("TTL = 604800", isinstance(ttl, int) and 604795 <= ttl <= 604800, "ttl=%s" % ttl) print("== 2. 未登录 / 错误 token ==") no = api("GET", "/admin/store/index") check("无 token → 401", no.get("status") == 401 and no.get("message") == "请先登录后再访问", json.dumps(no, ensure_ascii=False)[:160]) bad = api("GET", "/admin/store/index", token="0" * 32) check("错误 token → 401", bad.get("status") == 401, json.dumps(bad, ensure_ascii=False)[:160]) print("== 3. admin.user/detail、renew ==") detail = api("GET", "/admin/admin.user/detail", token=token) ui = (detail.get("data") or {}).get("userInfo") or {} check("detail 返回当前管理员", detail.get("status") == 200 and ui.get("adminUserId") == 10001, json.dumps(detail, ensure_ascii=False)[:200]) check("detail 不含 password", "password" not in ui, sorted(ui.keys())) renew = api("POST", "/admin/admin.user/renew", token=token, body={"form": {"userName": "admin", "password": "", "passwordConfirm": ""}}) check("renew 空密码不修改 → 200", renew.get("status") == 200, json.dumps(renew, ensure_ascii=False)[:160]) mismatch = api("POST", "/admin/admin.user/renew", token=token, body={"form": {"userName": "admin", "password": "a123456", "passwordConfirm": "b123456"}}) check("两次密码不一致 → 确认密码不正确", mismatch.get("status") == 500 and mismatch.get("message") == "确认密码不正确", json.dumps(mismatch, ensure_ascii=False)[:160]) still = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"}) check("口令未被改坏(123456 仍可登录)", still.get("status") == 200, json.dumps(still, ensure_ascii=False)[:160]) if still.get("status") == 200: r("DEL", PREFIX + still["data"]["token"]) print("== 4. 商城管理 ==") idx = api("GET", "/admin/store/index", token=token) lst = (idx.get("data") or {}).get("list") or {} rows = lst.get("data") or [] check("store/index 200 且分页结构正确", idx.get("status") == 200 and lst.get("perPage") == 15 and isinstance(rows, list), json.dumps(idx, ensure_ascii=False)[:200]) first = rows[0] if rows else {} check("列表字段驼峰且带 superUser", first.get("storeId") == 10001 and (first.get("superUser") or {}).get("userName") == "admin" and "logoImage" not in first, json.dumps(first, ensure_ascii=False)[:240]) add = api("POST", "/admin/store/add", token=token) check("store/add 免费版拒绝", add.get("status") == 500 and add.get("message") == "很抱歉,免费版暂不支持多开商城", json.dumps(add, ensure_ascii=False)[:160]) edit = api("POST", "/admin/store/edit", token=token, body={"storeId": 10001, "form": {"userName": user_snapshot[1], "password": "", "passwordConfirm": "", "remark": store_snapshot[1], "sort": store_snapshot[2]}}) check("store/edit 原值回写 → 200", edit.get("status") == 200, json.dumps(edit, ensure_ascii=False)[:200]) rec = api("POST", "/admin/store/recovery", token=token, body={"storeId": 10001}) cur.execute("select is_recycle from yoshop_store where store_id = 10001") recycled = cur.fetchone()[0] check("recovery 移入回收站(is_recycle=1)", rec.get("status") == 200 and recycled == 1, "status=%s is_recycle=%s" % (rec.get("status"), recycled)) rec_list = api("GET", "/admin/store/recycle", token=token) rec_rows = ((rec_list.get("data") or {}).get("list") or {}).get("data") or [] check("回收站列表能看到该商城", any(x.get("storeId") == 10001 for x in rec_rows), json.dumps(rec_rows, ensure_ascii=False)[:160]) mv = api("POST", "/admin/store/move", token=token, body={"storeId": 10001}) cur.execute("select is_recycle from yoshop_store where store_id = 10001") moved = cur.fetchone()[0] check("move 移出回收站(is_recycle=0)", mv.get("status") == 200 and moved == 0, "status=%s is_recycle=%s" % (mv.get("status"), moved)) print("== 5. API 权限点 ==") tree = api("GET", "/admin/store.api/index", token=token) tree_rows = (tree.get("data") or {}).get("list") or [] check("store.api/index 返回树", tree.get("status") == 200 and isinstance(tree_rows, list) and tree_rows, json.dumps(tree, ensure_ascii=False)[:200]) check("根节点有 children", any("children" in x for x in tree_rows), "根节点数=%d" % len(tree_rows)) add_api = api("POST", "/admin/store.api/add", token=token, body={"form": {"name": "临时权限A", "url": "tmp/a", "parentId": 0, "sort": 900}}) cur.execute("select api_id from yoshop_store_api where url = 'tmp/a' order by api_id desc limit 1") row = cur.fetchone() api_id = row[0] if row else None if api_id: temp_ids["api"].append(api_id) check("store.api/add 成功", add_api.get("status") == 200 and api_id is not None, json.dumps(add_api, ensure_ascii=False)[:160]) child = api("POST", "/admin/store.api/add", token=token, body={"form": {"name": "临时权限A-子", "url": "tmp/a-child", "parentId": api_id, "sort": 901}}) cur.execute("select api_id from yoshop_store_api where url = 'tmp/a-child' order by api_id desc limit 1") row = cur.fetchone() child_id = row[0] if row else None if child_id: temp_ids["api"].append(child_id) check("新增子权限成功", child.get("status") == 200 and child_id is not None, json.dumps(child, ensure_ascii=False)[:160]) edit_api = api("POST", "/admin/store.api/edit", token=token, body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": 0, "sort": 902}}) check("store.api/edit 成功", edit_api.get("status") == 200, json.dumps(edit_api, ensure_ascii=False)[:160]) cycle = api("POST", "/admin/store.api/edit", token=token, body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": child_id}}) check("上级选到自己的子级被拒", cycle.get("status") == 500 and cycle.get("message") == "上级权限不允许设置为当前子权限", json.dumps(cycle, ensure_ascii=False)[:160]) del_parent = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id}) check("有子级时删除被拒", del_parent.get("status") == 500 and del_parent.get("message") == "当前权限下存在子权限,请先删除", json.dumps(del_parent, ensure_ascii=False)[:160]) del_child = api("POST", "/admin/store.api/delete", token=token, body={"apiId": child_id}) del_parent2 = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id}) check("删子级 + 删父级成功", del_child.get("status") == 200 and del_parent2.get("status") == 200, "%s / %s" % (del_child.get("status"), del_parent2.get("status"))) if del_parent2.get("status") == 200: temp_ids["api"] = [] print("== 6. 后台菜单 ==") menus = api("GET", "/admin/store.menu/index", token=token) menu_rows = (menus.get("data") or {}).get("list") or [] check("store.menu/index 返回树", menus.get("status") == 200 and bool(menu_rows), json.dumps(menus, ensure_ascii=False)[:200]) check("菜单带 apiIds / 不含 menuApi", any("apiIds" in x for x in menu_rows) and all("menuApi" not in x for x in menu_rows), sorted(menu_rows[0].keys()) if menu_rows else "") first_menu_id = menu_rows[0]["menuId"] info = api("GET", "/admin/store.menu/info", token=token, params={"menuId": first_menu_id}) info_row = (info.get("data") or {}).get("info") or {} check("store.menu/info 返回详情", info.get("status") == 200 and info_row.get("menuId") == first_menu_id, json.dumps(info, ensure_ascii=False)[:200]) check("详情不含 apiIds/children", "apiIds" not in info_row and "children" not in info_row, sorted(info_row.keys())) add_menu = api("POST", "/admin/store.menu/add", token=token, body={"form": {"name": "临时菜单A", "path": "/tmp-a", "type": 10, "isPage": 1, "parentId": 0, "sort": 900}}) cur.execute("select menu_id from yoshop_store_menu where path = '/tmp-a' order by menu_id desc limit 1") row = cur.fetchone() menu_id = row[0] if row else None if menu_id: temp_ids["menu"].append(menu_id) check("store.menu/add 成功", add_menu.get("status") == 200 and menu_id is not None, json.dumps(add_menu, ensure_ascii=False)[:160]) edit_menu = api("POST", "/admin/store.menu/edit", token=token, body={"menuId": menu_id, "form": {"name": "临时菜单A改", "parentId": 0, "sort": 901}}) check("store.menu/edit 成功", edit_menu.get("status") == 200, json.dumps(edit_menu, ensure_ascii=False)[:160]) empty_apis = api("POST", "/admin/store.menu/set_apis", token=token, body={"menuId": menu_id, "form": {"apiIds": []}}) check("set_apis 空数组被拒", empty_apis.get("status") == 500 and empty_apis.get("message") == "API权限不能为空", json.dumps(empty_apis, ensure_ascii=False)[:160]) bind = api("POST", "/admin/store.menu/set_apis", token=token, body={"menuId": menu_id, "form": {"apiIds": [1, 2]}}) cur.execute("select api_id from yoshop_store_menu_api where menu_id = %s order by api_id", (menu_id,)) bound = [x[0] for x in cur.fetchall()] check("set_apis 绑定成功", bind.get("status") == 200 and bound == [1, 2], "status=%s bound=%s" % (bind.get("status"), bound)) del_menu = api("POST", "/admin/store.menu/delete", token=token, body={"menuId": menu_id}) cur.execute("select count(*) from yoshop_store_menu_api where menu_id = %s", (menu_id,)) left = cur.fetchone()[0] check("删除菜单并清关联", del_menu.get("status") == 200 and left == 0, "status=%s left=%s" % (del_menu.get("status"), left)) if del_menu.get("status") == 200: temp_ids["menu"] = [] print("== 7. 清理缓存 ==") clear = api("POST", "/admin/setting.cache/clear", token=token, body={"form": {"item": ["data"]}}) check("cache/clear 返回 200", clear.get("status") == 200, json.dumps(clear, ensure_ascii=False)[:160]) check("非强制模式不清登录态", r("GET", key) is not None, "key=%s" % key) print("== 8. 环境检测 ==") sci = api("GET", "/admin/setting.science/info", token=token) info_ = (sci.get("data") or {}).get("scienceInfo") or {} groups = ["server", "phpinfo", "writeable"] check("science/info 三段结构", sci.get("status") == 200 and all(g in info_ for g in groups), sorted(info_.keys())) items = (info_.get("server") or []) + (info_.get("writeable") or []) check("检测项形状 {name,key,value,status,remark}", items and all({"name", "key", "value", "status", "remark"} <= set(x.keys()) for x in items), json.dumps(items[:1], ensure_ascii=False)[:200]) print("== 9. 定时任务探活 ==") cur.execute("select last_working_time from yoshop_system_process where `key` = 'timer'") row = cur.fetchone() last = row[0] if row else 0 alive = __import__("time").time() - last <= 10 timer = api("POST", "/admin/setting.timer/test", token=token) expect = "恭喜您,定时任务已开启" if alive else "很抱歉,定时任务未开启" check("timer/test 结果与 system_process 一致", timer.get("message") == expect, "expect=%s got=%s" % (expect, json.dumps(timer, ensure_ascii=False)[:160])) print("== 10. 登出 ==") out = api("POST", "/admin/passport/logout", token=token) check("logout 200", out.get("status") == 200, json.dumps(out, ensure_ascii=False)[:160]) check("Redis 登录态已删除", r("GET", key) is None, "key=%s" % key) after = api("GET", "/admin/store/index", token=token) check("旧 token → 401", after.get("status") == 401, json.dumps(after, ensure_ascii=False)[:160]) print("== 11. 商家 token 不能访问 admin ==") store_key = r("KEYS", STORE_PREFIX + "*") if store_key: fake = store_key[0].decode().replace(STORE_PREFIX, "") if isinstance(store_key[0], bytes) \ else str(store_key[0]).replace(STORE_PREFIX, "") cross = api("GET", "/admin/store/index", token=fake) check("商家 token 访问 /admin → 401", cross.get("status") == 401, json.dumps(cross, ensure_ascii=False)[:160]) else: print(" [skip] 当前无商家登录态可借用") finally: # 还原商城数据 cur.execute("update yoshop_store set remark = %s, sort = %s, is_recycle = %s where store_id = 10001", (store_snapshot[1], store_snapshot[2], store_snapshot[3])) cur.execute("update yoshop_store_user set user_name = %s where store_user_id = %s", (user_snapshot[1], user_snapshot[0])) # 清理临时数据 for api_id in temp_ids["api"]: cur.execute("delete from yoshop_store_menu_api where api_id = %s", (api_id,)) cur.execute("delete from yoshop_store_api where api_id = %s", (api_id,)) for menu_id in temp_ids["menu"]: cur.execute("delete from yoshop_store_menu_api where menu_id = %s", (menu_id,)) cur.execute("delete from yoshop_store_menu where menu_id = %s", (menu_id,)) cur.execute("delete from yoshop_store_api where url in ('tmp/a', 'tmp/a-child')") cur.execute("delete from yoshop_store_menu where path = '/tmp-a'") conn.close() print("\n通过 %d / 失败 %d" % (passed, failed)) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())