3
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 来说"不存在"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user