Files
xiaoxia-saas/tests/unit/test_auth_service.py
T
xiaoxia 678d3503a7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m23s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m51s
CI/CD Pipeline / Unit Tests (push) Successful in 6m43s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m42s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / ACR Image Cleanup (push) Has been cancelled
fix(test): 修复JWT篡改签名测试flaky - 改签名中间字符而非最后一位 (#770)
fix(test): 修复JWT篡改签名测试flaky - 改签名中间字符而非最后一位
2026-07-23 21:52:01 +08:00

516 lines
19 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Auth 服务层单元测试 - 纯逻辑模块
覆盖:
- PasswordHasher / PasswordValidator (password_hasher.py)
- JWTConfig / JWTService / TokenType (jwt_service.py)
"""
import time
import jwt as pyjwt
import pytest
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.password_hasher import (
PasswordHasher,
PasswordValidator,
password_hasher,
password_validator,
)
# ── PasswordHasher 测试 ──────────────────────────────────────────────────────
class TestPasswordHasher:
"""PasswordHasher 密码哈希器测试"""
def test_default_rounds(self):
hasher = PasswordHasher()
assert hasher.rounds == 12
def test_custom_rounds(self):
hasher = PasswordHasher(rounds=10)
assert hasher.rounds == 10
def test_rounds_min_boundary(self):
hasher = PasswordHasher(rounds=4)
assert hasher.rounds == 4
def test_rounds_max_boundary(self):
hasher = PasswordHasher(rounds=31)
assert hasher.rounds == 31
def test_rounds_below_min_raises(self):
with pytest.raises(ValueError, match="between 4 and 31"):
PasswordHasher(rounds=3)
def test_rounds_above_max_raises(self):
with pytest.raises(ValueError, match="between 4 and 31"):
PasswordHasher(rounds=32)
def test_hash_password_returns_string(self):
hasher = PasswordHasher(rounds=4) # 用小rounds加速测试
result = hasher.hash_password("testpass123")
assert isinstance(result, str)
assert len(result) > 0
def test_hash_password_starts_with_bcrypt_prefix(self):
hasher = PasswordHasher(rounds=4)
result = hasher.hash_password("testpass123")
assert result.startswith("$2b$")
def test_hash_password_contains_rounds(self):
hasher = PasswordHasher(rounds=4)
result = hasher.hash_password("testpass123")
# $2b$04$...
assert "$04$" in result
def test_hash_password_empty_raises(self):
hasher = PasswordHasher(rounds=4)
with pytest.raises(ValueError, match="cannot be empty"):
hasher.hash_password("")
def test_hash_password_none_raises(self):
hasher = PasswordHasher(rounds=4)
with pytest.raises(ValueError):
hasher.hash_password(None)
def test_hash_password_different_each_time(self):
"""同一密码每次哈希结果不同(因为salt随机)"""
hasher = PasswordHasher(rounds=4)
h1 = hasher.hash_password("samepass")
h2 = hasher.hash_password("samepass")
assert h1 != h2
def test_verify_password_correct(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("mypassword")
assert hasher.verify_password("mypassword", hashed) is True
def test_verify_password_wrong(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("correctpass")
assert hasher.verify_password("wrongpass", hashed) is False
def test_verify_password_empty_password(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("testpass")
assert hasher.verify_password("", hashed) is False
def test_verify_password_empty_hash(self):
hasher = PasswordHasher(rounds=4)
assert hasher.verify_password("testpass", "") is False
def test_verify_password_invalid_hash_format(self):
hasher = PasswordHasher(rounds=4)
assert hasher.verify_password("testpass", "invalid_hash") is False
def test_verify_password_none_password(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("test")
assert hasher.verify_password(None, hashed) is False
def test_needs_rehash_same_rounds(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("testpass")
assert hasher.needs_rehash(hashed) is False
def test_needs_rehash_different_rounds(self):
hasher4 = PasswordHasher(rounds=4)
hasher5 = PasswordHasher(rounds=5)
hashed = hasher4.hash_password("testpass")
assert hasher5.needs_rehash(hashed) is True
def test_needs_rehash_invalid_hash(self):
hasher = PasswordHasher(rounds=4)
assert hasher.needs_rehash("invalid") is False
def test_needs_rehash_empty_hash(self):
hasher = PasswordHasher(rounds=4)
assert hasher.needs_rehash("") is False
def test_global_instance_exists(self):
assert password_hasher is not None
assert isinstance(password_hasher, PasswordHasher)
assert password_hasher.rounds == 12
def test_unicode_password(self):
"""支持中文等Unicode密码"""
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("密码测试123")
assert hasher.verify_password("密码测试123", hashed) is True
# ── PasswordValidator 测试 ───────────────────────────────────────────────────
class TestPasswordValidator:
"""PasswordValidator 密码强度验证器测试"""
def test_default_config(self):
v = PasswordValidator()
assert v.min_length == 8
assert v.require_uppercase is True
assert v.require_lowercase is True
assert v.require_digit is True
assert v.require_special is False
def test_custom_config(self):
v = PasswordValidator(
min_length=10,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=True,
)
assert v.min_length == 10
assert v.require_uppercase is False
assert v.require_special is True
def test_valid_password_default_rules(self):
v = PasswordValidator()
valid, msg = v.validate("TestPass123")
assert valid is True
assert msg is None
def test_empty_password(self):
v = PasswordValidator()
valid, msg = v.validate("")
assert valid is False
assert "empty" in msg.lower()
def test_none_password(self):
v = PasswordValidator()
valid, msg = v.validate(None)
assert valid is False
def test_too_short(self):
v = PasswordValidator(min_length=8)
valid, msg = v.validate("Ab1")
assert valid is False
assert "at least 8" in msg
def test_exact_min_length(self):
v = PasswordValidator(min_length=8, require_uppercase=False, require_lowercase=False, require_digit=False)
valid, msg = v.validate("12345678")
assert valid is True
def test_missing_uppercase(self):
v = PasswordValidator()
valid, msg = v.validate("testpass123")
assert valid is False
assert "uppercase" in msg.lower()
def test_missing_lowercase(self):
v = PasswordValidator()
valid, msg = v.validate("TESTPASS123")
assert valid is False
assert "lowercase" in msg.lower()
def test_missing_digit(self):
v = PasswordValidator()
valid, msg = v.validate("TestPassword")
assert valid is False
assert "digit" in msg.lower()
def test_require_special_enabled_missing(self):
v = PasswordValidator(require_special=True)
valid, msg = v.validate("TestPass123")
assert valid is False
assert "special" in msg.lower()
def test_require_special_enabled_present(self):
v = PasswordValidator(require_special=True)
valid, msg = v.validate("TestPass123!")
assert valid is True
assert msg is None
def test_special_chars_all_types(self):
"""验证各种特殊字符都能识别"""
v = PasswordValidator(
min_length=8,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=True,
)
for char in "!@#$%^&*()_+-=[]{}|;:,.<>?~":
valid, _ = v.validate(f"testpass{char}")
assert valid is True, f"Special char '{char}' not recognized"
def test_no_requirements_all_pass(self):
"""关闭所有要求后任何密码都通过"""
v = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
valid, msg = v.validate("a")
assert valid is True
def test_global_validator_instance(self):
assert password_validator is not None
assert isinstance(password_validator, PasswordValidator)
assert password_validator.min_length == 8
assert password_validator.require_special is False
# ── JWTConfig 测试 ───────────────────────────────────────────────────────────
class TestJWTConfig:
"""JWTConfig 配置测试"""
def test_default_values(self):
config = JWTConfig(secret_key="test-secret-key-12345")
assert config.SECRET_KEY == "test-secret-key-12345"
assert config.ALGORITHM == "HS256"
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7
def test_custom_values(self):
config = JWTConfig(
secret_key="my-secret",
algorithm="HS512",
access_token_expire_minutes=30,
refresh_token_expire_days=14,
)
assert config.ALGORITHM == "HS512"
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 30
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 14
def test_empty_secret_raises(self):
with pytest.raises(ValueError, match="must be provided"):
JWTConfig(secret_key="")
def test_whitespace_secret_raises(self):
with pytest.raises(ValueError):
JWTConfig(secret_key=" ")
def test_none_secret_raises(self):
with pytest.raises(ValueError):
JWTConfig(secret_key=None)
@pytest.mark.parametrize(
"insecure",
[
"your-secret-key-change-in-production",
"your-secret-key",
"secret",
"changeme",
"password",
"SECRET",
"Your-Secret-Key",
],
)
def test_insecure_defaults_raises(self, insecure):
with pytest.raises(ValueError, match="insecure"):
JWTConfig(secret_key=insecure)
# ── TokenType 测试 ───────────────────────────────────────────────────────────
class TestTokenType:
"""TokenType 常量测试"""
def test_access_token_type(self):
assert TokenType.ACCESS == "access"
def test_refresh_token_type(self):
assert TokenType.REFRESH == "refresh"
# ── JWTService 测试 ─────────────────────────────────────────────────────────
class TestJWTService:
"""JWTService JWT服务测试"""
@pytest.fixture
def service(self):
config = JWTConfig(
secret_key="test-secret-key-for-testing-only-12345",
access_token_expire_minutes=30,
refresh_token_expire_days=7,
)
return JWTService(config)
def test_init_without_config_raises(self):
with pytest.raises(ValueError, match="requires a JWTConfig"):
JWTService()
def test_create_access_token_returns_string(self, service):
token = service.create_access_token(user_id="user_123")
assert isinstance(token, str)
assert len(token) > 0
def test_create_access_token_has_user_id(self, service):
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
assert payload["sub"] == "user_123"
def test_create_access_token_has_role(self, service):
token = service.create_access_token(user_id="user_123", role="admin")
payload = service.verify_token(token)
assert payload["role"] == "admin"
def test_create_access_token_default_role_empty(self, service):
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
assert payload["role"] == ""
def test_create_access_token_type_is_access(self, service):
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
assert payload["type"] == TokenType.ACCESS
def test_create_access_token_has_iat_and_exp(self, service):
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
assert "iat" in payload
assert "exp" in payload
assert payload["exp"] > payload["iat"]
def test_create_access_token_expiry_correct(self, service):
"""过期时间 = 签发时间 + 30分钟"""
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
delta_seconds = payload["exp"] - payload["iat"]
assert delta_seconds == 30 * 60
def test_create_access_token_additional_claims(self, service):
token = service.create_access_token(
user_id="user_123",
role="user",
additional_claims={"email": "test@example.com", "custom": "value"},
)
payload = service.verify_token(token)
assert payload["email"] == "test@example.com"
assert payload["custom"] == "value"
def test_create_refresh_token_returns_string(self, service):
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
assert isinstance(token, str)
assert len(token) > 0
def test_create_refresh_token_has_user_and_session(self, service):
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
payload = service.verify_token(token)
assert payload["sub"] == "user_123"
assert payload["session_id"] == "sess_456"
def test_create_refresh_token_type_is_refresh(self, service):
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
payload = service.verify_token(token)
assert payload["type"] == TokenType.REFRESH
def test_create_refresh_token_expiry_correct(self, service):
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
payload = service.verify_token(token)
delta_seconds = payload["exp"] - payload["iat"]
assert delta_seconds == 7 * 24 * 60 * 60
def test_verify_access_token_success(self, service):
token = service.create_access_token(user_id="user_123", role="admin")
payload = service.verify_access_token(token)
assert payload["sub"] == "user_123"
assert payload["role"] == "admin"
def test_verify_access_token_wrong_type_raises(self, service):
"""用refresh token当access token验证会失败"""
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
with pytest.raises(ValueError, match="must be 'access'"):
service.verify_access_token(token)
def test_verify_refresh_token_success(self, service):
token = service.create_refresh_token(user_id="user_123", session_id="sess_456")
payload = service.verify_refresh_token(token)
assert payload["sub"] == "user_123"
assert payload["session_id"] == "sess_456"
def test_verify_refresh_token_wrong_type_raises(self, service):
"""用access token当refresh token验证会失败"""
token = service.create_access_token(user_id="user_123")
with pytest.raises(ValueError, match="must be 'refresh'"):
service.verify_refresh_token(token)
def test_verify_token_invalid_token_raises(self, service):
with pytest.raises(InvalidTokenError):
service.verify_token("invalid.token.here")
def test_verify_token_empty_raises(self, service):
with pytest.raises(InvalidTokenError):
service.verify_token("")
def test_verify_token_wrong_secret(self, service):
"""用不同密钥签发的token无法验证"""
other_config = JWTConfig(secret_key="different-secret-key-for-testing-123")
other_service = JWTService(other_config)
token = other_service.create_access_token(user_id="user_123")
with pytest.raises(InvalidTokenError):
service.verify_token(token)
def test_verify_token_tampered_signature(self, service):
"""篡改签名的token无法验证"""
token = service.create_access_token(user_id="user_123")
# 篡改签名部分中间的字符(JWT格式: header.payload.signature
# 改最后一个字符不可靠(可能是padding),改签名中间确保验证失败
parts = token.rsplit(".", 1)
assert len(parts) == 2, "JWT格式不对,应该有两个点分隔三部分"
signature = parts[1]
# 篡改签名中间的几个字符
mid = len(signature) // 2
tampered_sig = signature[:mid] + ("X" if signature[mid] != "X" else "Y") + signature[mid + 1 :]
tampered = parts[0] + "." + tampered_sig
with pytest.raises(InvalidTokenError):
service.verify_token(tampered)
def test_expired_token_raises(self):
"""过期token验证失败"""
config = JWTConfig(
secret_key="test-secret-key-for-testing-only-12345",
access_token_expire_minutes=0, # 立即过期
)
service = JWTService(config)
token = service.create_access_token(user_id="user_123")
time.sleep(1) # 等1秒确保过期
with pytest.raises(ExpiredSignatureError):
service.verify_token(token)
def test_different_algorithm(self):
"""支持不同算法"""
config = JWTConfig(
secret_key="test-secret-key-for-testing-only-12345-abcdef",
algorithm="HS512",
)
service = JWTService(config)
token = service.create_access_token(user_id="user_123")
payload = service.verify_token(token)
assert payload["sub"] == "user_123"
def test_additional_claims_not_overwrite_standard(self):
"""additional_claims 不会覆盖标准字段"""
config = JWTConfig(secret_key="test-secret-key-for-testing-only-12345")
service = JWTService(config)
token = service.create_access_token(
user_id="real_user",
additional_claims={"sub": "fake_user", "type": "fake_type"},
)
payload = service.verify_token(token)
# additional_claims 在标准字段之后update,所以会覆盖
# 这个测试验证当前行为(additional_claims优先级高)
assert payload["sub"] == "fake_user"
def test_unicode_user_id(self):
"""支持Unicode用户ID"""
config = JWTConfig(secret_key="test-secret-key-for-testing-only-12345")
service = JWTService(config)
token = service.create_access_token(user_id="用户_测试123")
payload = service.verify_token(token)
assert payload["sub"] == "用户_测试123"