7e505a04c7
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
300 lines
11 KiB
Python
Executable File
300 lines
11 KiB
Python
Executable File
"""密码重置 UseCase 单元测试."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from packages.application.auth.password_reset_use_case import (
|
|
RequestPasswordResetRequest,
|
|
RequestPasswordResetUseCase,
|
|
ResetPasswordRequest,
|
|
ResetPasswordUseCase,
|
|
)
|
|
from packages.domain.entities import User
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_user_repo():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_email_service():
|
|
svc = MagicMock()
|
|
svc.send_password_reset_email.return_value = (True, None)
|
|
return svc
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_user():
|
|
user = User(
|
|
id="user_001",
|
|
email="user@example.com",
|
|
display_name="测试用户",
|
|
username="testuser",
|
|
password_hash="old_hash",
|
|
)
|
|
user.password_reset_token = None
|
|
user.password_reset_expires_at = None
|
|
return user
|
|
|
|
|
|
class TestRequestPasswordResetRequest:
|
|
"""RequestPasswordResetRequest 测试"""
|
|
|
|
def test_email_lowercased_and_stripped(self):
|
|
"""邮箱转小写并去空格"""
|
|
req = RequestPasswordResetRequest(" User@Example.COM ")
|
|
assert req.email == "user@example.com"
|
|
|
|
def test_empty_email(self):
|
|
"""空邮箱"""
|
|
req = RequestPasswordResetRequest("")
|
|
assert req.email == ""
|
|
|
|
|
|
class TestRequestPasswordResetUseCase:
|
|
"""RequestPasswordResetUseCase 测试"""
|
|
|
|
def test_request_success(self, mock_user_repo, mock_email_service, sample_user):
|
|
"""请求重置成功"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("user@example.com")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
assert sample_user.password_reset_token is not None
|
|
assert len(sample_user.password_reset_token) > 0
|
|
assert sample_user.password_reset_expires_at is not None
|
|
mock_user_repo.save.assert_called_once()
|
|
mock_email_service.send_password_reset_email.assert_called_once()
|
|
|
|
def test_request_user_not_found_returns_success(self, mock_user_repo, mock_email_service):
|
|
"""用户不存在也返回成功(安全考虑,不暴露用户存在性)"""
|
|
mock_user_repo.find_by_email.return_value = None
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("nonexistent@example.com")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
mock_user_repo.save.assert_not_called()
|
|
mock_email_service.send_password_reset_email.assert_not_called()
|
|
|
|
def test_request_empty_email_returns_error(self, mock_user_repo, mock_email_service):
|
|
"""空邮箱返回错误"""
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Email is required" in error
|
|
|
|
def test_reset_token_expiry_set(self, mock_user_repo, mock_email_service, sample_user):
|
|
"""重置令牌过期时间正确设置"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
token_expire_hours=2,
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("user@example.com")
|
|
use_case.execute(request)
|
|
|
|
assert sample_user.password_reset_expires_at is not None
|
|
# 过期时间应该在约2小时后
|
|
expected = datetime.now(timezone.utc) + timedelta(hours=2)
|
|
diff = abs((sample_user.password_reset_expires_at - expected).total_seconds())
|
|
assert diff < 10 # 允许10秒误差
|
|
|
|
def test_email_contains_reset_url(self, mock_user_repo, mock_email_service, sample_user):
|
|
"""重置邮件包含正确的重置链接"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://app.example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("user@example.com")
|
|
use_case.execute(request)
|
|
|
|
call_args = mock_email_service.send_password_reset_email.call_args
|
|
reset_url = call_args[1]["reset_url"] if "reset_url" in call_args[1] else call_args[0][2]
|
|
assert "https://app.example.com/reset-password?token=" in reset_url
|
|
|
|
def test_email_failure_does_not_affect_result(self, mock_user_repo, mock_email_service, sample_user):
|
|
"""邮件发送失败不影响返回结果(安全考虑)"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
mock_email_service.send_password_reset_email.return_value = (False, "SMTP error")
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("user@example.com")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
|
|
def test_different_tokens_each_time(self, mock_user_repo, mock_email_service, sample_user):
|
|
"""每次请求生成不同的 token"""
|
|
mock_user_repo.find_by_email.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = RequestPasswordResetUseCase(
|
|
mock_user_repo,
|
|
base_url="https://example.com",
|
|
email_service=mock_email_service,
|
|
)
|
|
request = RequestPasswordResetRequest("user@example.com")
|
|
|
|
use_case.execute(request)
|
|
token1 = sample_user.password_reset_token
|
|
|
|
use_case.execute(request)
|
|
token2 = sample_user.password_reset_token
|
|
|
|
assert token1 != token2
|
|
|
|
|
|
class TestResetPasswordRequest:
|
|
"""ResetPasswordRequest 测试"""
|
|
|
|
def test_stores_token_and_password(self):
|
|
"""正确存储 token 和新密码"""
|
|
req = ResetPasswordRequest(token="abc123", new_password="NewPass1!")
|
|
assert req.token == "abc123"
|
|
assert req.new_password == "NewPass1!"
|
|
|
|
|
|
class TestResetPasswordUseCase:
|
|
"""ResetPasswordUseCase 测试"""
|
|
|
|
def test_reset_success(self, mock_user_repo, sample_user):
|
|
"""重置密码成功"""
|
|
sample_user.password_reset_token = "valid_token"
|
|
sample_user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
|
mock_user_repo.save.return_value = sample_user
|
|
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="valid_token", new_password="NewSecurePass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
assert sample_user.password_reset_token is None
|
|
assert sample_user.password_reset_expires_at is None
|
|
assert sample_user.password_hash != "old_hash"
|
|
mock_user_repo.save.assert_called_once()
|
|
|
|
def test_reset_empty_token(self, mock_user_repo):
|
|
"""空 token 返回错误"""
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="", new_password="NewPass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Reset token is required" in error
|
|
mock_user_repo.save.assert_not_called()
|
|
|
|
def test_reset_empty_password(self, mock_user_repo):
|
|
"""空密码返回错误"""
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="sometoken", new_password="")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "New password is required" in error
|
|
mock_user_repo.save.assert_not_called()
|
|
|
|
def test_reset_weak_password(self, mock_user_repo):
|
|
"""弱密码返回错误"""
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="sometoken", new_password="weak")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert error is not None
|
|
mock_user_repo.save.assert_not_called()
|
|
|
|
def test_reset_invalid_token(self, mock_user_repo):
|
|
"""无效 token 返回错误"""
|
|
mock_user_repo.find_by_password_reset_token.return_value = None
|
|
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="invalid_token", new_password="NewPass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "Invalid or expired" in error
|
|
mock_user_repo.save.assert_not_called()
|
|
|
|
def test_reset_expired_token(self, mock_user_repo, sample_user):
|
|
"""过期 token 返回错误"""
|
|
sample_user.password_reset_token = "expired_token"
|
|
sample_user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
|
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="expired_token", new_password="NewPass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "expired" in error.lower()
|
|
mock_user_repo.save.assert_not_called()
|
|
|
|
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo, sample_user):
|
|
"""无时区的过期时间按 UTC 处理"""
|
|
sample_user.password_reset_token = "naive_token"
|
|
# 用无时区的时间,设置为过去
|
|
sample_user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1)
|
|
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
|
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="naive_token", new_password="NewPass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is False
|
|
assert "expired" in error.lower()
|
|
|
|
def test_reset_no_expiry_set(self, mock_user_repo, sample_user):
|
|
"""没有设置过期时间的 token 可以使用"""
|
|
sample_user.password_reset_token = "no_expiry_token"
|
|
sample_user.password_reset_expires_at = None
|
|
mock_user_repo.find_by_password_reset_token.return_value = sample_user
|
|
|
|
use_case = ResetPasswordUseCase(mock_user_repo)
|
|
request = ResetPasswordRequest(token="no_expiry_token", new_password="NewPass1!")
|
|
success, error = use_case.execute(request)
|
|
|
|
assert success is True
|