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
343 lines
12 KiB
Python
Executable File
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
|