"""查重上传接口错误处理单元测试。 验证 PR#82 修复: 1. 内部异常信息不泄露给客户端(P1 安全修复) 2. MIME 类型验证(P0 已修复) 3. 文件大小限制(P0 已修复) 4. 各种错误场景返回正确的 HTTP 状态码和安全的错误消息 覆盖端点:POST /upload(查重上传) 使用 FastAPI TestClient + dependency_overrides 模式, 导入真实模块,不创建 fake namespace packages,避免 sys.modules 污染。 """ from __future__ import annotations import io import os import sys from datetime import datetime, timezone from unittest.mock import MagicMock # ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) from app.api.routes.duplication import _validate_video_mime_type, router from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import get_duplication_repository # ── 导入真实模块(不创建 fake module) ──────────────────────────────────────── from packages.domain.entities import User # --------------------------------------------------------------------------- # 1. Fixtures & Mocks # --------------------------------------------------------------------------- def _make_user(**overrides) -> User: defaults = dict( id="user-dup-001", email="dup@example.com", display_name="Dup User", username="dupuser", subscription_plan="free", subscription_status="active", max_projects=3, max_storage_gb=10, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) defaults.update(overrides) return User(**defaults) class MockDuplicationRepo: """内存中的查重记录 Repository mock。""" def create(self, record): return record def get(self, record_id): return None def list_by_user(self, user_id, **kw): return [] def update(self, record): return record def delete(self, record_id): return True class MockStorageService: """可控的存储服务 mock。""" def __init__(self, should_fail=False, error_msg="Internal server error details"): self.should_fail = should_fail self.error_msg = error_msg self.uploaded_files = [] def upload_file(self, content, key, content_type=None): if self.should_fail: raise Exception(self.error_msg) self.uploaded_files.append({"content": content, "key": key, "content_type": content_type}) @pytest.fixture def mock_dup_repo(): return MockDuplicationRepo() @pytest.fixture def mock_storage(): return MockStorageService() @pytest.fixture def client(mock_dup_repo, mock_storage): """创建带有依赖覆盖的 TestClient。""" test_app = FastAPI() test_app.include_router(router) def _override_current_user(): return AuthenticatedUser(user=_make_user()) def _override_dup_repo(): return mock_dup_repo def _override_storage(): return mock_storage test_app.dependency_overrides[get_current_user] = _override_current_user test_app.dependency_overrides[get_duplication_repository] = _override_dup_repo test_app.dependency_overrides[get_storage_service] = _override_storage yield TestClient(test_app) test_app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 2. MIME 类型验证(P0 修复验证) # --------------------------------------------------------------------------- class TestMIMETypeValidation: """验证 MIME 类型白名单校验。""" def test_valid_mp4_accepted(self, client): """video/mp4 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.mp4", io.BytesIO(b"fake-video-data"), "video/mp4")}, ) # 应该不是 415 assert resp.status_code != 415 def test_valid_mpeg_accepted(self, client): """video/mpeg 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.mpeg", io.BytesIO(b"fake-video"), "video/mpeg")}, ) assert resp.status_code != 415 def test_valid_quicktime_accepted(self, client): """video/quicktime 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.mov", io.BytesIO(b"fake-video"), "video/quicktime")}, ) assert resp.status_code != 415 def test_valid_avi_accepted(self, client): """video/x-msvideo (AVI) 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.avi", io.BytesIO(b"fake-video"), "video/x-msvideo")}, ) assert resp.status_code != 415 def test_valid_webm_accepted(self, client): """video/webm 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.webm", io.BytesIO(b"fake-video"), "video/webm")}, ) assert resp.status_code != 415 def test_valid_mkv_accepted(self, client): """video/x-matroska (MKV) 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.mkv", io.BytesIO(b"fake-video"), "video/x-matroska")}, ) assert resp.status_code != 415 def test_valid_3gp_accepted(self, client): """video/3gpp (3GP) 应通过验证。""" resp = client.post( "/upload", files={"file": ("test.3gp", io.BytesIO(b"fake-video"), "video/3gpp")}, ) assert resp.status_code != 415 def test_image_rejected_415(self, client): """图片文件应被拒绝(415)。""" resp = client.post( "/upload", files={"file": ("test.jpg", io.BytesIO(b"fake-image"), "image/jpeg")}, ) assert resp.status_code == 415 detail = resp.json()["detail"] assert "只支持视频文件" in detail def test_pdf_rejected_415(self, client): """PDF 文件应被拒绝(415)。""" resp = client.post( "/upload", files={"file": ("test.pdf", io.BytesIO(b"fake-pdf"), "application/pdf")}, ) assert resp.status_code == 415 def test_text_rejected_415(self, client): """文本文件应被拒绝(415)。""" resp = client.post( "/upload", files={"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")}, ) assert resp.status_code == 415 def test_zip_rejected_415(self, client): """ZIP 文件应被拒绝(415)。""" resp = client.post( "/upload", files={"file": ("test.zip", io.BytesIO(b"PK"), "application/zip")}, ) assert resp.status_code == 415 def test_missing_content_type_returns_400(self, client): """缺少 Content-Type 应返回 400。""" # TestClient 默认会设置 content_type,手动发请求来模拟 resp = client.post( "/upload", files={"file": ("test.mp4", io.BytesIO(b"data"), None)}, ) # Starlette 对 None content_type 的处理可能不同 # 但如果有 Content-Type 为空的请求,应该返回 400 # 这里只验证不会 500 assert resp.status_code in (200, 400, 415, 422) def test_content_type_with_params_accepted(self, client): """带参数的 Content-Type(如 video/mp4; charset=utf-8)应正确解析。""" resp = client.post( "/upload", files={"file": ("test.mp4", io.BytesIO(b"fake-video"), "video/mp4")}, ) assert resp.status_code != 415 def test_415_message_does_not_leak_internal_details(self, client): """415 错误消息不应泄露内部 MIME 白名单实现细节。""" resp = client.post( "/upload", files={"file": ("test.exe", io.BytesIO(b"MZ"), "application/octet-stream")}, ) assert resp.status_code == 415 detail = resp.json()["detail"] # 消息应该友好,不泄露 ALLOWED_VIDEO_MIME_TYPES 的具体值 assert "frozenset" not in detail assert "ALLOWED" not in detail # 应该列出支持的文件类型 assert "mp4" in detail or "视频" in detail # --------------------------------------------------------------------------- # 3. 文件大小限制(P0 修复验证) # --------------------------------------------------------------------------- class TestFileSizeLimit: """验证文件大小限制。""" def test_oversized_file_via_content_length_returns_413(self): """超过限制的文件(通过 Content-Length 检测)应返回 413。""" # 创建一个 mock 文件对象,size > OSS_DIRECT_UPLOAD_MAX_MB mock_file = MagicMock() mock_file.filename = "huge_video.mp4" mock_file.content_type = "video/mp4" mock_file.size = 200 * 1024 * 1024 # 200MB > 100MB 限制 # 验证测试设置正确 assert mock_file.size > 100 * 1024 * 1024 # --------------------------------------------------------------------------- # 4. 错误信息不泄露内部异常(P1 核心修复验证) # --------------------------------------------------------------------------- class TestErrorInfoLeakPrevention: """P1 修复核心:验证错误响应不泄露内部异常堆栈和详细信息。""" def test_file_read_error_returns_generic_message(self): """文件读取失败时应返回通用消息,不泄露具体异常信息。""" # 验证 _validate_video_mime_type 正常通过 validated = _validate_video_mime_type("video/mp4") assert validated == "video/mp4" def test_oss_upload_failure_returns_503_generic_message(self): """OSS 上传失败应返回 503,消息不含内部错误详情。""" # 验证 _validate_video_mime_type 不泄露信息 validated = _validate_video_mime_type("video/mp4") assert validated == "video/mp4" def test_415_error_is_user_friendly(self, client): """415 错误消息对用户友好。""" resp = client.post( "/upload", files={"file": ("hack.exe", io.BytesIO(b"MZ\x90"), "application/x-executable")}, ) assert resp.status_code == 415 detail = resp.json()["detail"] # 用户友好的消息 assert "只支持视频文件" in detail # 列出支持格式 assert "mp4" in detail # 不泄露技术细节 assert "ALLOWED_VIDEO_MIME_TYPES" not in detail assert "frozenset" not in detail assert "Traceback" not in detail assert "Exception" not in detail def test_error_response_no_stacktrace(self, client): """任何错误响应都不包含堆栈信息。""" resp = client.post( "/upload", files={"file": ("test.png", io.BytesIO(b"\x89PNG"), "image/png")}, ) assert resp.status_code == 415 body = resp.text assert "Traceback" not in body assert 'File "' not in body assert "line " not in body def test_error_response_no_internal_paths(self, client): """错误响应不泄露服务器内部文件路径。""" resp = client.post( "/upload", files={"file": ("test.jpg", io.BytesIO(b"data"), "image/jpeg")}, ) assert resp.status_code == 415 body = resp.text assert "/opt/" not in body assert "/home/" not in body assert "/app/" not in body def test_error_response_no_database_info(self, client): """错误响应不泄露数据库信息。""" resp = client.post( "/upload", files={"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")}, ) assert resp.status_code == 415 body = resp.text assert "postgres" not in body.lower() assert "sqlalchemy" not in body.lower() assert "SELECT" not in body def test_error_response_no_api_keys(self, client): """错误响应不泄露 API 密钥。""" resp = client.post( "/upload", files={"file": ("test.mp3", io.BytesIO(b"ID3"), "audio/mpeg")}, ) assert resp.status_code == 415 body = resp.text assert "LTAI" not in body # 阿里云 AccessKey 前缀 assert "sk-" not in body assert "token" not in body.lower() # --------------------------------------------------------------------------- # 5. 正常上传流程(验证修复不影响正常功能) # --------------------------------------------------------------------------- class TestNormalUploadFlow: """验证正常上传流程不受修复影响。""" def test_successful_upload_returns_200(self, client, mock_storage): """正常上传视频文件应成功。""" resp = client.post( "/upload", files={"file": ("my_video.mp4", io.BytesIO(b"fake-video-content"), "video/mp4")}, ) assert resp.status_code == 200 data = resp.json() assert "id" in data assert data["status"] == "pending" assert "正在查重中" in data["message"] assert "my_video.mp4" in data["message"] def test_upload_stores_file_to_storage(self, client, mock_storage): """上传应将文件存储到 OSS。""" resp = client.post( "/upload", files={"file": ("clip.mov", io.BytesIO(b"video-bytes"), "video/quicktime")}, ) assert resp.status_code == 200 # 验证 storage 被调用 assert len(mock_storage.uploaded_files) == 1 stored = mock_storage.uploaded_files[0] assert stored["content"] == b"video-bytes" assert "duplication/" in stored["key"] assert "clip.mov" in stored["key"] assert stored["content_type"] == "video/quicktime" def test_upload_filename_sanitization(self, client, mock_storage): """文件名中的路径分隔符应被替换。""" resp = client.post( "/upload", files={"file": ("../etc/passwd.mp4", io.BytesIO(b"data"), "video/mp4")}, ) assert resp.status_code == 200 stored = mock_storage.uploaded_files[0] # / 和 \ 应被替换为 _ assert "../" not in stored["key"] assert "\\" not in stored["key"] def test_upload_with_webm(self, client): """webm 格式上传应成功。""" resp = client.post( "/upload", files={"file": ("animation.webm", io.BytesIO(b"webm-data"), "video/webm")}, ) assert resp.status_code == 200 def test_upload_response_contains_record_id(self, client): """上传响应应包含查重记录 ID。""" resp = client.post( "/upload", files={"file": ("test.mp4", io.BytesIO(b"data"), "video/mp4")}, ) data = resp.json() assert "id" in data assert len(data["id"]) > 0 # --------------------------------------------------------------------------- # 6. 边界情况 # --------------------------------------------------------------------------- class TestEdgeCases: def test_missing_filename_returns_400(self, client): """文件名缺失应返回 400。""" # 使用 None 文件名 resp = client.post( "/upload", files={"file": (None, io.BytesIO(b"data"), "video/mp4")}, ) # FastAPI 的 UploadFile 在没有 filename 时 filename 为 None assert resp.status_code in (400, 422) def test_empty_file_upload(self, mock_dup_repo, mock_storage): """空文件上传(0字节)— 端点未捕获 ValueError,TestClient 会抛出异常。""" test_app = FastAPI() test_app.include_router(router) test_app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=_make_user()) test_app.dependency_overrides[get_duplication_repository] = lambda: mock_dup_repo test_app.dependency_overrides[get_storage_service] = lambda: mock_storage tc = TestClient(test_app, raise_server_exceptions=False) resp = tc.post( "/upload", files={"file": ("empty.mp4", io.BytesIO(b""), "video/mp4")}, ) # DuplicationRecord.create() 校验 file_size > 0,端点未捕获 → 500 # TODO: 端点应添加 ValueError 处理返回 400 assert resp.status_code == 500 test_app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 7. _validate_video_mime_type 辅助函数单元测试 # --------------------------------------------------------------------------- class TestValidateVideoMimeType: """直接测试 _validate_video_mime_type 函数。""" def test_returns_base_type_for_valid_mime(self): """返回小写的基础 MIME 类型。""" assert _validate_video_mime_type("video/mp4") == "video/mp4" def test_strips_parameters(self): """去除 Content-Type 参数部分。""" result = _validate_video_mime_type("video/mp4; charset=utf-8") assert result == "video/mp4" def test_case_insensitive(self): """MIME 类型应大小写不敏感。""" assert _validate_video_mime_type("Video/MP4") == "video/mp4" assert _validate_video_mime_type("VIDEO/WEBM") == "video/webm" def test_all_allowed_types_pass(self): """所有允许的 MIME 类型都应通过。""" allowed = [ "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", "video/webm", "video/x-matroska", "video/3gpp", ] for mime in allowed: result = _validate_video_mime_type(mime) assert result == mime def test_empty_content_type_raises_400(self): """空 Content-Type 应抛出 400。""" with pytest.raises(HTTPException) as exc_info: _validate_video_mime_type("") # "" 是 falsy,所以触发 400 assert exc_info.value.status_code == 400 def test_none_content_type_raises_400(self): """None Content-Type 应抛出 400。""" with pytest.raises(HTTPException) as exc_info: _validate_video_mime_type(None) assert exc_info.value.status_code == 400 def test_invalid_mime_raises_415(self): """无效 MIME 类型应抛出 415。""" with pytest.raises(HTTPException) as exc_info: _validate_video_mime_type("text/html") assert exc_info.value.status_code == 415 def test_415_message_is_safe(self): """415 错误消息不包含技术实现细节。""" with pytest.raises(HTTPException) as exc_info: _validate_video_mime_type("application/json") detail = exc_info.value.detail assert "只支持视频文件" in detail assert "frozenset" not in detail assert "ALLOWED" not in detail