feat: add comprehensive unit and integration testing infrastructure
- Backend: pytest with unit tests (auth, rbac, config) and integration tests (auth flow, docker, files) - Frontend: vitest with unit tests (api client) and component tests (login) - CI: Gitea Actions workflow for automated testing with coverage reports - Documentation: TESTING.md guide with setup, usage, and best practices - Coverage goals: 80%+ backend, 70%+ frontend
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||
"""
|
||||
import pytest
|
||||
import jwt
|
||||
import pyotp
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
user_from_access_token,
|
||||
verify_totp,
|
||||
load_totp_secret,
|
||||
save_totp_secret,
|
||||
load_password_hash,
|
||||
save_password_hash,
|
||||
increment_token_version,
|
||||
verify_token_version,
|
||||
_encrypt,
|
||||
_decrypt,
|
||||
load_passkey_credentials,
|
||||
save_passkey_credential,
|
||||
clear_passkey_credentials,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing and verification."""
|
||||
|
||||
def test_hash_and_verify_password(self):
|
||||
"""Test that password hashing and verification works."""
|
||||
password = "test_password_123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert hashed != password
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
"""Test that wrong password fails verification."""
|
||||
password = "correct_password"
|
||||
wrong_password = "wrong_password"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert not verify_password(wrong_password, hashed)
|
||||
|
||||
def test_different_hashes_for_same_password(self):
|
||||
"""Test that same password produces different hashes (salt)."""
|
||||
password = "test_password"
|
||||
hash1 = get_password_hash(password)
|
||||
hash2 = get_password_hash(password)
|
||||
|
||||
assert hash1 != hash2
|
||||
assert verify_password(password, hash1)
|
||||
assert verify_password(password, hash2)
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""Test JWT token creation and validation."""
|
||||
|
||||
def test_create_access_token(self, mock_config):
|
||||
"""Test access token creation."""
|
||||
data = {"sub": "testuser", "role": "admin"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "admin"
|
||||
assert payload["type"] == "access"
|
||||
assert "exp" in payload
|
||||
|
||||
def test_create_access_token_with_custom_expiry(self, mock_config):
|
||||
"""Test access token with custom expiration."""
|
||||
data = {"sub": "testuser"}
|
||||
expires_delta = timedelta(minutes=60)
|
||||
token = create_access_token(data, expires_delta)
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Should expire in approximately 60 minutes
|
||||
time_diff = (exp_time - now).total_seconds()
|
||||
assert 3500 < time_diff < 3700 # ~60 minutes with some tolerance
|
||||
|
||||
def test_create_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test refresh token creation."""
|
||||
data = {"sub": "testuser", "role": "member"}
|
||||
token = create_refresh_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "member"
|
||||
assert payload["type"] == "refresh"
|
||||
assert "tv" in payload # token version
|
||||
assert "exp" in payload
|
||||
|
||||
def test_user_from_valid_access_token(self, mock_config, temp_rbac_file):
|
||||
"""Test extracting user from valid access token."""
|
||||
token = create_access_token({"sub": "testuser", "role": "admin"})
|
||||
user = user_from_access_token(token)
|
||||
|
||||
assert user.username == "testuser"
|
||||
assert user.role == "admin"
|
||||
assert user.pages == "*"
|
||||
assert user.sidebar_links == "*"
|
||||
|
||||
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that expired token raises HTTPException."""
|
||||
token = create_access_token(
|
||||
{"sub": "testuser", "role": "admin"},
|
||||
expires_delta=timedelta(seconds=-1)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_invalid_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that invalid token raises HTTPException."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token("invalid.token.here")
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_refresh_token_fails(self, mock_config, temp_rbac_file, temp_auth_file):
|
||||
"""Test that refresh token cannot be used as access token."""
|
||||
token = create_refresh_token({"sub": "testuser", "role": "admin"})
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_token_without_username(self, mock_config, temp_rbac_file):
|
||||
"""Test that token without username fails."""
|
||||
# Create token without 'sub' field
|
||||
token = jwt.encode(
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)},
|
||||
mock_config.SECRET_KEY,
|
||||
algorithm=mock_config.ALGORITHM
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
"""Test encryption and decryption functions."""
|
||||
|
||||
def test_encrypt_decrypt(self, mock_config):
|
||||
"""Test basic encryption and decryption."""
|
||||
plaintext = "my-secret-totp-key"
|
||||
encrypted = _encrypt(plaintext)
|
||||
|
||||
assert encrypted != plaintext
|
||||
assert encrypted != ""
|
||||
|
||||
decrypted = _decrypt(encrypted)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_encrypt_empty_string(self, mock_config):
|
||||
"""Test encrypting empty string."""
|
||||
encrypted = _encrypt("")
|
||||
assert encrypted == ""
|
||||
|
||||
decrypted = _decrypt("")
|
||||
assert decrypted == ""
|
||||
|
||||
def test_decrypt_invalid_ciphertext(self, mock_config):
|
||||
"""Test decrypting invalid ciphertext returns original."""
|
||||
invalid = "not-a-valid-encrypted-string"
|
||||
decrypted = _decrypt(invalid)
|
||||
|
||||
# Should fallback to returning the original string
|
||||
assert decrypted == invalid
|
||||
|
||||
def test_encrypt_decrypt_unicode(self, mock_config):
|
||||
"""Test encryption with unicode characters."""
|
||||
plaintext = "测试密钥🔐"
|
||||
encrypted = _encrypt(plaintext)
|
||||
decrypted = _decrypt(encrypted)
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
|
||||
class TestTOTP:
|
||||
"""Test TOTP functionality."""
|
||||
|
||||
def test_save_and_load_totp_secret(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading TOTP secret."""
|
||||
secret = "JBSWY3DPEHPK3PXP"
|
||||
save_totp_secret(secret)
|
||||
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == secret
|
||||
|
||||
def test_load_totp_secret_from_env(self, mock_config, temp_auth_file, monkeypatch):
|
||||
"""Test loading TOTP secret from environment variable."""
|
||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||
import importlib
|
||||
import config
|
||||
importlib.reload(config)
|
||||
|
||||
# When no secret in file, should return env var
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == "ENV_SECRET_KEY"
|
||||
|
||||
def test_verify_totp_valid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying valid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
assert verify_totp(valid_code)
|
||||
|
||||
def test_verify_totp_invalid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying invalid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
assert not verify_totp("000000")
|
||||
|
||||
def test_verify_totp_no_secret_enabled(self, mock_config, temp_auth_file):
|
||||
"""Test that TOTP verification passes when 2FA not enabled."""
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
|
||||
def test_verify_totp_replay_protection(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test that same TOTP code cannot be used twice."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
# First use should succeed
|
||||
assert verify_totp(valid_code)
|
||||
|
||||
# Second use of same code should fail (replay protection)
|
||||
assert not verify_totp(valid_code)
|
||||
|
||||
|
||||
class TestPasswordPersistence:
|
||||
"""Test password hash persistence."""
|
||||
|
||||
def test_save_and_load_password_hash(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading password hash."""
|
||||
hashed = get_password_hash("new_password")
|
||||
save_password_hash(hashed)
|
||||
|
||||
loaded = load_password_hash()
|
||||
assert loaded == hashed
|
||||
|
||||
def test_load_password_hash_from_env(self, mock_config, temp_auth_file):
|
||||
"""Test loading password hash from environment variable."""
|
||||
# When no hash in file, should return env var
|
||||
loaded = load_password_hash()
|
||||
assert loaded == mock_config.ADMIN_PASSWORD_HASH
|
||||
|
||||
|
||||
class TestPasskeyCredentials:
|
||||
"""Test passkey credential management."""
|
||||
|
||||
def test_save_and_load_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading passkey credentials."""
|
||||
cred1 = {"id": "cred1", "public_key": "key1"}
|
||||
cred2 = {"id": "cred2", "public_key": "key2"}
|
||||
|
||||
save_passkey_credential(cred1)
|
||||
save_passkey_credential(cred2)
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 2
|
||||
assert cred1 in creds
|
||||
assert cred2 in creds
|
||||
|
||||
def test_clear_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test clearing all passkey credentials."""
|
||||
save_passkey_credential({"id": "cred1"})
|
||||
save_passkey_credential({"id": "cred2"})
|
||||
|
||||
clear_passkey_credentials()
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 0
|
||||
|
||||
|
||||
class TestTokenVersion:
|
||||
"""Test token version management for refresh token rotation."""
|
||||
|
||||
def test_increment_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test incrementing token version."""
|
||||
v1 = increment_token_version()
|
||||
assert v1 == 1
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert v2 == 2
|
||||
|
||||
v3 = increment_token_version()
|
||||
assert v3 == 3
|
||||
|
||||
def test_verify_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test verifying token version."""
|
||||
v1 = increment_token_version()
|
||||
assert verify_token_version(v1)
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert verify_token_version(v2)
|
||||
assert not verify_token_version(v1) # Old version should fail
|
||||
|
||||
def test_token_version_in_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test that refresh token includes current token version."""
|
||||
current_version = increment_token_version()
|
||||
token = create_refresh_token({"sub": "testuser"})
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["tv"] == current_version
|
||||
Reference in New Issue
Block a user