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
490 lines
20 KiB
Python
Executable File
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"
|