feat: 多租户账号体系 + 前台收藏按钮

- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
Your Name
2026-08-11 12:58:12 +08:00
parent 2196189791
commit d9793300f9
16 changed files with 1216 additions and 278 deletions
+74 -15
View File
@@ -55,7 +55,7 @@ def req(method, path, data=None, raw=False, headers=None):
except Exception as ex:
return -1, {"error": str(ex)}
print("═══ 1. 认证安全 ═══")
print("═══ 1. 认证安全(多租户用户名+密码) ═══")
# 未登录访问管理 API -> 401
st, d = req("GET", "/admin/api/tree")
@@ -68,23 +68,38 @@ st, d = req("POST", "/admin/notes")
report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})")
# 错误密码登录
st, d = req("POST", "/admin/login", "password=wrongpass")
st, d = req("POST", "/admin/login", "username=smoketest&password=wrongpass")
report("错误密码登录返回401", st == 401, f"(got {st})")
# 正确登录
st, d = req("POST", "/admin/login", "password=admin123")
report("正确密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 用随机用户名注册一个专属测试管理员(保证可重复运行)
import uuid as _uuid
TUSER = "smoke_" + _uuid.uuid4().hex[:8]
st, d = req("POST", "/api/auth/register", {"username": TUSER, "password": "smoke123", "display_name": "冒烟测试"})
report("注册测试用户成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 注册后自动登录 -> 可访问管理 API
st, d = req("GET", "/admin/api/tree")
report("注册后自动登录(管理API可用)", st == 200 and d.get("code") == 0, f"(got {st})")
# 退出登录
st, d = req("POST", "/api/auth/logout")
report("登出成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 用户名+密码正确登录
st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
report("用户名密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 登录后可访问
st, d = req("GET", "/admin/api/tree")
report("登录后访问管理API成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 重复注册同一用户名 -> 失败
st, d = req("POST", "/api/auth/register", {"username": TUSER, "password": "smoke123"})
report("重复注册同名用户被拒", st == 400, f"(got {st} {d})")
# 旧固定 cookie 不再有效
class TmpOpener:
pass
# 手动构造请求带旧的 fixed cookie
import urllib.request as u
reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "admin_token=authenticated"})
reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "note_token=authenticated"})
try:
r = u.urlopen(reqf, timeout=10)
st_old = r.status
@@ -124,21 +139,24 @@ st, d = req("POST", "/admin/api/notes", {
protected_id = d.get("data", {}).get("id")
report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})")
# 通过公开接口 GET /api/notes/:id 访问 -> 应被拒
# 登出后(游客身份)通过公开接口 GET /api/notes/:id 访问 -> 应被拒
req("POST", "/api/auth/logout")
st, d = req("GET", f"/api/notes/{protected_id}")
report("公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})")
report("游客公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})")
# 密码验证接口应能正确返回
# 密码验证接口(公开,游客)应能正确返回
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "secret123"})
report("密码验证访问成功", st == 200 and "秘密内容" in d.get("data", {}).get("content", ""), f"(got {st})")
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "wrong"})
report("错误密码访问被拒", st == 401, f"(got {st})")
# 公开无密码笔记
public_ok = False
# 公开无密码笔记(游客)
st, d = req("GET", f"/api/notes/{note_id}")
report("公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
report("游客公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 重新登录回测试用户
req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
print("═══ 4. 回收站(软删除/恢复/清空) ═══")
@@ -341,6 +359,47 @@ report("合并后标签去重", "已重命名" in tag_str and "保留" not in ta
st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"})
report("删除标签", st == 200, f"(got {st})")
# ── 多租户数据隔离 ──
print("\n═══ 12b. 多租户数据隔离 ═══")
# 记住当前测试用户可见的笔记(应只有本脚本创建的 + 自己空间的)
st, d = req("GET", "/admin/api/tree")
own_ids = {x["id"] for x in d.get("data", [])}
# 注册第二个用户
st, d = req("POST", "/api/auth/register", {"username": "iso_" + _uuid.uuid4().hex[:8], "password": "iso12345"})
report("第二个用户注册成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 第二个用户的树必须是空的(看不到第一个用户的笔记)
st, d = req("GET", "/admin/api/tree")
other_ids = {x["id"] for x in d.get("data", [])}
report("第二用户树为空(数据隔离)", st == 200 and len(other_ids) == 0, f"(got {other_ids})")
# 两用户笔记 ID 无交集
report("两用户数据无交集", len(own_ids & other_ids) == 0, f"(own={own_ids} other={other_ids})")
# 第二用户无权读取第一用户创建的笔记
st, d = req("GET", f"/admin/api/notes/{note_id}")
report("越权读取他人笔记被拒(404)", st == 404, f"(got {st})")
# 第二用户无权收藏/删除他人笔记
st, d = req("PUT", f"/admin/api/notes/{note_id}", {"is_favorite": True})
report("越权修改他人笔记被拒(404)", st == 404, f"(got {st})")
# 第二用户 FTS 搜索不到第一用户的笔记
q = urllib.parse.urlencode({"q": "测试笔记A"})
st, d = req("GET", f"/api/notes/search?{q}")
other_hits = d.get("data") or []
report("第二用户搜不到他人笔记", len(other_hits) == 0, f"(got {len(other_hits)})")
# 游客公开接口能读取公开笔记(跨租户展示,需登出为游客)
req("POST", "/api/auth/logout")
st, d = req("GET", f"/api/notes/{note_id}")
report("游客可读公开笔记", st == 200 and d.get("code") == 0, f"(got {st})")
# 重新登录回第一个测试用户(后续清理需要)
st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"})
report("切回测试用户成功", st == 200 and d.get("code") == 0, f"(got {st})")
# ── 清理测试数据(彻底删除本脚本创建的所有笔记及其版本)──
print("\n═══ 13. 清理测试数据 ═══")
test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x]