- .gitignore: keep api/uploads ignore and copyright_source_listing.pdf path - auth: keep COS avatar upload URL; delete prior COS object when applying preset - i18n: regenerate resources.ts (includes profile tapAwayToClose) - Avatar/COS tests and personal-info remain from prior local work Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
8.2 KiB
Python
243 lines
8.2 KiB
Python
"""预设头像 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"
|