Files
xiaoxia-saas/tests/unit/test_password_reset_use_case.py
xiaoxia 35b38ed48c
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
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
test(wave211): 密码重置UseCase单测 +25测 (#1181)
2026-07-30 07:45:04 +08:00

490 lines
20 KiB
Python
Executable File

"""密码重置 Use Case 单元测试."""
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
# ── Test Fixtures ────────────────────────────────────────────────────────────
def _make_user(
user_id="user-1",
email="user@example.com",
username="testuser",
display_name="Test User",
password_hash="hashed_password_123",
):
"""创建一个测试用户."""
return User(
id=user_id,
email=email,
display_name=display_name,
username=username,
password_hash=password_hash,
)
@pytest.fixture
def mock_user_repo():
"""mock 用户仓储."""
repo = MagicMock()
repo.find_by_email.return_value = None
repo.find_by_password_reset_token.return_value = None
repo.save.return_value = None
return repo
@pytest.fixture
def mock_email_service():
"""mock 邮件服务."""
svc = MagicMock()
svc.send_password_reset_email.return_value = (True, None)
return svc
# ── RequestPasswordResetUseCase 测试 ────────────────────────────────────────
class TestRequestPasswordReset:
"""请求密码重置用例测试"""
def test_request_success_sends_email(self, mock_user_repo, mock_email_service):
"""成功请求时发送重置邮件."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# 用户被更新了 reset_token
mock_user_repo.save.assert_called_once()
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_reset_token is not None
assert len(saved_user.password_reset_token) > 0
assert saved_user.password_reset_expires_at is not None
# 邮件发送了
mock_email_service.send_password_reset_email.assert_called_once()
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["to_email"] == "user@example.com"
assert "reset-password?token=" in call_kwargs["reset_url"]
assert "https://app.example.com" in call_kwargs["reset_url"]
def test_request_nonexistent_user_returns_success(self, mock_user_repo, mock_email_service):
"""用户不存在时也返回成功(不暴露用户存在性)."""
mock_user_repo.find_by_email.return_value = None
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="nonexistent@example.com")
ok, error = uc.execute(req)
assert ok 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(self, mock_user_repo, mock_email_service):
"""空邮箱返回错误."""
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="")
ok, error = uc.execute(req)
assert ok is False
assert "Email is required" in error
def test_request_email_normalized(self, mock_user_repo, mock_email_service):
"""邮箱会被规范化(小写+去空格)."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
ok, _ = uc.execute(req)
assert ok is True
# find_by_email 收到的是小写的
mock_user_repo.find_by_email.assert_called_with("user@example.com")
def test_request_token_expiry_custom_hours(self, mock_user_repo, mock_email_service):
"""自定义令牌过期时间."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
token_expire_hours=6,
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
before = datetime.now(timezone.utc)
ok, _ = uc.execute(req)
after = datetime.now(timezone.utc)
assert ok is True
saved_user = mock_user_repo.save.call_args[0][0]
expires_at = saved_user.password_reset_expires_at
# 过期时间应该在 ~6 小时后
expected_min = before + timedelta(hours=6)
expected_max = after + timedelta(hours=6)
assert expected_min <= expires_at <= expected_max
def test_request_email_failure_returns_success(self, mock_user_repo, mock_email_service):
"""邮件发送失败不影响返回结果(安全考虑)."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
mock_email_service.send_password_reset_email.return_value = (False, "SMTP error")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# token 仍然保存了
mock_user_repo.save.assert_called_once()
def test_request_email_exception_does_not_propagate(self, mock_user_repo, mock_email_service):
"""邮件服务异常不向外传播."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
mock_email_service.send_password_reset_email.side_effect = Exception("SMTP down")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is True
assert error is None
def test_request_username_uses_display_name_fallback(self, mock_user_repo, mock_email_service):
"""用户名为空时用 display_name."""
user = _make_user(username="", display_name="Display Name")
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["username"] == "Display Name"
def test_request_uses_username_when_available(self, mock_user_repo, mock_email_service):
"""有用户名时用用户名."""
user = _make_user(username="myusername", display_name="Display Name")
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
call_kwargs = mock_email_service.send_password_reset_email.call_args.kwargs
assert call_kwargs["username"] == "myusername"
def test_request_generates_unique_tokens(self, mock_user_repo, mock_email_service):
"""每次请求生成不同的令牌."""
user = _make_user()
mock_user_repo.find_by_email.return_value = user
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
tokens = []
for _ in range(3):
req = RequestPasswordResetRequest(email="user@example.com")
uc.execute(req)
saved_user = mock_user_repo.save.call_args[0][0]
tokens.append(saved_user.password_reset_token)
assert len(set(tokens)) == 3 # 三个不同的令牌
def test_request_general_exception_returns_error(self, mock_user_repo, mock_email_service):
"""其他异常返回错误信息."""
mock_user_repo.find_by_email.side_effect = Exception("DB connection error")
uc = RequestPasswordResetUseCase(
user_repository=mock_user_repo,
base_url="https://app.example.com",
email_service=mock_email_service,
)
req = RequestPasswordResetRequest(email="user@example.com")
ok, error = uc.execute(req)
assert ok is False
assert "failed" in error.lower()
# ── ResetPasswordUseCase 测试 ───────────────────────────────────────────────
class TestResetPassword:
"""重置密码用例测试"""
def test_reset_success(self, mock_user_repo):
"""成功重置密码."""
user = _make_user()
user.password_reset_token = "valid-token-123"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
# mock password_hasher 和 password_validator
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "new_hashed_password"
req = ResetPasswordRequest(token="valid-token-123", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is True
assert error is None
# 密码被更新
mock_user_repo.save.assert_called_once()
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_hash == "new_hashed_password"
# 令牌被清除
assert saved_user.password_reset_token is None
assert saved_user.password_reset_expires_at is None
def test_reset_empty_token(self, mock_user_repo):
"""空令牌返回错误."""
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "token is required" in error.lower()
def test_reset_empty_password(self, mock_user_repo):
"""空密码返回错误."""
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="valid-token", new_password="")
ok, error = uc.execute(req)
assert ok is False
assert "password is required" in error.lower()
def test_reset_invalid_token(self, mock_user_repo):
"""无效令牌返回错误."""
mock_user_repo.find_by_password_reset_token.return_value = None
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="invalid-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "Invalid or expired" in error
def test_reset_expired_token(self, mock_user_repo):
"""过期令牌返回错误."""
user = _make_user()
user.password_reset_token = "expired-token"
user.password_reset_expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="expired-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "expired" in error.lower()
def test_reset_naive_datetime_treated_as_utc(self, mock_user_repo):
"""不带时区的过期时间被当作 UTC 处理."""
user = _make_user()
user.password_reset_token = "token-123"
# 用 naive datetime(无时区),应该被当作 UTC
user.password_reset_expires_at = datetime.utcnow() - timedelta(hours=1) # type: ignore
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (True, None)
req = ResetPasswordRequest(token="token-123", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "expired" in error.lower()
def test_reset_weak_password_fails(self, mock_user_repo):
"""弱密码被拒绝."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator:
mock_validator.validate.return_value = (False, "Password too short")
req = ResetPasswordRequest(token="valid-token", new_password="123")
ok, error = uc.execute(req)
assert ok is False
assert "too short" in error.lower()
# 密码没被更新
mock_user_repo.save.assert_not_called()
def test_reset_no_expires_at_still_works(self, mock_user_repo):
"""没有过期时间时视为不过期."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = None
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "newhash"
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is True
assert error is None
def test_reset_exception_returns_error(self, mock_user_repo):
"""异常情况返回错误信息."""
mock_user_repo.find_by_password_reset_token.side_effect = Exception("DB error")
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
req = ResetPasswordRequest(token="token", new_password="NewPass123!")
ok, error = uc.execute(req)
assert ok is False
assert "failed" in error.lower()
def test_reset_clears_token_on_success(self, mock_user_repo):
"""成功重置后令牌被清除,防止重复使用."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "newhash"
req = ResetPasswordRequest(token="valid-token", new_password="NewPass123!")
ok, _ = uc.execute(req)
assert ok is True
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_reset_token is None
assert saved_user.password_reset_expires_at is None
def test_reset_hashes_new_password(self, mock_user_repo):
"""密码被哈希后保存."""
user = _make_user()
user.password_reset_token = "valid-token"
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
mock_user_repo.find_by_password_reset_token.return_value = user
uc = ResetPasswordUseCase(user_repository=mock_user_repo)
with (
patch("packages.application.auth.password_reset_use_case.password_hasher") as mock_hasher,
patch("packages.application.auth.password_reset_use_case.password_validator") as mock_validator,
):
mock_validator.validate.return_value = (True, None)
mock_hasher.hash_password.return_value = "hashed_abcdef"
req = ResetPasswordRequest(token="valid-token", new_password="MyNewPass123!")
uc.execute(req)
mock_hasher.hash_password.assert_called_once_with("MyNewPass123!")
saved_user = mock_user_repo.save.call_args[0][0]
assert saved_user.password_hash == "hashed_abcdef"
# ── RequestPasswordResetRequest 测试 ────────────────────────────────────────
class TestRequestPasswordResetRequest:
"""请求数据类测试"""
def test_email_stripped_and_lowercased(self):
req = RequestPasswordResetRequest(email=" USER@Example.COM ")
assert req.email == "user@example.com"
def test_email_already_lowercase(self):
req = RequestPasswordResetRequest(email="user@example.com")
assert req.email == "user@example.com"
# ── ResetPasswordRequest 测试 ───────────────────────────────────────────────
class TestResetPasswordRequest:
"""重置密码请求数据类测试"""
def test_stores_token_and_password(self):
req = ResetPasswordRequest(token="token123", new_password="password123")
assert req.token == "token123"
assert req.new_password == "password123"