08cb1cfc23
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
488 lines
18 KiB
Python
Executable File
488 lines
18 KiB
Python
Executable File
"""用户登录 UseCase 单元测试."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from packages.application.auth.login_use_case import (
|
|
LEGACY_SHA256_HEX_LENGTH,
|
|
LoginRequest,
|
|
LoginResponse,
|
|
LoginUseCase,
|
|
LogoutRequest,
|
|
LogoutUseCase,
|
|
RefreshTokenRequest,
|
|
RefreshTokenUseCase,
|
|
_is_legacy_sha256_hash,
|
|
_legacy_sha256,
|
|
)
|
|
from packages.domain.entities import User
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_user_repo():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session_store():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_user():
|
|
"""使用 bcrypt 哈希的正常用户"""
|
|
from packages.application.auth.password_hasher import PasswordHasher
|
|
|
|
hasher = PasswordHasher(rounds=4)
|
|
hashed = hasher.hash_password("CorrectPass1!")
|
|
|
|
user = User(
|
|
id="user_001",
|
|
email="test@example.com",
|
|
username="testuser",
|
|
display_name="测试用户",
|
|
password_hash=hashed,
|
|
)
|
|
user.last_login_at = None
|
|
user.last_login_ip = None
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def legacy_user():
|
|
"""使用 SHA256 哈希的旧版用户"""
|
|
legacy_hash = _legacy_sha256("OldPassword1!")
|
|
user = User(
|
|
id="user_legacy",
|
|
email="legacy@example.com",
|
|
username="legacyuser",
|
|
display_name="旧版用户",
|
|
password_hash=legacy_hash,
|
|
)
|
|
user.last_login_at = None
|
|
user.last_login_ip = None
|
|
return user
|
|
|
|
|
|
class TestLegacyHelpers:
|
|
"""遗留哈希辅助函数测试"""
|
|
|
|
def test_is_legacy_sha256_valid_hash(self):
|
|
"""有效的 SHA256 哈希返回 True"""
|
|
test_hash = "a" * 64 # 64个十六进制字符
|
|
assert _is_legacy_sha256_hash(test_hash) is True
|
|
|
|
def test_is_legacy_sha256_wrong_length(self):
|
|
"""长度不对返回 False"""
|
|
assert _is_legacy_sha256_hash("abc") is False
|
|
assert _is_legacy_sha256_hash("a" * 63) is False
|
|
assert _is_legacy_sha256_hash("a" * 65) is False
|
|
|
|
def test_is_legacy_sha256_non_hex(self):
|
|
"""包含非十六进制字符返回 False"""
|
|
test_hash = "g" * 64 # 'g' 不是十六进制
|
|
assert _is_legacy_sha256_hash(test_hash) is False
|
|
|
|
def test_is_legacy_sha256_mixed_case(self):
|
|
"""大小写混合也能识别"""
|
|
test_hash = "AbCdEf0123456789" * 4 # 64字符,大小写混合
|
|
assert _is_legacy_sha256_hash(test_hash) is True
|
|
|
|
def test_legacy_sha256_consistent(self):
|
|
"""相同密码产生相同哈希"""
|
|
h1 = _legacy_sha256("test_password")
|
|
h2 = _legacy_sha256("test_password")
|
|
assert h1 == h2
|
|
assert len(h1) == LEGACY_SHA256_HEX_LENGTH
|
|
|
|
def test_legacy_sha256_different_passwords(self):
|
|
"""不同密码产生不同哈希"""
|
|
h1 = _legacy_sha256("password1")
|
|
h2 = _legacy_sha256("password2")
|
|
assert h1 != h2
|
|
|
|
|
|
class TestLoginRequest:
|
|
"""LoginRequest 测试"""
|
|
|
|
def test_email_lowercased_stripped(self):
|
|
"""邮箱转小写并去空格"""
|
|
req = LoginRequest(
|
|
email=" Test@Example.COM ",
|
|
password="TestPass1!",
|
|
)
|
|
assert req.email == "test@example.com"
|
|
|
|
def test_default_device_info(self):
|
|
"""默认设备信息"""
|
|
req = LoginRequest(email="test@example.com", password="pass")
|
|
assert req.device_info == "Unknown"
|
|
|
|
def test_default_ip_address(self):
|
|
"""默认 IP"""
|
|
req = LoginRequest(email="test@example.com", password="pass")
|
|
assert req.ip_address == "unknown"
|
|
|
|
def test_custom_device_and_ip(self):
|
|
"""自定义设备信息和 IP"""
|
|
req = LoginRequest(
|
|
email="test@example.com",
|
|
password="pass",
|
|
device_info="Chrome/Windows",
|
|
ip_address="192.168.1.1",
|
|
)
|
|
assert req.device_info == "Chrome/Windows"
|
|
assert req.ip_address == "192.168.1.1"
|
|
|
|
|
|
class TestLoginUseCase:
|
|
"""LoginUseCase 测试"""
|
|
|
|
def test_login_success(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""正常登录成功"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key-for-jwt-login-123",
|
|
)
|
|
request = LoginRequest(
|
|
email="test@example.com",
|
|
password="CorrectPass1!",
|
|
device_info="Chrome",
|
|
ip_address="192.168.1.1",
|
|
)
|
|
response, error = use_case.execute(request)
|
|
|
|
assert error is None
|
|
assert response is not None
|
|
assert response.user_id == "user_001"
|
|
assert response.email == "test@example.com"
|
|
assert response.username == "testuser"
|
|
assert response.display_name == "测试用户"
|
|
assert len(response.access_token) > 0
|
|
assert len(response.refresh_token) > 0
|
|
assert response.expires_in > 0
|
|
mock_session_store.save_session.assert_called_once()
|
|
mock_user_repo.save.assert_called() # 更新最后登录时间
|
|
|
|
def test_login_empty_email(self, mock_user_repo, mock_session_store):
|
|
"""空邮箱返回错误"""
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="", password="TestPass1!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Email is required" in error
|
|
|
|
def test_login_empty_password(self, mock_user_repo, mock_session_store):
|
|
"""空密码返回错误"""
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="test@example.com", password="")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Password is required" in error
|
|
|
|
def test_login_user_not_found(self, mock_user_repo, mock_session_store):
|
|
"""用户不存在返回错误"""
|
|
mock_user_repo.find_by_email.return_value = None
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="nonexistent@example.com", password="TestPass1!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Invalid email or password" in error
|
|
|
|
def test_login_wrong_password(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""密码错误返回错误"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="test@example.com", password="WrongPass1!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Invalid email or password" in error
|
|
mock_session_store.save_session.assert_not_called()
|
|
|
|
def test_login_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""登录成功更新最后登录信息"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(
|
|
email="test@example.com",
|
|
password="CorrectPass1!",
|
|
ip_address="10.0.0.1",
|
|
)
|
|
use_case.execute(request)
|
|
|
|
assert sample_user.last_login_at is not None
|
|
assert sample_user.last_login_ip == "10.0.0.1"
|
|
|
|
def test_login_session_saved(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""登录成功保存 session"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(
|
|
email="test@example.com",
|
|
password="CorrectPass1!",
|
|
device_info="Firefox/Mac",
|
|
ip_address="192.168.1.100",
|
|
)
|
|
use_case.execute(request)
|
|
|
|
call_kwargs = mock_session_store.save_session.call_args[1]
|
|
assert call_kwargs["user_id"] == "user_001"
|
|
assert call_kwargs["device_info"] == "Firefox/Mac"
|
|
assert call_kwargs["ip_address"] == "192.168.1.100"
|
|
assert call_kwargs["expires_in_seconds"] == 30 * 24 * 3600
|
|
|
|
def test_login_legacy_hash_migration(self, mock_user_repo, mock_session_store, legacy_user):
|
|
"""旧版 SHA256 哈希登录成功并迁移到 bcrypt"""
|
|
original_hash = legacy_user.password_hash
|
|
mock_user_repo.find_by_email.return_value = legacy_user
|
|
|
|
saved_user = None
|
|
|
|
def capture_save(user):
|
|
nonlocal saved_user
|
|
saved_user = user
|
|
|
|
mock_user_repo.save.side_effect = capture_save
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="legacy@example.com", password="OldPassword1!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert error is None
|
|
assert response is not None
|
|
# 密码哈希应该被更新为 bcrypt 格式
|
|
assert saved_user is not None
|
|
assert saved_user.password_hash != original_hash
|
|
assert saved_user.password_hash.startswith("$2") # bcrypt 格式
|
|
|
|
def test_login_legacy_hash_wrong_password(self, mock_user_repo, mock_session_store, legacy_user):
|
|
"""旧版哈希密码错误返回错误"""
|
|
mock_user_repo.find_by_email.return_value = legacy_user
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="legacy@example.com", password="WrongPass!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Invalid email or password" in error
|
|
|
|
def test_login_exception_returns_error(self, mock_user_repo, mock_session_store):
|
|
"""异常时返回友好错误"""
|
|
mock_user_repo.find_by_email.side_effect = Exception("DB error")
|
|
|
|
use_case = LoginUseCase(
|
|
mock_user_repo,
|
|
session_store=mock_session_store,
|
|
jwt_secret_key="test-secret-key",
|
|
)
|
|
request = LoginRequest(email="test@example.com", password="TestPass1!")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Login failed" in error
|
|
|
|
|
|
class TestRefreshTokenUseCase:
|
|
"""RefreshTokenUseCase 测试"""
|
|
|
|
def test_refresh_success(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""刷新令牌成功"""
|
|
session_data = {"session_id": "sess_123", "user_id": "user_001"}
|
|
mock_session_store.get_session_by_refresh_token.return_value = session_data
|
|
mock_session_store.get_refresh_token.return_value = "valid_refresh_token"
|
|
mock_user_repo.get.return_value = sample_user
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="valid_refresh_token")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert error is None
|
|
assert response is not None
|
|
assert response.user_id == "user_001"
|
|
assert len(response.access_token) > 0
|
|
assert response.refresh_token == "valid_refresh_token" # 不变
|
|
|
|
def test_refresh_empty_token(self, mock_user_repo, mock_session_store):
|
|
"""空 refresh token 返回错误"""
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Refresh token is required" in error
|
|
|
|
def test_refresh_invalid_token(self, mock_user_repo, mock_session_store):
|
|
"""无效 refresh token 返回错误"""
|
|
mock_session_store.get_session_by_refresh_token.return_value = None
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="invalid_token")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Invalid or expired" in error
|
|
|
|
def test_refresh_token_mismatch(self, mock_user_repo, mock_session_store):
|
|
"""refresh token 不匹配返回错误"""
|
|
session_data = {"session_id": "sess_123", "user_id": "user_001"}
|
|
mock_session_store.get_session_by_refresh_token.return_value = session_data
|
|
mock_session_store.get_refresh_token.return_value = "different_token"
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="requested_token")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "mismatch" in error
|
|
|
|
def test_refresh_user_not_found(self, mock_user_repo, mock_session_store):
|
|
"""用户不存在返回错误"""
|
|
session_data = {"session_id": "sess_123", "user_id": "nonexistent"}
|
|
mock_session_store.get_session_by_refresh_token.return_value = session_data
|
|
mock_session_store.get_refresh_token.return_value = "valid_token"
|
|
mock_user_repo.get.return_value = None
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="valid_token")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "User not found" in error
|
|
|
|
def test_refresh_invalid_session_data(self, mock_user_repo, mock_session_store):
|
|
"""session 数据不完整返回错误"""
|
|
session_data = {"session_id": "sess_123"} # 缺少 user_id
|
|
mock_session_store.get_session_by_refresh_token.return_value = session_data
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
request = RefreshTokenRequest(refresh_token="token")
|
|
response, error = use_case.execute(request)
|
|
|
|
assert response is None
|
|
assert "Invalid session data" in error
|
|
|
|
def test_refresh_returns_valid_access_token(self, mock_user_repo, mock_session_store, sample_user):
|
|
"""刷新返回有效的 access_token"""
|
|
session_data = {"session_id": "sess_123", "user_id": "user_001"}
|
|
mock_session_store.get_session_by_refresh_token.return_value = session_data
|
|
mock_session_store.get_refresh_token.return_value = "refresh_123"
|
|
mock_user_repo.get.return_value = sample_user
|
|
|
|
use_case = RefreshTokenUseCase(mock_user_repo, session_store=mock_session_store)
|
|
|
|
req = RefreshTokenRequest(refresh_token="refresh_123")
|
|
resp, error = use_case.execute(req)
|
|
|
|
assert error is None
|
|
assert resp.access_token is not None
|
|
# JWT 格式:三段 base64,用 . 分隔
|
|
parts = resp.access_token.split(".")
|
|
assert len(parts) == 3
|
|
assert resp.refresh_token == "refresh_123"
|
|
|
|
|
|
class TestLogoutUseCase:
|
|
"""LogoutUseCase 测试"""
|
|
|
|
def test_logout_single_session(self, mock_session_store):
|
|
"""单设备登出成功"""
|
|
mock_session_store.delete_session.return_value = True
|
|
|
|
use_case = LogoutUseCase(session_store=mock_session_store)
|
|
request = LogoutRequest(user_id="user_001", session_id="sess_123")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
mock_session_store.delete_session.assert_called_once_with("sess_123")
|
|
|
|
def test_logout_all_devices(self, mock_session_store):
|
|
"""全部设备登出"""
|
|
use_case = LogoutUseCase(session_store=mock_session_store)
|
|
request = LogoutRequest(user_id="user_001", logout_all_devices=True)
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
mock_session_store.delete_all_user_sessions.assert_called_once_with("user_001")
|
|
|
|
def test_logout_no_session_id(self, mock_session_store):
|
|
"""单设备登出没有 session_id 返回错误"""
|
|
use_case = LogoutUseCase(session_store=mock_session_store)
|
|
request = LogoutRequest(user_id="user_001", session_id=None)
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Session ID is required" in error
|
|
|
|
def test_logout_session_not_found(self, mock_session_store):
|
|
"""session 不存在返回错误"""
|
|
mock_session_store.delete_session.return_value = False
|
|
|
|
use_case = LogoutUseCase(session_store=mock_session_store)
|
|
request = LogoutRequest(user_id="user_001", session_id="nonexistent")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Session not found" in error
|
|
|
|
def test_logout_exception_returns_error(self, mock_session_store):
|
|
"""异常时返回友好错误"""
|
|
mock_session_store.delete_session.side_effect = Exception("Redis error")
|
|
|
|
use_case = LogoutUseCase(session_store=mock_session_store)
|
|
request = LogoutRequest(user_id="user_001", session_id="sess_123")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Logout failed" in error
|