Files
xiaoxia-saas/tests/unit/test_password_hasher.py
xiaoxia f246f7d8aa
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(wave131): password_hasher密码哈希+验证 + 55单测 (#1034)
2026-07-27 18:30:13 +08:00

343 lines
12 KiB
Python
Executable File

"""密码哈希与验证单元测试 — wave131."""
from __future__ import annotations
import pytest
from packages.application.auth.password_hasher import (
PasswordHasher,
PasswordValidator,
password_hasher,
password_validator,
)
# ── PasswordHasher 初始化 ───────────────────────────────────────────────────
class TestPasswordHasherInit:
def test_default_rounds(self):
hasher = PasswordHasher()
assert hasher.rounds == 12
def test_custom_rounds(self):
hasher = PasswordHasher(rounds=8)
assert hasher.rounds == 8
def test_min_rounds(self):
hasher = PasswordHasher(rounds=4)
assert hasher.rounds == 4
def test_max_rounds(self):
hasher = PasswordHasher(rounds=31)
assert hasher.rounds == 31
def test_rounds_below_min_raises(self):
with pytest.raises(ValueError, match="rounds must be between"):
PasswordHasher(rounds=3)
def test_rounds_above_max_raises(self):
with pytest.raises(ValueError, match="rounds must be between"):
PasswordHasher(rounds=32)
def test_rounds_zero_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=0)
def test_rounds_negative_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=-5)
# ── hash_password ──────────────────────────────────────────────────────────
# 用 rounds=4 加速测试
def _make_hasher():
return PasswordHasher(rounds=4)
class TestHashPassword:
def test_returns_string(self):
hasher = _make_hasher()
result = hasher.hash_password("testpassword")
assert isinstance(result, str)
assert len(result) > 0
def test_empty_password_raises(self):
hasher = _make_hasher()
with pytest.raises(ValueError, match="Password cannot be empty"):
hasher.hash_password("")
def test_none_password_raises(self):
hasher = _make_hasher()
with pytest.raises(ValueError):
hasher.hash_password(None) # type: ignore
def test_bcrypt_format(self):
"""bcrypt hash 格式: $2b$rounds$..."""
hasher = _make_hasher()
result = hasher.hash_password("test")
assert result.startswith("$2b$04$")
def test_same_password_different_hash(self):
"""每次哈希应该生成不同的 salt."""
hasher = _make_hasher()
h1 = hasher.hash_password("samepassword")
h2 = hasher.hash_password("samepassword")
assert h1 != h2
def test_hash_contains_salt(self):
"""bcrypt hash 长度固定约 60 字符."""
hasher = _make_hasher()
result = hasher.hash_password("test")
assert len(result) >= 59 # bcrypt 标准长度
def test_long_password(self):
hasher = _make_hasher()
long_pw = "x" * 100
result = hasher.hash_password(long_pw)
assert isinstance(result, str)
assert len(result) > 0
def test_unicode_password(self):
hasher = _make_hasher()
result = hasher.hash_password("密码测试_🎉_123")
assert isinstance(result, str)
assert len(result) > 0
def test_special_chars_password(self):
hasher = _make_hasher()
result = hasher.hash_password("p@ssw0rd!#$%^&*()")
assert isinstance(result, str)
# ── verify_password ─────────────────────────────────────────────────────────
class TestVerifyPassword:
def test_correct_password_returns_true(self):
hasher = _make_hasher()
pw = "MySecureP@ss123"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
def test_wrong_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("correct_password")
assert hasher.verify_password("wrong_password", hashed) is False
def test_empty_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("test")
assert hasher.verify_password("", hashed) is False
def test_empty_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.verify_password("test", "") is False
def test_none_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("test")
assert hasher.verify_password(None, hashed) is False # type: ignore
def test_invalid_hash_format_returns_false(self):
hasher = _make_hasher()
assert hasher.verify_password("test", "not-a-valid-hash") is False
def test_case_sensitive(self):
hasher = _make_hasher()
hashed = hasher.hash_password("Password")
assert hasher.verify_password("password", hashed) is False
assert hasher.verify_password("PASSWORD", hashed) is False
assert hasher.verify_password("Password", hashed) is True
def test_whitespace_matters(self):
hasher = _make_hasher()
hashed = hasher.hash_password("password")
assert hasher.verify_password(" password", hashed) is False
assert hasher.verify_password("password ", hashed) is False
def test_unicode_roundtrip(self):
hasher = _make_hasher()
pw = "密码_测试_🎉"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
# ── needs_rehash ────────────────────────────────────────────────────────────
class TestNeedsRehash:
def test_same_rounds_no_rehash(self):
hasher = _make_hasher() # rounds=4
hashed = hasher.hash_password("test")
assert hasher.needs_rehash(hashed) is False
def test_different_rounds_needs_rehash(self):
hasher4 = PasswordHasher(rounds=4)
hashed = hasher4.hash_password("test")
hasher6 = PasswordHasher(rounds=6)
assert hasher6.needs_rehash(hashed) is True
def test_higher_rounds_needs_rehash(self):
hasher4 = PasswordHasher(rounds=4)
hashed = hasher4.hash_password("test")
hasher10 = PasswordHasher(rounds=10)
assert hasher10.needs_rehash(hashed) is True
def test_lower_rounds_also_needs_rehash(self):
hasher10 = PasswordHasher(rounds=10)
# 用 rounds=10 的 hasher 生成的 hash,用 rounds=4 的检查应该也要重算(因为 cost 不同)
hashed = hasher10.hash_password("test")
hasher4 = PasswordHasher(rounds=4)
assert hasher4.needs_rehash(hashed) is True
def test_invalid_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.needs_rehash("invalid-hash") is False
def test_empty_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.needs_rehash("") is False
# ── PasswordValidator 初始化 ────────────────────────────────────────────────
class TestPasswordValidatorInit:
def test_default_settings(self):
validator = PasswordValidator()
assert validator.min_length == 8
assert validator.require_uppercase is True
assert validator.require_lowercase is True
assert validator.require_digit is True
assert validator.require_special is False
def test_custom_settings(self):
validator = PasswordValidator(
min_length=12,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=True,
)
assert validator.min_length == 12
assert validator.require_uppercase is False
assert validator.require_special is True
# ── PasswordValidator.validate ──────────────────────────────────────────────
class TestPasswordValidator:
def setup_method(self):
self.validator = PasswordValidator() # 默认 8 位 + 大小写 + 数字
def test_strong_password_passes(self):
valid, error = self.validator.validate("MyStr0ngP@ss")
assert valid is True
assert error is None
def test_empty_password_fails(self):
valid, error = self.validator.validate("")
assert valid is False
assert "empty" in error.lower()
def test_too_short_fails(self):
valid, error = self.validator.validate("Abc123") # 6 chars
assert valid is False
assert "at least" in error.lower()
assert "8" in error
def test_exactly_min_length_passes(self):
valid, error = self.validator.validate("Abcd1234") # 8 chars
assert valid is True
def test_no_uppercase_fails(self):
valid, error = self.validator.validate("lowercase123")
assert valid is False
assert "uppercase" in error.lower()
def test_no_lowercase_fails(self):
valid, error = self.validator.validate("UPPERCASE123")
assert valid is False
assert "lowercase" in error.lower()
def test_no_digit_fails(self):
valid, error = self.validator.validate("NoDigitsHere")
assert valid is False
assert "digit" in error.lower()
def test_special_char_not_required_by_default(self):
# 默认不要求特殊字符,没有也通过
valid, error = self.validator.validate("GoodPass123")
assert valid is True
def test_special_char_required_and_missing(self):
validator = PasswordValidator(require_special=True)
valid, error = validator.validate("GoodPass123")
assert valid is False
assert "special character" in error.lower()
def test_special_char_required_and_present(self):
validator = PasswordValidator(require_special=True)
valid, error = validator.validate("GoodPass123!")
assert valid is True
def test_all_special_chars_accepted(self):
validator = PasswordValidator(require_special=True)
specials = "!@#$%^&*()_+-=[]{}|;:,.<>?~"
for c in specials:
pw = f"Pass123{c}"
valid, _ = validator.validate(pw)
assert valid is True, f"Special char '{c}' should be accepted"
def test_minimal_valid_password(self):
"""刚好满足所有要求的最短密码."""
valid, _ = self.validator.validate("Ab1cdefg") # 8 chars: 1 upper, 1 lower, 1 digit
assert valid is True
def test_only_digits_fails(self):
valid, _ = self.validator.validate("12345678")
assert valid is False
def test_only_letters_fails(self):
valid, _ = self.validator.validate("Abcdefgh")
assert valid is False # 没有数字
def test_long_password_passes(self):
valid, _ = self.validator.validate("VeryLongPassword123WithManyCharacters")
assert valid is True
def test_unicode_password_passes(self):
"""Unicode 字符计数正确."""
valid, error = self.validator.validate("密码Abc12345")
assert valid is True
def test_whitespace_password_passes_if_meets_req(self):
"""包含空格的密码(如果满足其他要求)应该通过."""
valid, _ = self.validator.validate("Pass word 123")
assert valid is True
# ── 全局实例 ────────────────────────────────────────────────────────────────
class TestGlobalInstances:
def test_global_hasher_exists(self):
assert password_hasher is not None
assert isinstance(password_hasher, PasswordHasher)
assert password_hasher.rounds == 12
def test_global_validator_exists(self):
assert password_validator is not None
assert isinstance(password_validator, PasswordValidator)
def test_global_hasher_works(self):
hashed = password_hasher.hash_password("test_global")
assert password_hasher.verify_password("test_global", hashed) is True
def test_global_validator_works(self):
valid, _ = password_validator.validate("TestPass123")
assert valid is True