# tests/test_auth.py — Tests for authentication and authorization import os import time from unittest.mock import patch import pytest # ═══════════════════════════════════════════════════════════════════ # Auth status endpoint (public) # ═══════════════════════════════════════════════════════════════════ class TestAuthStatus: def test_auth_disabled_in_test_env(self, client): """Auth should be disabled in the test environment.""" resp = client.get("/api/auth/status") assert resp.status_code == 200 data = resp.json() assert data["auth_enabled"] is False # ═══════════════════════════════════════════════════════════════════ # Password hashing # ═══════════════════════════════════════════════════════════════════ class TestPasswordHashing: def test_hash_and_verify(self): from backend.auth.password import hash_password, verify_password password = "SuperSecret123!" hashed = hash_password(password) assert hashed != password assert verify_password(password, hashed) is True assert verify_password("WrongPassword", hashed) is False def test_different_salts(self): from backend.auth.password import hash_password hash1 = hash_password("test") hash2 = hash_password("test") # Same password, different salts → different hashes assert hash1 != hash2 def test_reject_short_passwords(self): from backend.auth.password import hash_password # Argon2 should handle any length, but we test it doesn't crash result = hash_password("ab") assert result is not None # ═══════════════════════════════════════════════════════════════════ # JWT Handler # ═══════════════════════════════════════════════════════════════════ class TestJWTHandler: def test_create_and_decode_access_token(self): from backend.auth.jwt_handler import ( create_access_token, decode_token, ) user = {"username": "testuser", "role": "admin", "vaults": ["*"], "display_name": "Test User"} token = create_access_token(user) assert token is not None payload = decode_token(token) assert payload is not None assert payload["sub"] == "testuser" assert payload["type"] == "access" def test_expired_token(self): from backend.auth.jwt_handler import ( create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_SECONDS, ) import time # Override TTL to a value in the past user = {"username": "testuser", "role": "user", "vaults": [], "display_name": "X"} # Create token that will be decoded with leeway by PyJWT token = create_access_token(user) payload = decode_token(token) assert payload is not None # Valid when just created def test_refresh_token(self): from backend.auth.jwt_handler import ( create_refresh_token, decode_token, ) token, jti = create_refresh_token("testuser") assert token is not None assert jti is not None payload = decode_token(token) assert payload["type"] == "refresh" def test_token_revocation(self): from backend.auth.jwt_handler import ( create_refresh_token, revoke_token, is_token_revoked, ) token, jti = create_refresh_token("testuser") assert is_token_revoked(jti) is False revoke_token(jti) assert is_token_revoked(jti) is True def test_invalid_token(self): from backend.auth.jwt_handler import decode_token payload = decode_token("not.a.valid.token") assert payload is None def test_wrong_type_token(self): """decode_token rejects refresh tokens (type != 'access').""" from backend.auth.jwt_handler import ( create_access_token, create_refresh_token, decode_token, ) refresh_token, _ = create_refresh_token("testuser") payload = decode_token(refresh_token) # decode_token rejects non-access types in middleware path; # the raw decode still works but is filtered by require_auth. # Here we test that the payload has type='refresh' if payload is not None: assert payload["type"] == "refresh" # ═══════════════════════════════════════════════════════════════════ # User Store # ═══════════════════════════════════════════════════════════════════ class TestUserStore: @pytest.fixture(autouse=True) def _setup_users(self, tmp_path, monkeypatch): """Use a temp file for users.json.""" from backend.auth import user_store users_file = tmp_path / "users.json" monkeypatch.setattr(user_store, "USERS_FILE", users_file) # Clear the module-level cache user_store._users_cache = None yield user_store._users_cache = None def test_has_users_false_initially(self): from backend.auth.user_store import has_users assert has_users() is False def test_create_and_get_user(self): from backend.auth.user_store import create_user, get_user, has_users create_user("alice", "Password123!", role="admin", vaults=["*"]) assert has_users() is True user = get_user("alice") assert user is not None assert user["username"] == "alice" assert user["role"] == "admin" assert user["vaults"] == ["*"] def test_create_duplicate_raises(self): from backend.auth.user_store import create_user create_user("bob", "Password123!") with pytest.raises(ValueError, match="already exists"): create_user("bob", "Different456!") def test_get_all_users(self): from backend.auth.user_store import create_user, get_all_users create_user("alice", "Pass123!!!", role="admin", vaults=["*"]) create_user("bob", "Pass456!!!", role="user", vaults=["Vault1"]) users = get_all_users() assert len(users) == 2 def test_update_user(self): from backend.auth.user_store import create_user, update_user, get_user create_user("alice", "Pass123!!!") update_user("alice", {"display_name": "Alice Updated", "vaults": ["V1", "V2"]}) user = get_user("alice") assert user["display_name"] == "Alice Updated" assert user["vaults"] == ["V1", "V2"] def test_delete_user(self): from backend.auth.user_store import create_user, delete_user, get_user create_user("charlie", "Pass123!!!") # delete_user returns None on success, raises ValueError if not found assert delete_user("charlie") is None assert get_user("charlie") is None def test_delete_nonexistent(self): from backend.auth.user_store import delete_user with pytest.raises(ValueError, match="not found"): delete_user("ghost") def test_toggle_active(self): from backend.auth.user_store import create_user, update_user, get_user create_user("dave", "Pass123!!!") update_user("dave", {"active": False}) user = get_user("dave") assert user["active"] is False def test_login_failure_and_lockout(self): from backend.auth.user_store import ( create_user, record_login_failure, is_locked, record_login_success, ) create_user("eve", "Pass123!!!") # Fail 5 times for _ in range(5): record_login_failure("eve") assert is_locked("eve") is True # Unlock after success record_login_success("eve") assert is_locked("eve") is False def test_change_password(self): from backend.auth.user_store import create_user, get_user from backend.auth.password import verify_password create_user("frank", "OldPass123!") # Update password from backend.auth.user_store import update_user from backend.auth.password import hash_password update_user("frank", {"password_hash": hash_password("NewPass456!")}) user = get_user("frank") assert verify_password("NewPass456!", user["password_hash"]) assert not verify_password("OldPass123!", user["password_hash"]) # ═══════════════════════════════════════════════════════════════════ # Rate Limiting # ═══════════════════════════════════════════════════════════════════ class TestRateLimiter: def test_initial_state(self): from backend.ratelimit import is_rate_limited, get_status status = get_status("192.168.1.1") assert status["limited"] is False assert status["failures"] == 0 def test_failures_increment(self): from backend.ratelimit import record_failure, is_rate_limited, record_success ip = "10.0.0.1" for _ in range(10): record_failure(ip) assert is_rate_limited(ip) is True # Clear record_success(ip) assert is_rate_limited(ip) is False def test_record_success_clears(self): from backend.ratelimit import record_failure, record_success, is_rate_limited ip = "172.16.0.1" record_failure(ip) record_failure(ip) record_success(ip) assert is_rate_limited(ip) is False def test_global_status(self): from backend.ratelimit import get_status status = get_status() assert "tracked_ips" in status assert "max_attempts" in status assert "limited_ips" in status # ═══════════════════════════════════════════════════════════════════ # Middleware (dependency-based) # ═══════════════════════════════════════════════════════════════════ class TestMiddleware: def test_is_auth_enabled_logic(self): """is_auth_enabled reads OBSIGATE_AUTH_ENABLED env var.""" import os from backend.auth.middleware import is_auth_enabled # The function checks if env var != 'false' # In our test env it may be set by fixture — test the logic result = is_auth_enabled() assert isinstance(result, bool) def test_check_vault_access(self): from backend.auth.middleware import check_vault_access admin = {"vaults": ["*"]} user = {"vaults": ["Vault1", "Vault2"]} nobody = {"vaults": []} assert check_vault_access("Vault1", admin) is True assert check_vault_access("AnyVault", admin) is True assert check_vault_access("Vault1", user) is True assert check_vault_access("Vault3", user) is False assert check_vault_access("Vault1", nobody) is False