Fix wave 2: CORS default, path traversal, delete UI, shared clients, bucket check
- Default CORS_ORIGINS to http://localhost:5173 instead of "*" (still overridable) - Reject ".." and leading "/" in document keys/paths across get/save/delete/upload, mapped to HTTP 400 via new InvalidPathError - Add a delete button to DocumentEditor wired to deleteDocument + onDeleted, giving the existing DELETE API an actual UI entry point - Share a single lru_cache'd Meilisearch (and Minio) SDK client instance instead of constructing duplicates in dependencies.py - Add a startup check that creates the MinIO bucket if missing, tolerating MinIO being unreachable at boot without crashing the app
This commit is contained in:
parent
911d814a2f
commit
9ef22f9f0a
@ -10,7 +10,7 @@ class Settings:
|
|||||||
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
||||||
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
||||||
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
||||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
|
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -10,20 +10,27 @@ from app.search_client import SearchClient
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_document_service() -> DocumentService:
|
def _get_minio_sdk() -> Minio:
|
||||||
minio_sdk = Minio(
|
return Minio(
|
||||||
settings.minio_endpoint,
|
settings.minio_endpoint,
|
||||||
access_key=settings.minio_access_key,
|
access_key=settings.minio_access_key,
|
||||||
secret_key=settings.minio_secret_key,
|
secret_key=settings.minio_secret_key,
|
||||||
secure=False,
|
secure=False,
|
||||||
)
|
)
|
||||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
|
||||||
minio_client = MinioClient(minio_sdk, settings.minio_bucket)
|
|
||||||
search_client = SearchClient(meili_sdk, settings.meilisearch_index)
|
@lru_cache
|
||||||
|
def _get_meilisearch_sdk() -> meilisearch.Client:
|
||||||
|
return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_document_service() -> DocumentService:
|
||||||
|
minio_client = MinioClient(_get_minio_sdk(), settings.minio_bucket)
|
||||||
|
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||||
return DocumentService(minio_client, search_client)
|
return DocumentService(minio_client, search_client)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_search_client() -> SearchClient:
|
def get_search_client() -> SearchClient:
|
||||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||||
return SearchClient(meili_sdk, settings.meilisearch_index)
|
|
||||||
|
|||||||
@ -26,6 +26,17 @@ class FileTooLargeError(Exception):
|
|||||||
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPathError(Exception):
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
super().__init__(f"invalid path: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_path(path: str) -> None:
|
||||||
|
if path.startswith("/") or any(segment == ".." for segment in path.split("/")):
|
||||||
|
raise InvalidPathError(path)
|
||||||
|
|
||||||
|
|
||||||
def _extension_of(key: str) -> str:
|
def _extension_of(key: str) -> str:
|
||||||
name = key.rsplit("/", 1)[-1]
|
name = key.rsplit("/", 1)[-1]
|
||||||
if "." not in name:
|
if "." not in name:
|
||||||
@ -68,6 +79,7 @@ class DocumentService:
|
|||||||
return {"folders": folders, "files": files}
|
return {"folders": folders, "files": files}
|
||||||
|
|
||||||
def get_document(self, key: str) -> dict:
|
def get_document(self, key: str) -> dict:
|
||||||
|
_validate_path(key)
|
||||||
try:
|
try:
|
||||||
data = self._minio_client.get_object(key)
|
data = self._minio_client.get_object(key)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
@ -94,6 +106,7 @@ class DocumentService:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
||||||
|
_validate_path(key)
|
||||||
self._minio_client.put_object(key, content, content_type)
|
self._minio_client.put_object(key, content, content_type)
|
||||||
try:
|
try:
|
||||||
text = self._extract_text(content, content_type)
|
text = self._extract_text(content, content_type)
|
||||||
@ -105,6 +118,7 @@ class DocumentService:
|
|||||||
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
||||||
|
|
||||||
def delete_document(self, key: str) -> None:
|
def delete_document(self, key: str) -> None:
|
||||||
|
_validate_path(key)
|
||||||
self._minio_client.delete_object(key)
|
self._minio_client.delete_object(key)
|
||||||
try:
|
try:
|
||||||
self._search_client.delete_document(key)
|
self._search_client.delete_document(key)
|
||||||
@ -112,6 +126,7 @@ class DocumentService:
|
|||||||
logger.exception("Failed to delete search index entry for %s", key)
|
logger.exception("Failed to delete search index entry for %s", key)
|
||||||
|
|
||||||
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
||||||
|
_validate_path(key)
|
||||||
extension = _extension_of(key)
|
extension = _extension_of(key)
|
||||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||||
raise UnsupportedFileTypeError(extension)
|
raise UnsupportedFileTypeError(extension)
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.dependencies import _get_minio_sdk
|
||||||
from app.routers import documents, search
|
from app.routers import documents, search
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="DocHub API")
|
app = FastAPI(title="DocHub API")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@ -18,6 +23,16 @@ app.include_router(documents.router)
|
|||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def ensure_bucket_exists():
|
||||||
|
try:
|
||||||
|
minio_sdk = _get_minio_sdk()
|
||||||
|
if not minio_sdk.bucket_exists(settings.minio_bucket):
|
||||||
|
minio_sdk.make_bucket(settings.minio_bucket)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not verify/create MinIO bucket '%s' on startup", settings.minio_bucket, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from app.document_service import (
|
|||||||
DocumentNotFoundError,
|
DocumentNotFoundError,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
FileTooLargeError,
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
|
|||||||
return service.get_document(key)
|
return service.get_document(key)
|
||||||
except DocumentNotFoundError:
|
except DocumentNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/documents/{key:path}")
|
@router.put("/api/documents/{key:path}")
|
||||||
def save_document(
|
def save_document(
|
||||||
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/documents/{key:path}", status_code=204)
|
@router.delete("/api/documents/{key:path}", status_code=204)
|
||||||
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||||||
|
try:
|
||||||
service.delete_document(key)
|
service.delete_document(key)
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -54,5 +63,5 @@ async def upload_document(
|
|||||||
content_type = file.content_type or "application/octet-stream"
|
content_type = file.content_type or "application/octet-stream"
|
||||||
try:
|
try:
|
||||||
return service.upload_document(key, data, content_type)
|
return service.upload_document(key, data, content_type)
|
||||||
except (UnsupportedFileTypeError, FileTooLargeError) as exc:
|
except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
17
backend/tests/test_config.py
Normal file
17
backend/tests/test_config.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_origins_defaults_to_localhost_dev_port(monkeypatch):
|
||||||
|
monkeypatch.delenv("CORS_ORIGINS", raising=False)
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_origins_can_be_overridden_to_wildcard(monkeypatch):
|
||||||
|
monkeypatch.setenv("CORS_ORIGINS", "*")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["*"]
|
||||||
@ -4,6 +4,7 @@ from app.document_service import (
|
|||||||
DocumentService,
|
DocumentService,
|
||||||
DocumentNotFoundError,
|
DocumentNotFoundError,
|
||||||
FileTooLargeError,
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
MAX_UPLOAD_SIZE_BYTES,
|
MAX_UPLOAD_SIZE_BYTES,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
@ -196,6 +197,40 @@ def test_upload_document_rejects_oversized_file():
|
|||||||
assert "产品文档/大文件.md" not in minio_client.stored
|
assert "产品文档/大文件.md" not in minio_client.stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_rejects_path_traversal_key():
|
||||||
|
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.get_document("../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_document_rejects_path_traversal_key():
|
||||||
|
minio_client = FakeMinioClient()
|
||||||
|
service = DocumentService(minio_client, FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.save_document("../../etc/passwd", b"data", "text/plain")
|
||||||
|
|
||||||
|
assert "../../etc/passwd" not in minio_client.stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_document_rejects_path_traversal_key():
|
||||||
|
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.delete_document("../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_document_rejects_path_traversal_key():
|
||||||
|
minio_client = FakeMinioClient()
|
||||||
|
service = DocumentService(minio_client, FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.upload_document("../../etc/passwd", b"data", "text/plain")
|
||||||
|
|
||||||
|
assert "../../etc/passwd" not in minio_client.stored
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_accepts_allowed_image_extension():
|
def test_upload_document_accepts_allowed_image_extension():
|
||||||
minio_client = FakeMinioClient()
|
minio_client = FakeMinioClient()
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(minio_client, FakeSearchClient())
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main_module
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.document_service import DocumentNotFoundError
|
from app.document_service import DocumentNotFoundError
|
||||||
from app.dependencies import get_document_service, get_search_client
|
from app.dependencies import get_document_service, get_search_client
|
||||||
@ -14,6 +17,40 @@ def test_health_returns_ok():
|
|||||||
assert response.json() == {"status": "ok"}
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_creates_bucket_when_missing(monkeypatch):
|
||||||
|
fake_minio = MagicMock()
|
||||||
|
fake_minio.bucket_exists.return_value = False
|
||||||
|
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio)
|
||||||
|
|
||||||
|
with TestClient(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_minio.bucket_exists.assert_called()
|
||||||
|
fake_minio.make_bucket.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch):
|
||||||
|
fake_minio = MagicMock()
|
||||||
|
fake_minio.bucket_exists.return_value = True
|
||||||
|
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio)
|
||||||
|
|
||||||
|
with TestClient(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_minio.make_bucket.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_does_not_crash_app_when_minio_is_unreachable(monkeypatch):
|
||||||
|
def raise_connection_error():
|
||||||
|
raise ConnectionError("minio unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_module, "_get_minio_sdk", raise_connection_error)
|
||||||
|
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
response = test_client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
class FakeDocumentService:
|
class FakeDocumentService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.storage = {}
|
self.storage = {}
|
||||||
|
|||||||
@ -3,7 +3,12 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.dependencies import get_document_service
|
from app.dependencies import get_document_service
|
||||||
from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError
|
from app.document_service import (
|
||||||
|
DocumentNotFoundError,
|
||||||
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
|
UnsupportedFileTypeError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeDocumentService:
|
class FakeDocumentService:
|
||||||
@ -23,16 +28,24 @@ class FakeDocumentService:
|
|||||||
def get_document(self, key):
|
def get_document(self, key):
|
||||||
if key == "不存在/文档.md":
|
if key == "不存在/文档.md":
|
||||||
raise DocumentNotFoundError(key)
|
raise DocumentNotFoundError(key)
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
||||||
|
|
||||||
def save_document(self, key, content, content_type):
|
def save_document(self, key, content, content_type):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
self.saved.append((key, content, content_type))
|
self.saved.append((key, content, content_type))
|
||||||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||||||
|
|
||||||
def delete_document(self, key):
|
def delete_document(self, key):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
self.deleted.append(key)
|
self.deleted.append(key)
|
||||||
|
|
||||||
def upload_document(self, key, data, content_type):
|
def upload_document(self, key, data, content_type):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
if key.endswith(".exe"):
|
if key.endswith(".exe"):
|
||||||
raise UnsupportedFileTypeError(".exe")
|
raise UnsupportedFileTypeError(".exe")
|
||||||
if len(data) > 1024:
|
if len(data) > 1024:
|
||||||
@ -119,3 +132,30 @@ def test_upload_document_returns_400_for_oversized_file(client):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "file too large" in response.json()["detail"]
|
assert "file too large" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/documents/..%2F..%2Fetc%2Fpasswd",
|
||||||
|
json={"content": "x", "contentType": "text/plain"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_document_returns_400_for_path_traversal_in_path(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/upload",
|
||||||
|
data={"path": "../../etc"},
|
||||||
|
files={"file": ("passwd.md", b"data", "text/markdown")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|||||||
@ -54,6 +54,11 @@ export default function App() {
|
|||||||
setRefreshKey((prev) => prev + 1);
|
setRefreshKey((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeleted() {
|
||||||
|
setSelectedKey(null);
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
@ -67,7 +72,7 @@ export default function App() {
|
|||||||
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
||||||
<div className="main" data-testid="main-area">
|
<div className="main" data-testid="main-area">
|
||||||
{selectedKey ? (
|
{selectedKey ? (
|
||||||
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} />
|
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} onDeleted={handleDeleted} />
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state" data-testid="empty-state">
|
<div className="empty-state" data-testid="empty-state">
|
||||||
未选择文档
|
未选择文档
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { getDocument, saveDocument } from '../api/client';
|
import { deleteDocument, getDocument, saveDocument } from '../api/client';
|
||||||
|
|
||||||
interface DocumentEditorProps {
|
interface DocumentEditorProps {
|
||||||
documentKey: string;
|
documentKey: string;
|
||||||
onDirtyChange?: (isDirty: boolean) => void;
|
onDirtyChange?: (isDirty: boolean) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) {
|
export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: DocumentEditorProps) {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [originalContent, setOriginalContent] = useState('');
|
const [originalContent, setOriginalContent] = useState('');
|
||||||
const [contentType, setContentType] = useState('text/markdown');
|
const [contentType, setContentType] = useState('text/markdown');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -57,6 +60,23 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
deleteDocument(documentKey)
|
||||||
|
.then(() => {
|
||||||
|
setDeleting(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setDeleteError('删除失败,请重试');
|
||||||
|
setDeleting(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||||
}
|
}
|
||||||
@ -84,6 +104,17 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{deleteError && (
|
||||||
|
<span className="save-error" data-testid="delete-error">
|
||||||
|
{deleteError}
|
||||||
|
<button onClick={() => setDeleteError(null)} aria-label="关闭错误提示">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting ? '删除中…' : '删除'}
|
||||||
|
</button>
|
||||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { vi, test, expect, beforeEach } from 'vitest';
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||||
import { getDocument, saveDocument } from '../../src/api/client';
|
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
||||||
|
|
||||||
vi.mock('../../src/api/client');
|
vi.mock('../../src/api/client');
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDocument).mockReset();
|
vi.mocked(getDocument).mockReset();
|
||||||
vi.mocked(saveDocument).mockReset();
|
vi.mocked(saveDocument).mockReset();
|
||||||
|
vi.mocked(deleteDocument).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows a loading state, then loads and displays document content', async () => {
|
test('shows a loading state, then loads and displays document content', async () => {
|
||||||
@ -27,7 +28,7 @@ test('shows a loading state, then loads and displays document content', async ()
|
|||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
|||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
await user.clear(textarea);
|
await user.clear(textarea);
|
||||||
@ -84,7 +85,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
|
|||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
await user.clear(textarea);
|
await user.clear(textarea);
|
||||||
@ -117,7 +118,7 @@ test('calls onDirtyChange when dirty state changes', async () => {
|
|||||||
const onDirtyChange = vi.fn();
|
const onDirtyChange = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
|
|
||||||
@ -131,3 +132,72 @@ test('calls onDirtyChange when dirty state changes', async () => {
|
|||||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onDeleted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking delete does nothing if the confirmation is declined', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
expect(deleteDocument).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an inline error and does not call onDeleted when delete fails', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(onDeleted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user