"""预设头像 HTTP 契约。""" from __future__ import annotations import uuid from datetime import datetime, timezone from io import BytesIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from PIL import Image from app.core.dependencies import get_current_user from app.features.auth.deps import get_auth_service from app.features.auth.router import router as auth_router from app.features.auth.service import AuthService from app.features.user.models import User def _mock_current_user() -> User: u = MagicMock(spec=User) u.id = str(uuid.uuid4()) u.phone = "13800000000" u.email = None u.nickname = "测试用户" u.avatar_url = None u.subscription_type = "free" u.created_at = datetime.now(timezone.utc) u.language_preference = "zh" return u @pytest.fixture def preset_auth_app() -> FastAPI: app = FastAPI() app.include_router(auth_router) fixed_user = _mock_current_user() async def _fake_update_avatar(uid: str, url: str): fixed_user.avatar_url = url return fixed_user mock_service = MagicMock(spec=AuthService) mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar) app.dependency_overrides[get_auth_service] = lambda: mock_service app.dependency_overrides[get_current_user] = lambda: fixed_user app.state._mock_auth_service = mock_service app.state._fixed_user = fixed_user return app @pytest.mark.asyncio async def test_list_avatar_presets(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatar-presets") assert r.status_code == 200 items = r.json() assert len(items) == 8 assert items[0]["id"] == "01" assert items[0]["url"] == "/api/auth/avatar-presets/01.png" @pytest.mark.asyncio async def test_get_avatar_preset_ok(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatar-presets/01.png") assert r.status_code == 200 assert r.headers.get("content-type", "").startswith("image/png") @pytest.mark.asyncio async def test_get_avatar_preset_unknown(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatar-presets/99.png") assert r.status_code == 404 @pytest.mark.asyncio async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatar-presets/../secrets.env") assert r.status_code == 404 @pytest.mark.asyncio async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.put( "/api/auth/me/avatar/preset", json={"preset_id": "02"}, headers={"Authorization": "Bearer x"}, ) assert r.status_code == 200 body = r.json() url = body["avatar_url"] assert url.startswith("/api/auth/avatar-presets/02.png") assert "?v=" in url svc: MagicMock = preset_auth_app.state._mock_auth_service stored = svc.update_avatar_url.await_args[0][1] assert stored.startswith("/api/auth/avatar-presets/02.png") assert "?v=" in stored @pytest.mark.asyncio async def test_set_avatar_preset_invalid(preset_auth_app: FastAPI) -> None: transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.put( "/api/auth/me/avatar/preset", json={"preset_id": "99"}, headers={"Authorization": "Bearer x"}, ) assert r.status_code == 400 @pytest.mark.asyncio async def test_get_uploaded_avatar_rejects_traversal() -> None: app = FastAPI() app.include_router(auth_router) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatars/../../../etc/passwd") assert r.status_code == 404 @pytest.mark.asyncio async def test_get_uploaded_avatar_ok_with_safe_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: import app.features.auth.router as auth_router_mod avatar_dir = tmp_path / "avatars" avatar_dir.mkdir() (avatar_dir / "abc-def-123.jpg").write_bytes(b"x") monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir) app = FastAPI() app.include_router(auth_router) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatars/abc-def-123.jpg") assert r.status_code == 200 @pytest.mark.asyncio async def test_get_uploaded_avatar_ok_when_stem_has_underscore( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: import app.features.auth.router as auth_router_mod avatar_dir = tmp_path / "avatars" avatar_dir.mkdir() (avatar_dir / "user_abc_01.jpg").write_bytes(b"x") monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir) app = FastAPI() app.include_router(auth_router) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatars/user_abc_01.jpg") assert r.status_code == 200 def _minimal_jpeg_bytes() -> bytes: img = Image.new("RGB", (2, 2), color=(120, 80, 200)) buf = BytesIO() img.save(buf, format="JPEG", quality=85) return buf.getvalue() @pytest.mark.asyncio async def test_upload_avatar_cos_calls_storage_and_presigns( monkeypatch: pytest.MonkeyPatch, ) -> None: from fastapi import FastAPI from httpx import ASGITransport, AsyncClient import app.core.dependencies as deps from app.core.config import settings from app.core.dependencies import get_current_user from app.features.auth.router import router as auth_router bucket, region = "life-test-bucket", "ap-shanghai" uid = str(uuid.uuid4()) public = f"https://{bucket}.cos.{region}.myqcloud.com/avatars/{uid}.jpg" for attr, val in ( ("tencent_cos_secret_id", "sid"), ("tencent_cos_secret_key", "sk"), ("tencent_cos_bucket", bucket), ("tencent_cos_region", region), ("tencent_cos_base_url", f"https://{bucket}.cos.{region}.myqcloud.com"), ): monkeypatch.setattr(settings, attr, val, raising=False) mock_storage = MagicMock() mock_storage.upload = MagicMock(return_value=public) mock_storage.get_url = MagicMock(return_value="https://example.com/signed-avatar") monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage) fixed_user = _mock_current_user() fixed_user.id = uid fixed_user.avatar_url = None async def _fake_update_avatar(u: str, url: str): fixed_user.avatar_url = url return fixed_user mock_service = MagicMock(spec=AuthService) mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar) app = FastAPI() app.include_router(auth_router) app.dependency_overrides[get_auth_service] = lambda: mock_service app.dependency_overrides[get_current_user] = lambda: fixed_user transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.post( "/api/auth/me/avatar", files={"file": ("a.jpg", BytesIO(_minimal_jpeg_bytes()), "image/jpeg")}, headers={"Authorization": "Bearer x"}, ) assert r.status_code == 200 assert r.json()["avatar_url"] == "https://example.com/signed-avatar" mock_storage.upload.assert_called_once() assert mock_storage.upload.call_args[0][0] == f"avatars/{uid}.jpg" mock_storage.get_url.assert_called_once() assert mock_storage.get_url.call_args[0][0] == f"avatars/{uid}.jpg"