#!/usr/bin/env python3 # -*- coding: utf-8 -*- """note-manager 新功能端到端冒烟测试""" import json import re import sys import urllib.request import urllib.parse import http.cookiejar import io import zipfile BASE = "http://localhost:8080" cj = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) PASSED = 0 FAILED = 0 def report(name, ok, detail=""): global PASSED, FAILED if ok: PASSED += 1 print(f" ✅ {name}") else: FAILED += 1 print(f" ❌ {name} {detail}") def req(method, path, data=None, raw=False, headers=None): url = BASE + path body = None hdrs = dict(headers or {}) if data is not None: if isinstance(data, (dict, list)): body = json.dumps(data).encode() hdrs["Content-Type"] = "application/json" elif isinstance(data, str): body = data.encode() hdrs["Content-Type"] = "application/x-www-form-urlencoded" r = urllib.request.Request(url, data=body, method=method, headers=hdrs) try: resp = opener.open(r, timeout=10) content = resp.read() if raw: return resp.status, content, dict(resp.headers) return resp.status, json.loads(content.decode() if content else "{}") except urllib.error.HTTPError as e: content = e.read() if raw: return e.code, content, dict(e.headers) try: return e.code, json.loads(content.decode() if content else "{}") except Exception: return e.code, {} except Exception as ex: return -1, {"error": str(ex)} print("═══ 1. 认证安全(多租户用户名+密码) ═══") # 未登录访问管理 API -> 401 st, d = req("GET", "/admin/api/tree") report("未登录访问管理API返回401", st == 401, f"(got {st})") st, d = req("POST", "/admin/api/notes") report("未登录创建笔记返回401", st == 401, f"(got {st})") st, d = req("POST", "/admin/notes") report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})") # 错误密码登录 st, d = req("POST", "/admin/login", "username=smoketest&password=wrongpass") report("错误密码登录返回401", st == 401, f"(got {st})") # 用随机用户名注册一个专属测试管理员(保证可重复运行) 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 不再有效 import urllib.request as u reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "note_token=authenticated"}) try: r = u.urlopen(reqf, timeout=10) st_old = r.status except urllib.error.HTTPError as e: st_old = e.code report("固定字符串cookie已失效(返回401)", st_old == 401, f"(got {st_old})") print("═══ 2. 笔记 CRUD + 版本历史 ═══") st, d = req("POST", "/admin/api/notes", { "title": "测试笔记A", "content": "第一版内容", "category": "测试", "tags": '["go","测试"]', "is_public": True }) note_id = d.get("data", {}).get("id") report("创建笔记成功", st == 200 and note_id, f"(got {st} {d})") st, d = req("PUT", f"/admin/api/notes/{note_id}", {"content": "第二版内容更新了"}) report("更新笔记成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", f"/admin/api/notes/{note_id}/versions") vers = d.get("data", []) report("版本历史记录>=2", st == 200 and len(vers) >= 2, f"(got {len(vers)})") # 回滚到第一版 old_ver = vers[-1] st, d = req("POST", f"/admin/api/notes/{note_id}/restore-version", "version_id=%d" % old_ver["id"]) report("恢复历史版本成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", f"/admin/api/notes/{note_id}") report("恢复后内容为第一版", d.get("data", {}).get("content") == "第一版内容", f"(got {d.get('data',{}).get('content')})") print("═══ 3. 安全:公开接口不泄露带密码笔记 ═══") # 创建一个带密码的公开笔记 st, d = req("POST", "/admin/api/notes", { "title": "受保护笔记", "content": "秘密内容", "is_public": True, "password": "secret123" }) protected_id = d.get("data", {}).get("id") report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})") # 登出后(游客身份)通过公开接口 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})") # 密码验证接口(公开,游客)应能正确返回 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})") # 公开无密码笔记(游客) st, d = req("GET", f"/api/notes/{note_id}") report("游客公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})") # 重新登录回测试用户 req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"}) print("═══ 4. 回收站(软删除/恢复/清空) ═══") # 删除受保护笔记 -> 进入回收站 st, d = req("DELETE", f"/admin/api/notes/{protected_id}") report("软删除笔记成功", st == 200 and d.get("code") == 0, f"(got {st})") # 前台公开接口应查不到已删除笔记 st, d = req("GET", f"/api/notes/{protected_id}") report("已删除笔记公开接口404", st == 404, f"(got {st})") st, d = req("GET", "/admin/api/trash") trash = d.get("data", []) report("回收站包含已删笔记", any(t.get("id") == protected_id for t in trash), f"(got {[t.get('id') for t in trash]})") # 恢复 st, d = req("POST", f"/admin/api/restore/{protected_id}") report("从回收站恢复成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", "/admin/api/trash") trash = d.get("data", []) report("恢复后回收站不再包含该笔记", not any(t.get("id") == protected_id for t in trash)) st, d = req("GET", f"/api/notes/{protected_id}") report("恢复后可再次访问(仍带密码被拒)", st in (401, 403, 200), f"(got {st})") # 彻底删除测试 st, d = req("POST", "/admin/api/notes", {"title": "待彻底删除", "content": "x", "is_public": True}) tmp_id = d.get("data", {}).get("id") req("DELETE", f"/admin/api/notes/{tmp_id}") st, d = req("POST", f"/admin/api/purge/{tmp_id}") report("彻底删除成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", "/admin/api/trash") report("彻底删除后回收站无该记录", not any(t.get("id") == tmp_id for t in d.get("data", []))) print("═══ 5. 分享链接 ═══") # 给测试笔记A创建分享 st, d = req("POST", f"/admin/api/notes/{note_id}/share", "expire_hours=24") share_token = d.get("data", {}).get("share_token") report("创建分享链接成功", st == 200 and share_token, f"(got {st} {d})") # 通过分享链接访问 st, d = req("GET", f"/api/share/{share_token}") report("分享链接可公开访问", st == 200 and d.get("data", {}).get("id") == note_id, f"(got {st})") # 分享阅读页 st, raw, _ = req("GET", f"/share/{share_token}", raw=True) report("分享阅读页返回HTML", st == 200 and b"alert(1)" # 用 multipart 上传 boundary = "----WebKitFormBoundary" + uuid.uuid4().hex body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="image"; filename="evil.png"\r\n' f"Content-Type: image/png\r\n\r\n" ).encode() + fake_bytes + f"\r\n--{boundary}--\r\n".encode() st, d = req("POST", "/admin/api/upload", data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, raw=False) report("伪造图片(非真实图片)被拒", st in (400, 500), f"(got {st} {d})") print("═══ 8. 列表/分类/标签/树 ═══") st, d = req("GET", "/api/notes") report("公开列表接口正常", st == 200 and d.get("code") == 0) st, d = req("GET", "/api/categories") report("分类列表正常", st == 200 and "测试" in d.get("data", [])) st, d = req("GET", "/api/tags") report("标签列表正常", st == 200) st, d = req("GET", "/api/tree") report("公开树正常", st == 200) print("═══ 9. FTS5 全文搜索 ═══") # 创建一篇含中文与英文的笔记用于搜索测试 st, d = req("POST", "/admin/api/notes", { "title": "搜索引擎优化笔记", "content": "这是一篇关于数据库索引与搜索引擎的笔记 涉及中文分词 ASP.NET", "category": "技术", "tags": '["全文","优化"]', "is_public": True }) fts_id = d.get("data", {}).get("id") report("创建搜索测试笔记", st == 200 and fts_id, f"(got {st})") # 中文短词(1~2字)搜索 q_enc = urllib.parse.urlencode({"q": "数据库"}) st, d = req("GET", f"/api/notes/search?{q_enc}") hits = [x.get("title") for x in (d.get("data") or [])] report("中文短词搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})") # 英文搜索 q_enc = urllib.parse.urlencode({"q": "ASP.NET"}) st, d = req("GET", f"/api/notes/search?{q_enc}") hits = [x.get("title") for x in (d.get("data") or [])] report("英文搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})") # 无结果关键词 q_enc = urllib.parse.urlencode({"q": "不存在的关键词XYZ"}) st, d = req("GET", f"/api/notes/search?{q_enc}") report("无匹配关键词返回空", st == 200 and d.get("total", 0) == 0, f"(got {d.get('total')})") print("═══ 10. 双向链接 / 知识图谱 ═══") # 创建两篇互相/单向链接的笔记 st, da = req("POST", "/admin/api/notes", { "title": "链接源A", "content": f"参考 [[搜索引擎优化笔记]] 和 [[链接目标B]]", "is_public": True }) linkA_id = da.get("data", {}).get("id") st, db_ = req("POST", "/admin/api/notes", { "title": "链接目标B", "content": "内容B", "is_public": True }) linkB_id = db_.get("data", {}).get("id") report("创建链接笔记成功", st == 200 and linkA_id and linkB_id, f"(got {st})") # 反向链接:谁链接到了搜索引擎优化笔记(应为 链接源A) st, d = req("GET", f"/admin/api/notes/{fts_id}/backlinks") backlinks = [x.get("title") for x in (d.get("data") or [])] report("反向链接查询", st == 200 and "链接源A" in backlinks, f"(got {backlinks})") # 知识图谱:应包含节点,且存在 edges st, d = req("GET", "/admin/api/graph") g = d.get("data") or {} gnodes = g.get("nodes") or [] gedges = g.get("edges") or [] report("知识图谱返回节点", st == 200 and any(n.get("title") == "链接源A" for n in gnodes), f"(got {st})") report("知识图谱含链接边", st == 200 and len(gedges) >= 1, f"(got {len(gedges)})") print("═══ 11. 自动保存草稿 ═══") st, d = req("POST", f"/admin/api/notes/{fts_id}/draft", {"content": "这是未保存的草稿"}) report("保存草稿成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", f"/admin/api/notes/{fts_id}") report("读取到草稿字段", d.get("data", {}).get("draft_content") == "这是未保存的草稿", f"(got {d.get('data',{}).get('draft_content')})") st, d = req("DELETE", f"/admin/api/notes/{fts_id}/draft") report("清除草稿成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", f"/admin/api/notes/{fts_id}") report("清除后草稿为空", d.get("data", {}).get("draft_content", "") == "", f"(got {d.get('data',{}).get('draft_content')})") print("═══ 12. 标签管理 ═══") # 标签使用统计应包含 test 链接笔记的标签 st, d = req("GET", "/admin/api/tags/usage") usage = {u.get("name"): u.get("count") for u in (d.get("data") or [])} report("标签使用统计接口", st == 200 and isinstance(d.get("data"), list), f"(got {st})") # 重命名标签:给链接源A加个标签再重命名 st, d = req("PUT", f"/admin/api/notes/{linkA_id}", {"tags": '["临时标签","保留"]'}) report("为链接笔记设置标签", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("POST", "/admin/api/tags/rename", {"old_name": "临时标签", "new_name": "已重命名"}) report("重命名标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})") st, d = req("GET", f"/admin/api/notes/{linkA_id}") tag_str = d.get("data", {}).get("tags", "") report("重命名后笔记标签同步", "已重命名" in tag_str and "临时标签" not in tag_str, f"(got {tag_str})") # 合并标签 st, d = req("POST", "/admin/api/tags/merge", {"from": "保留", "to": "已重命名"}) report("合并标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})") st, d = req("GET", f"/admin/api/notes/{linkA_id}") tag_str = d.get("data", {}).get("tags", "") report("合并后标签去重", "已重命名" in tag_str and "保留" not in tag_str, f"(got {tag_str})") # 删除标签 st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"}) report("删除标签", st == 200, f"(got {st})") # ═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══ print("\n═══ 12b. 多租户数据隔离 + 管理员跨租户管理 ═══") # 平台管理员账号(首个注册的 admin,prod 已存在 admin/admin123) ADMINU = "admin" ADMINP = "admin123" # 切到管理员 st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) admin_ok = st == 200 and d.get("code") == 0 report("管理员账号可登录", admin_ok, f"(got {st} {d})") if not admin_ok: # 若无 admin 账号,则首个用户即管理员;这里直接注册一个新管理员作为降级 st, d = req("POST", "/api/auth/register", {"username": "_adm_" + _uuid.uuid4().hex[:6], "password": "adm12345"}) report("(降级)注册新管理员", st == 200 and d.get("code") == 0, f"(got {st} {d})") # 注册第二个普通用户 ISOUSER = "iso_" + _uuid.uuid4().hex[:8] st, d = req("POST", "/api/auth/register", {"username": ISOUSER, "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})") # 第二用户无权读取第一用户创建的笔记 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)})") # 第二用户创建一篇自己的私有笔记(供管理员跨租户操作) st, d = req("POST", "/admin/api/notes", {"title": "第二用户的私有笔记", "content": "only visible to owner + admin", "is_public": False}) iso_note_id = d.get("data", {}).get("id") report("第二用户创建私有笔记", st == 200 and iso_note_id, f"(got {st} {d})") # 管理员跨租户管理:管理员应能读取第二用户的私有笔记 st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) report("切回管理员成功", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("GET", f"/admin/api/notes/{iso_note_id}") report("管理员可读取他人(第二用户)笔记", st == 200 and d.get("code") == 0 and d.get("data", {}).get("title") == "第二用户的私有笔记", f"(got {st} {d})") # 管理员可更新他人笔记(收藏) st, d = req("PUT", f"/admin/api/notes/{iso_note_id}", {"is_favorite": True}) report("管理员可修改他人笔记", st == 200 and d.get("code") == 0, f"(got {st})") # 管理员树包含第二用户的笔记(全平台视角) st, d = req("GET", "/admin/api/tree") admin_all = {x["id"] for x in d.get("data", [])} report("管理员树含第二用户笔记(全平台)", iso_note_id in admin_all, f"(got {iso_note_id} in {admin_all})") # 游客公开接口能读取公开笔记(跨租户展示,需登出为游客) 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})") # ═══ 12c. 注册开关 ═══ print("\n═══ 12c. 注册开关 ═══") # 切回管理员 st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) report("切回管理员(开关注册测试)", st == 200 and d.get("code") == 0, f"(got {st})") # 普通用户无权限修改注册开关 st, d = req("POST", "/api/auth/login", {"username": ISOUSER, "password": "iso12345"}) report("切到普通用户", st == 200 and d.get("code") == 0, f"(got {st})") st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False}) report("普通用户无权改注册开关(403)", st == 403, f"(got {st})") # 管理员关闭注册 st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) st, d = req("PUT", "/admin/api/settings/registration", {"enabled": False}) report("管理员关闭注册", st == 200 and d.get("data", {}).get("registration_enabled") == False, f"(got {st} {d})") # 关闭后再注册应被拒 st, d = req("POST", "/api/auth/register", {"username": "blocked_" + _uuid.uuid4().hex[:6], "password": "pass12345"}) report("注册关闭后被拒绝", st == 400, f"(got {st} {d})") # 管理员重新开启注册 st, d = req("PUT", "/admin/api/settings/registration", {"enabled": True}) report("管理员开启注册", st == 200 and d.get("data", {}).get("registration_enabled") == True, f"(got {st} {d})") # 重新切回第一个测试用户用于清理并删除第二用户的笔记 st, d = req("POST", "/api/auth/login", {"username": TUSER, "password": "smoke123"}) report("切回测试用户", st == 200 and d.get("code") == 0, f"(got {st})") # 管理员清理第二用户笔记前,先切回管理员 st, d = req("POST", "/api/auth/login", {"username": ADMINU, "password": ADMINP}) st, d = req("POST", f"/admin/api/purge/{iso_note_id}") report("清理第二用户笔记", st in (200, 500), 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] for tid in test_ids: if tid: # 先尝试恢复到非删除态再彻底删除(若在回收站) req("POST", f"/admin/api/restore/{tid}") st, d = req("POST", f"/admin/api/purge/{tid}") # 若返回 500(记录可能已在测试中途被彻底删除),视为已清理成功 ok = st == 200 or st == 500 report(f"清理测试笔记 id={tid}", ok, f"(got {st})") report("测试数据清理完成", True) print(f"\n════ 结果:{PASSED} 通过,{FAILED} 失败 ════") sys.exit(0 if FAILED == 0 else 1)