2273fb329f
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
319 lines
11 KiB
Python
Executable File
319 lines
11 KiB
Python
Executable File
"""密码哈希与验证模块单元测试."""
|
|
|
|
import pytest
|
|
|
|
from packages.application.auth.password_handler import (
|
|
PasswordHandler,
|
|
configure_password_handler,
|
|
get_password_handler,
|
|
)
|
|
from packages.application.auth.password_hasher import (
|
|
PasswordHasher,
|
|
PasswordValidator,
|
|
)
|
|
|
|
# ==================== PasswordValidator ====================
|
|
|
|
|
|
class TestPasswordValidator:
|
|
@pytest.fixture
|
|
def default_validator(self):
|
|
return PasswordValidator()
|
|
|
|
@pytest.fixture
|
|
def strict_validator(self):
|
|
return PasswordValidator(
|
|
min_length=12,
|
|
require_uppercase=True,
|
|
require_lowercase=True,
|
|
require_digit=True,
|
|
require_special=True,
|
|
)
|
|
|
|
class TestBasicValidation:
|
|
def test_valid_password(self, default_validator):
|
|
ok, msg = default_validator.validate("SecurePass123")
|
|
assert ok is True
|
|
assert msg is None
|
|
|
|
def test_empty_password(self, default_validator):
|
|
ok, msg = default_validator.validate("")
|
|
assert ok is False
|
|
assert "empty" in msg.lower()
|
|
|
|
def test_none_password_treated_as_empty(self, default_validator):
|
|
# None被not判定为falsy,返回空密码错误
|
|
ok, msg = default_validator.validate(None)
|
|
assert ok is False
|
|
assert "empty" in msg.lower()
|
|
|
|
class TestMinLength:
|
|
def test_too_short(self, default_validator):
|
|
ok, msg = default_validator.validate("Ab1")
|
|
assert ok is False
|
|
assert "8" in msg
|
|
|
|
def test_exact_min_length(self, default_validator):
|
|
# 刚好8个字符
|
|
ok, _ = default_validator.validate("Abcdefg1")
|
|
assert ok is True
|
|
|
|
def test_custom_min_length(self, strict_validator):
|
|
ok, msg = strict_validator.validate("Short1!")
|
|
assert ok is False
|
|
assert "12" in msg
|
|
|
|
class TestUppercase:
|
|
def test_no_uppercase(self, default_validator):
|
|
ok, msg = default_validator.validate("password123")
|
|
assert ok is False
|
|
assert "uppercase" in msg.lower()
|
|
|
|
def test_with_uppercase(self, default_validator):
|
|
ok, _ = default_validator.validate("Password123")
|
|
assert ok is True
|
|
|
|
def test_disabled_requirement(self):
|
|
v = PasswordValidator(require_uppercase=False)
|
|
ok, _ = v.validate("password123")
|
|
assert ok is True
|
|
|
|
class TestLowercase:
|
|
def test_no_lowercase(self, default_validator):
|
|
ok, msg = default_validator.validate("PASSWORD123")
|
|
assert ok is False
|
|
assert "lowercase" in msg.lower()
|
|
|
|
def test_with_lowercase(self, default_validator):
|
|
ok, _ = default_validator.validate("Password123")
|
|
assert ok is True
|
|
|
|
def test_disabled_requirement(self):
|
|
v = PasswordValidator(require_lowercase=False)
|
|
ok, _ = v.validate("PASSWORD123")
|
|
assert ok is True
|
|
|
|
class TestDigit:
|
|
def test_no_digit(self, default_validator):
|
|
ok, msg = default_validator.validate("Passworddd")
|
|
assert ok is False
|
|
assert "digit" in msg.lower()
|
|
|
|
def test_with_digit(self, default_validator):
|
|
ok, _ = default_validator.validate("Password1")
|
|
assert ok is True
|
|
|
|
def test_disabled_requirement(self):
|
|
v = PasswordValidator(require_digit=False)
|
|
ok, _ = v.validate("Passworddd")
|
|
assert ok is True
|
|
|
|
class TestSpecialChar:
|
|
def test_no_special_when_not_required(self, default_validator):
|
|
ok, _ = default_validator.validate("Password123")
|
|
assert ok is True
|
|
|
|
def test_no_special_when_required(self, strict_validator):
|
|
ok, msg = strict_validator.validate("Password1234")
|
|
assert ok is False
|
|
assert "special" in msg.lower()
|
|
|
|
def test_with_special(self, strict_validator):
|
|
ok, _ = strict_validator.validate("Password123!")
|
|
assert ok is True
|
|
|
|
def test_various_special_chars(self, strict_validator):
|
|
for char in "!@#$%^&*()_+-=[]{}|;:,.<>?~":
|
|
pw = f"LongPassword1{char}" # 13字符,含大小写数字特殊
|
|
ok, msg = strict_validator.validate(pw)
|
|
assert ok is True, f"special char {char} should be valid: {msg}"
|
|
|
|
class TestAllDisabled:
|
|
def test_all_disabled_min_length_only(self):
|
|
v = PasswordValidator(
|
|
min_length=1,
|
|
require_uppercase=False,
|
|
require_lowercase=False,
|
|
require_digit=False,
|
|
require_special=False,
|
|
)
|
|
ok, _ = v.validate("a")
|
|
assert ok is True
|
|
|
|
def test_all_disabled_empty_still_fails(self):
|
|
v = PasswordValidator(
|
|
min_length=1,
|
|
require_uppercase=False,
|
|
require_lowercase=False,
|
|
require_digit=False,
|
|
require_special=False,
|
|
)
|
|
ok, _ = v.validate("")
|
|
assert ok is False
|
|
|
|
|
|
# ==================== PasswordHasher ====================
|
|
|
|
|
|
class TestPasswordHasher:
|
|
@pytest.fixture
|
|
def hasher(self):
|
|
return PasswordHasher(rounds=4) # 用最低rounds加速测试
|
|
|
|
class TestHashPassword:
|
|
def test_hash_returns_string(self, hasher):
|
|
result = hasher.hash_password("testpassword")
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
|
|
def test_hash_starts_with_bcrypt_prefix(self, hasher):
|
|
result = hasher.hash_password("testpassword")
|
|
assert result.startswith("$2")
|
|
|
|
def test_hash_contains_rounds(self, hasher):
|
|
result = hasher.hash_password("testpassword")
|
|
parts = result.split("$")
|
|
assert parts[2] == "04" # bcrypt rounds格式是两位数
|
|
|
|
def test_hash_different_salts(self, hasher):
|
|
# 同一密码两次哈希结果不同(因为salt随机)
|
|
h1 = hasher.hash_password("samepassword")
|
|
h2 = hasher.hash_password("samepassword")
|
|
assert h1 != h2
|
|
|
|
def test_hash_empty_password_raises(self, hasher):
|
|
with pytest.raises(ValueError, match="empty"):
|
|
hasher.hash_password("")
|
|
|
|
def test_hash_unicode_password(self, hasher):
|
|
result = hasher.hash_password("密码Pass123!")
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
|
|
def test_hash_long_password(self, hasher):
|
|
long_pw = "a" * 72 # bcrypt最大72字节
|
|
result = hasher.hash_password(long_pw)
|
|
assert isinstance(result, str)
|
|
|
|
class TestVerifyPassword:
|
|
def test_verify_correct_password(self, hasher):
|
|
hashed = hasher.hash_password("CorrectPass123")
|
|
assert hasher.verify_password("CorrectPass123", hashed) is True
|
|
|
|
def test_verify_wrong_password(self, hasher):
|
|
hashed = hasher.hash_password("CorrectPass123")
|
|
assert hasher.verify_password("WrongPass123", hashed) is False
|
|
|
|
def test_verify_empty_password(self, hasher):
|
|
hashed = hasher.hash_password("testpass")
|
|
assert hasher.verify_password("", hashed) is False
|
|
|
|
def test_verify_empty_hash(self, hasher):
|
|
assert hasher.verify_password("testpass", "") is False
|
|
|
|
def test_verify_invalid_hash_format(self, hasher):
|
|
assert hasher.verify_password("testpass", "invalid-hash-format") is False
|
|
|
|
def test_verify_none_hash(self, hasher):
|
|
assert hasher.verify_password("testpass", None) is False
|
|
|
|
def test_verify_unicode_password(self, hasher):
|
|
pw = "密码Pass123!"
|
|
hashed = hasher.hash_password(pw)
|
|
assert hasher.verify_password(pw, hashed) is True
|
|
|
|
class TestNeedsRehash:
|
|
def test_same_rounds_no_rehash(self, hasher):
|
|
hashed = hasher.hash_password("testpass")
|
|
assert hasher.needs_rehash(hashed) is False
|
|
|
|
def test_lower_rounds_needs_rehash(self):
|
|
hasher_low = PasswordHasher(rounds=4)
|
|
hashed = hasher_low.hash_password("testpass")
|
|
|
|
hasher_high = PasswordHasher(rounds=5)
|
|
assert hasher_high.needs_rehash(hashed) is True
|
|
|
|
def test_higher_rounds_needs_rehash(self):
|
|
hasher_high = PasswordHasher(rounds=5)
|
|
hashed = hasher_high.hash_password("testpass")
|
|
|
|
hasher_low = PasswordHasher(rounds=4)
|
|
assert hasher_low.needs_rehash(hashed) is True
|
|
|
|
def test_invalid_hash_no_rehash(self, hasher):
|
|
assert hasher.needs_rehash("invalid-format") is False
|
|
|
|
def test_empty_hash_no_rehash(self, hasher):
|
|
assert hasher.needs_rehash("") is False
|
|
|
|
class TestInit:
|
|
def test_rounds_below_min_raises(self):
|
|
with pytest.raises(ValueError):
|
|
PasswordHasher(rounds=3)
|
|
|
|
def test_rounds_above_max_raises(self):
|
|
with pytest.raises(ValueError):
|
|
PasswordHasher(rounds=32)
|
|
|
|
def test_min_rounds_ok(self):
|
|
h = PasswordHasher(rounds=4)
|
|
assert h.rounds == 4
|
|
|
|
def test_max_rounds_ok(self):
|
|
h = PasswordHasher(rounds=31)
|
|
assert h.rounds == 31
|
|
|
|
|
|
# ==================== PasswordHandler ====================
|
|
|
|
|
|
class TestPasswordHandler:
|
|
@pytest.fixture
|
|
def handler(self):
|
|
return PasswordHandler(rounds=4)
|
|
|
|
def test_hash_and_verify_roundtrip(self, handler):
|
|
hashed = handler.hash_password("MySecurePass123")
|
|
assert handler.verify_password("MySecurePass123", hashed) is True
|
|
assert handler.verify_password("WrongPass", hashed) is False
|
|
|
|
def test_needs_rehash(self, handler):
|
|
# 用当前rounds哈希,不需要rehash
|
|
hashed = handler.hash_password("testpass")
|
|
assert handler.needs_rehash(hashed) is False
|
|
|
|
def test_validate_strength(self, handler):
|
|
# 强密码通过
|
|
ok, msg = handler.validate_strength("StrongPass123")
|
|
assert ok is True
|
|
assert msg is None
|
|
|
|
# 弱密码不通过
|
|
ok, msg = handler.validate_strength("weak")
|
|
assert ok is False
|
|
assert msg is not None
|
|
|
|
def test_hash_empty_raises(self, handler):
|
|
with pytest.raises(ValueError):
|
|
handler.hash_password("")
|
|
|
|
|
|
class TestGlobalHandler:
|
|
def test_get_password_handler_returns_instance(self):
|
|
# 重置全局实例
|
|
import packages.application.auth.password_handler as ph
|
|
|
|
ph._default_handler = None
|
|
|
|
handler = get_password_handler()
|
|
assert isinstance(handler, PasswordHandler)
|
|
|
|
def test_configure_password_handler(self):
|
|
handler = configure_password_handler(rounds=4)
|
|
assert isinstance(handler, PasswordHandler)
|
|
|
|
# get应该返回同一个配置好的实例
|
|
same_handler = get_password_handler()
|
|
assert same_handler is handler
|