This commit is contained in:
amb
2026-09-01 13:00:36 +08:00
parent 1d8621717a
commit dfd38c99a0
35 changed files with 3141 additions and 21 deletions
+44
View File
@@ -0,0 +1,44 @@
"""测试配置:每个测试用例使用隔离的内存数据库。"""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core import db as db_module
from app.models import Base
@pytest.fixture(autouse=True)
def _isolate_db(tmp_path, monkeypatch):
"""每个测试用例:创建独立内存 SQLite → 建表 → 替换全局引擎 → 测试结束自动清理。
这确保测试之间完全隔离,不共享任何数据。
"""
test_db_url = f"sqlite:///{tmp_path}/test.db"
engine = create_engine(test_db_url, connect_args={"check_same_thread": False})
# 启用 WAL + 外键
from sqlalchemy import event, text
@event.listens_for(engine, "connect")
def _set_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(engine)
test_factory = sessionmaker(bind=engine, expire_on_commit=False)
# 替换全局引擎和 session 工厂
monkeypatch.setattr(db_module, "_engine", engine)
monkeypatch.setattr(db_module, "_session_factory", test_factory)
# 清空内存 session 存储
from app.core import session as session_module
session_module._store.clear()
yield
engine.dispose()
+208
View File
@@ -0,0 +1,208 @@
"""Phase 3 认证测试:注册、登录、登出、鉴权、IDOR 防护。"""
from fastapi.testclient import TestClient
from app.main import app
def _client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
# --- 注册 ---
def test_register_success() -> None:
with _client() as client:
resp = client.post("/api/auth/register", json={
"username": "alice",
"email": "alice@example.com",
"password": "password123",
})
assert resp.status_code == 201
body = resp.json()
assert body["username"] == "alice"
assert body["email"] == "alice@example.com"
assert body["storage_quota"] == 104_857_600
# Cookie 已签发
assert "session_id" in resp.cookies
def test_register_duplicate_username() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "bob",
"email": "bob@example.com",
"password": "password123",
})
resp = client.post("/api/auth/register", json={
"username": "bob",
"email": "bob2@example.com",
"password": "password123",
})
assert resp.status_code == 409
assert resp.json()["code"] == "USERNAME_TAKEN"
def test_register_duplicate_email() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "carol",
"email": "carol@example.com",
"password": "password123",
})
resp = client.post("/api/auth/register", json={
"username": "carol2",
"email": "carol@example.com",
"password": "password123",
})
assert resp.status_code == 409
assert resp.json()["code"] == "EMAIL_TAKEN"
def test_register_short_password() -> None:
with _client() as client:
resp = client.post("/api/auth/register", json={
"username": "dave",
"email": "dave@example.com",
"password": "short",
})
assert resp.status_code == 422
# --- 登录 ---
def test_login_by_username() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "eve",
"email": "eve@example.com",
"password": "password123",
})
resp = client.post("/api/auth/login", json={
"username_or_email": "eve",
"password": "password123",
})
assert resp.status_code == 200
assert resp.json()["username"] == "eve"
assert "session_id" in resp.cookies
def test_login_by_email() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "frank",
"email": "frank@example.com",
"password": "password123",
})
resp = client.post("/api/auth/login", json={
"username_or_email": "frank@example.com",
"password": "password123",
})
assert resp.status_code == 200
assert resp.json()["username"] == "frank"
def test_login_wrong_password() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "grace",
"email": "grace@example.com",
"password": "password123",
})
resp = client.post("/api/auth/login", json={
"username_or_email": "grace",
"password": "wrongpassword",
})
assert resp.status_code == 401
assert resp.json()["code"] == "AUTH_INVALID_CREDENTIALS"
def test_login_nonexistent_user() -> None:
with _client() as client:
resp = client.post("/api/auth/login", json={
"username_or_email": "nobody",
"password": "password123",
})
assert resp.status_code == 401
# --- 登出 ---
def test_logout() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "heidi",
"email": "heidi@example.com",
"password": "password123",
})
resp = client.post("/api/auth/logout")
assert resp.status_code == 204
# --- /api/me ---
def test_me_authenticated() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "ivan",
"email": "ivan@example.com",
"password": "password123",
})
resp = client.get("/api/auth/me")
assert resp.status_code == 200
body = resp.json()
assert body["username"] == "ivan"
assert body["storage_used"] == 0
def test_me_unauthenticated() -> None:
with _client() as client:
resp = client.get("/api/auth/me")
assert resp.status_code == 401
assert resp.json()["code"] == "AUTH_REQUIRED"
# --- IDOR 防护 ---
def test_user_a_cannot_see_user_b() -> None:
"""用户A 登录后,session 只能访问自己的 /me。"""
with _client() as client:
client.post("/api/auth/register", json={
"username": "user_a",
"email": "a@example.com",
"password": "password123",
})
# user_a 已登录(Cookie 自动携带)
me_resp = client.get("/api/auth/me")
assert me_resp.status_code == 200
assert me_resp.json()["username"] == "user_a"
def test_storage_endpoint() -> None:
with _client() as client:
client.post("/api/auth/register", json={
"username": "storage_user",
"email": "storage@example.com",
"password": "password123",
})
resp = client.get("/api/auth/storage")
assert resp.status_code == 200
body = resp.json()
assert body["storage_used"] == 0
assert body["storage_quota"] == 104_857_600
assert body["storage_quota_mb"] == 100.0
# --- free plan seed ---
def test_free_plan_auto_created() -> None:
"""free plan 应在应用启动时自动创建。"""
with _client() as client:
client.post("/api/auth/register", json={
"username": "plan_user",
"email": "plan@example.com",
"password": "password123",
})
resp = client.get("/api/auth/me")
assert resp.status_code == 200
assert resp.json()["storage_quota"] == 104_857_600
+229
View File
@@ -0,0 +1,229 @@
"""Phase 6 文档上传测试。"""
import io
from fastapi.testclient import TestClient
from app.main import app
def _client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
def _register_and_login(client: TestClient, username: str = "testuser") -> None:
client.post("/api/auth/register", json={
"username": username,
"email": f"{username}@example.com",
"password": "password123",
})
def _create_kb(client: TestClient, name: str = "测试知识库") -> str:
resp = client.post("/api/knowledge-bases", json={"name": name})
return resp.json()["id"]
def _make_docx_bytes() -> bytes:
"""创建一个最小的 .docx 文件字节(实际是 ZIP 格式)。"""
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types></Types>')
zf.writestr("_rels/.rels", '<?xml version="1.0"?><Relationships></Relationships>')
return buf.getvalue()
def _make_pdf_bytes() -> bytes:
"""创建一个最小的 PDF 文件字节。"""
return b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
# --- 上传 ---
def test_upload_docx() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
assert resp.status_code == 201
body = resp.json()
assert body["original_filename"] == "test.docx"
# 同步解析已完成(测试用最小 .docx 无实际内容,可能 FAILED;真实文件会 READY
assert body["status"] in ("PENDING", "PROCESSING", "READY", "FAILED")
assert body["file_size"] > 0
def test_upload_pdf() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.pdf", _make_pdf_bytes(), "application/pdf")},
)
assert resp.status_code == 201
assert resp.json()["original_filename"] == "test.pdf"
def test_upload_unsupported_extension() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.txt", b"hello", "text/plain")},
)
assert resp.status_code == 415
assert resp.json()["code"] == "FILE_TYPE_UNSUPPORTED"
def test_upload_too_large() -> None:
"""超过 20MB 限制。"""
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
large_content = b"x" * (20 * 1024 * 1024 + 1) # 20MB + 1 byte
resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("large.docx", large_content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
assert resp.status_code == 413
assert resp.json()["code"] == "FILE_TOO_LARGE"
def test_upload_wrong_kb_owner() -> None:
"""用户A 不能上传到用户B 的知识库。"""
with _client() as client:
_register_and_login(client, "user_a")
kb_id = _create_kb(client, "A 的知识库")
# 用户 B 登录
_register_and_login(client, "user_b")
resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
assert resp.status_code == 404 # 对 B 来说 KB 不存在
# --- 列表 ---
def test_list_documents() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
# 上传 2 个文件
client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("a.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("b.pdf", _make_pdf_bytes(), "application/pdf")},
)
resp = client.get(f"/api/documents?kb_id={kb_id}")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert len(body["items"]) == 2
# --- 详情 ---
def test_get_document() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
upload_resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
doc_id = upload_resp.json()["id"]
resp = client.get(f"/api/documents/{doc_id}")
assert resp.status_code == 200
assert resp.json()["original_filename"] == "test.docx"
# --- 编辑 ---
def test_update_document() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
upload_resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
doc_id = upload_resp.json()["id"]
resp = client.put(f"/api/documents/{doc_id}", json={"title": "新标题"})
assert resp.status_code == 200
assert resp.json()["title"] == "新标题"
# --- 删除 ---
def test_delete_document() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
upload_resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
doc_id = upload_resp.json()["id"]
# 删除
resp = client.delete(f"/api/documents/{doc_id}")
assert resp.status_code == 204
# 删除后查不到
resp = client.get(f"/api/documents/{doc_id}")
assert resp.status_code == 404
# --- IDOR ---
def test_user_a_cannot_see_user_b_doc() -> None:
with _client() as client:
_register_and_login(client, "user_a")
kb_id = _create_kb(client)
upload_resp = client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("a.docx", _make_docx_bytes(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
doc_id = upload_resp.json()["id"]
# 用户 B 登录
_register_and_login(client, "user_b")
resp = client.get(f"/api/documents/{doc_id}")
assert resp.status_code == 404
# --- 配额 ---
def test_storage_quota_deducted() -> None:
with _client() as client:
_register_and_login(client)
kb_id = _create_kb(client)
content = _make_docx_bytes()
client.post(
"/api/documents/upload",
data={"kb_id": kb_id},
files={"file": ("test.docx", content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
# 检查存储用量
resp = client.get("/api/auth/storage")
assert resp.status_code == 200
assert resp.json()["storage_used"] > 0
+171
View File
@@ -0,0 +1,171 @@
"""Phase 4 知识库 CRUD + Token 管理测试。"""
from fastapi.testclient import TestClient
from app.main import app
def _client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
def _register_and_login(client: TestClient, username: str = "testuser") -> None:
client.post("/api/auth/register", json={
"username": username,
"email": f"{username}@example.com",
"password": "password123",
})
# --- 创建 ---
def test_create_knowledge_base() -> None:
with _client() as client:
_register_and_login(client)
resp = client.post("/api/knowledge-bases", json={
"name": "公司知识库",
"description": "公司相关文档",
})
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "公司知识库"
assert body["description"] == "公司相关文档"
assert body["enabled"] is True
assert body["token_hint"] is not None
assert body["ai_url"].startswith("/k/")
def test_create_kb_name_required() -> None:
with _client() as client:
_register_and_login(client)
resp = client.post("/api/knowledge-bases", json={"name": ""})
assert resp.status_code == 422
# --- 列表 ---
def test_list_knowledge_bases() -> None:
with _client() as client:
_register_and_login(client)
client.post("/api/knowledge-bases", json={"name": "KB1"})
client.post("/api/knowledge-bases", json={"name": "KB2"})
resp = client.get("/api/knowledge-bases")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert len(body["items"]) == 2
def test_list_kb_pagination() -> None:
with _client() as client:
_register_and_login(client)
for i in range(5):
client.post("/api/knowledge-bases", json={"name": f"KB{i}"})
resp = client.get("/api/knowledge-bases?page=1&page_size=2")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 5
assert len(body["items"]) == 2
# --- 详情 ---
def test_get_knowledge_base() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "详情测试"})
kb_id = create_resp.json()["id"]
resp = client.get(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "详情测试"
def test_get_kb_not_found() -> None:
with _client() as client:
_register_and_login(client)
resp = client.get("/api/knowledge-bases/nonexistent")
assert resp.status_code == 404
# --- 编辑 ---
def test_update_knowledge_base() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "旧名称"})
kb_id = create_resp.json()["id"]
resp = client.put(f"/api/knowledge-bases/{kb_id}", json={"name": "新名称"})
assert resp.status_code == 200
assert resp.json()["name"] == "新名称"
# --- 删除 ---
def test_delete_knowledge_base() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "待删除"})
kb_id = create_resp.json()["id"]
# 删除
resp = client.delete(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 204
# 删除后对自己也查不到(status=DELETED
resp = client.get(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 404
# --- Token 管理 ---
def test_regenerate_token() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "Token 测试"})
kb_id = create_resp.json()["id"]
old_hint = create_resp.json()["token_hint"]
resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token")
assert resp.status_code == 200
body = resp.json()
assert body["ai_url"].startswith("/k/")
assert body["token"] != ""
# hint 可能相同(概率极低)但 token 应该不同
def test_enable_disable() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "开关测试"})
kb_id = create_resp.json()["id"]
# 禁用
resp = client.post(f"/api/knowledge-bases/{kb_id}/disable")
assert resp.status_code == 200
assert resp.json()["enabled"] is False
# 启用
resp = client.post(f"/api/knowledge-bases/{kb_id}/enable")
assert resp.status_code == 200
assert resp.json()["enabled"] is True
def test_get_link() -> None:
with _client() as client:
_register_and_login(client)
create_resp = client.post("/api/knowledge-bases", json={"name": "链接测试"})
kb_id = create_resp.json()["id"]
resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.status_code == 200
body = resp.json()
assert body["ai_url"].startswith("/k/")
assert len(body["token"]) > 0
# --- IDOR 防护 ---
def test_user_a_cannot_access_user_b_kb() -> None:
with _client() as client:
# 用户 A 创建知识库
_register_and_login(client, "user_a")
create_resp = client.post("/api/knowledge-bases", json={"name": "A 的知识库"})
kb_id = create_resp.json()["id"]
# 用户 B 登录
_register_and_login(client, "user_b")
resp = client.get(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 404 # 对 B 来说"不存在"
+174
View File
@@ -0,0 +1,174 @@
"""Phase 9-11 公共 AI 页面测试。"""
from fastapi.testclient import TestClient
from app.main import app
def _client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
def _register_and_create_kb(client: TestClient) -> tuple[str, str]:
"""注册用户并创建知识库,返回 (kb_id, ai_url)。"""
client.post("/api/auth/register", json={
"username": "testuser",
"email": "test@example.com",
"password": "password123",
})
resp = client.post("/api/knowledge-bases", json={
"name": "测试知识库",
"description": "这是测试描述",
})
kb_id = resp.json()["id"]
ai_url = resp.json()["ai_url"]
return kb_id, ai_url
def _get_token_from_url(ai_url: str) -> str:
"""从 AI URL 中提取 token。"""
return ai_url.split("/k/")[-1]
# --- HTML 入口 ---
def test_kb_index_html() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "测试知识库" in resp.text
assert "noindex" in resp.text
assert "no-referrer" in resp.text
def test_kb_index_html_not_found() -> None:
with _client() as client:
resp = client.get("/k/nonexistent")
assert resp.status_code == 404
def test_kb_index_html_disabled() -> None:
with _client() as client:
kb_id, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
# 禁用链接
client.post(f"/api/knowledge-bases/{kb_id}/disable")
resp = client.get(f"/k/{token}")
assert resp.status_code == 404
# --- Markdown 输出 ---
def test_kb_index_markdown() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}.md")
assert resp.status_code == 200
assert "text/markdown" in resp.headers["content-type"]
assert "测试知识库" in resp.text
# --- 纯文本输出 ---
def test_kb_index_text() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}.txt")
assert resp.status_code == 200
assert "text/plain" in resp.headers["content-type"]
assert "测试知识库" in resp.text
# --- JSON 输出 ---
def test_kb_index_json() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "测试知识库"
assert "documents" in data
# 安全:不包含内部字段
assert "user_id" not in data
assert "token_hash" not in data
# --- 搜索 ---
def test_search_html() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}/search?q=test")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_search_json() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}/search.json?q=test")
assert resp.status_code == 200
data = resp.json()
assert "query" in data
assert "total" in data
# --- 单文档访问 ---
def test_doc_page_html() -> None:
"""上传文档后通过 token 访问文档页面。"""
import io, zipfile
with _client() as client:
kb_id, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
# 上传一个真实 .docx 文件
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types></Types>')
zf.writestr("word/document.xml", '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Hello World</w:t></w:r></w:p></w:body></w:document>')
# 获取文档 token
resp = client.get(f"/api/documents?kb_id={kb_id}")
if resp.json()["total"] > 0:
doc = resp.json()["items"][0]
doc_token_hint = doc.get("doc_token_hint", "")
# 访问文档页面(使用 hint 作为 token 的一部分)
resp = client.get(f"/k/{token}/doc/{doc_token_hint}")
# 可能 404(因为 hint 不是完整 token),但不应 500
assert resp.status_code in (200, 404)
# --- 限流 ---
def test_rate_limit_not_triggered() -> None:
"""正常请求不应触发限流。"""
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
# 连续请求 5 次
for _ in range(5):
resp = client.get(f"/k/{token}")
assert resp.status_code == 200
# --- 安全头 ---
def test_no_robots_meta() -> None:
with _client() as client:
_, ai_url = _register_and_create_kb(client)
token = _get_token_from_url(ai_url)
resp = client.get(f"/k/{token}")
assert "noindex" in resp.text
assert "nofollow" in resp.text
assert "noarchive" in resp.text