feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
280
tests/backend/test_check_identity_endpoint.py
Normal file
280
tests/backend/test_check_identity_endpoint.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Unit Tests for Check Identity Endpoint (US-013)
|
||||
|
||||
Tests the dual login support: email + username verification
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST CHECK IDENTITY REQUEST MODEL
|
||||
# ============================================================================
|
||||
|
||||
class TestCheckIdentityRequestModel:
|
||||
"""Tests for CheckIdentityRequest model validation."""
|
||||
|
||||
def test_valid_email_normalized_to_lowercase(self):
|
||||
"""Email inputs should be normalized to lowercase."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="User@Example.COM")
|
||||
assert request.identity == "user@example.com"
|
||||
|
||||
def test_valid_username_normalized_to_uppercase(self):
|
||||
"""Username inputs (without @) should be normalized to uppercase."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="marius")
|
||||
assert request.identity == "MARIUS"
|
||||
|
||||
def test_username_with_spaces_normalized(self):
|
||||
"""Username with spaces should be preserved but uppercased."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="marius m")
|
||||
assert request.identity == "MARIUS M"
|
||||
|
||||
def test_whitespace_trimmed(self):
|
||||
"""Leading/trailing whitespace should be trimmed."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity=" user@test.com ")
|
||||
assert request.identity == "user@test.com"
|
||||
|
||||
def test_empty_identity_raises_error(self):
|
||||
"""Empty identity should raise validation error."""
|
||||
from pydantic import ValidationError
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CheckIdentityRequest(identity="")
|
||||
|
||||
def test_too_short_identity_raises_error(self):
|
||||
"""Identity shorter than 2 chars should raise validation error."""
|
||||
from pydantic import ValidationError
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CheckIdentityRequest(identity="a")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST CHECK IDENTITY RESPONSE MODEL
|
||||
# ============================================================================
|
||||
|
||||
class TestCheckIdentityResponseModel:
|
||||
"""Tests for CheckIdentityResponse model."""
|
||||
|
||||
def test_response_with_email_type(self):
|
||||
"""Response should include identity_type field."""
|
||||
from shared.auth.models import CheckIdentityResponse, ServerInfo
|
||||
|
||||
response = CheckIdentityResponse(
|
||||
exists=True,
|
||||
servers=[ServerInfo(id="server1", name="Server 1")],
|
||||
identity_type="email"
|
||||
)
|
||||
assert response.exists is True
|
||||
assert response.identity_type == "email"
|
||||
assert len(response.servers) == 1
|
||||
|
||||
def test_response_with_username_type(self):
|
||||
"""Response should support username identity type."""
|
||||
from shared.auth.models import CheckIdentityResponse
|
||||
|
||||
response = CheckIdentityResponse(
|
||||
exists=True,
|
||||
servers=[],
|
||||
identity_type="username"
|
||||
)
|
||||
assert response.identity_type == "username"
|
||||
|
||||
def test_response_default_identity_type(self):
|
||||
"""Default identity_type should be 'unknown'."""
|
||||
from shared.auth.models import CheckIdentityResponse
|
||||
|
||||
response = CheckIdentityResponse(exists=False, servers=[])
|
||||
assert response.identity_type == "unknown"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST IDENTITY TYPE DETECTION
|
||||
# ============================================================================
|
||||
|
||||
class TestIdentityTypeDetection:
|
||||
"""Tests for email vs username detection logic."""
|
||||
|
||||
def test_email_detected_by_at_sign(self):
|
||||
"""Identity with @ should be treated as email."""
|
||||
# This is tested via the model validator
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="test@example.com")
|
||||
# Email should be lowercase
|
||||
assert request.identity == "test@example.com"
|
||||
assert "@" in request.identity
|
||||
|
||||
def test_username_detected_without_at_sign(self):
|
||||
"""Identity without @ should be treated as username."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="MARIUS")
|
||||
# Username should be uppercase
|
||||
assert request.identity == "MARIUS"
|
||||
assert "@" not in request.identity
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST EMAIL SERVER CACHE USERNAME LOOKUP
|
||||
# ============================================================================
|
||||
|
||||
class TestEmailServerCacheUsernameLookup:
|
||||
"""Tests for username lookup in EmailServerCache."""
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache(self):
|
||||
"""Reset the cache singleton before each test."""
|
||||
from shared.auth.email_server_cache import EmailServerCache
|
||||
|
||||
# Reset singleton
|
||||
EmailServerCache._instance = None
|
||||
yield
|
||||
EmailServerCache._instance = None
|
||||
|
||||
def test_get_servers_for_username_method_exists(self, reset_cache):
|
||||
"""EmailServerCache should have get_servers_for_username method."""
|
||||
from shared.auth.email_server_cache import EmailServerCache
|
||||
|
||||
cache = EmailServerCache()
|
||||
assert hasattr(cache, 'get_servers_for_username')
|
||||
assert callable(cache.get_servers_for_username)
|
||||
|
||||
def test_empty_username_returns_empty_list(self, reset_cache):
|
||||
"""Empty username should return empty list."""
|
||||
import asyncio
|
||||
from shared.auth.email_server_cache import EmailServerCache
|
||||
|
||||
cache = EmailServerCache()
|
||||
|
||||
async def test():
|
||||
# Mock settings to return empty servers
|
||||
with patch('backend.config.settings') as mock_settings:
|
||||
mock_settings.get_oracle_servers.return_value = []
|
||||
result = await cache.get_servers_for_username("")
|
||||
return result
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(test())
|
||||
assert result == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST BACKWARD COMPATIBILITY
|
||||
# ============================================================================
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Tests for backward compatibility with check-email endpoint."""
|
||||
|
||||
def test_check_email_request_still_works(self):
|
||||
"""CheckEmailRequest should still work for backward compatibility."""
|
||||
from shared.auth.models import CheckEmailRequest
|
||||
|
||||
request = CheckEmailRequest(email="user@example.com")
|
||||
assert request.email == "user@example.com"
|
||||
|
||||
def test_check_email_response_still_works(self):
|
||||
"""CheckEmailResponse should still work for backward compatibility."""
|
||||
from shared.auth.models import CheckEmailResponse, ServerInfo
|
||||
|
||||
response = CheckEmailResponse(
|
||||
exists=True,
|
||||
servers=[ServerInfo(id="s1", name="Server 1")]
|
||||
)
|
||||
assert response.exists is True
|
||||
assert len(response.servers) == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST ACCEPTANCE CRITERIA (US-013)
|
||||
# ============================================================================
|
||||
|
||||
class TestAcceptanceCriteria:
|
||||
"""Tests verifying US-013 acceptance criteria."""
|
||||
|
||||
def test_ac1_check_identity_request_model_exists(self):
|
||||
"""AC1: CheckIdentityRequest model exists."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
assert CheckIdentityRequest is not None
|
||||
|
||||
def test_ac2_email_detection_with_at_sign(self):
|
||||
"""AC2: Input with @ is treated as email."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="test@domain.com")
|
||||
# Email normalized to lowercase
|
||||
assert "@" in request.identity
|
||||
assert request.identity.islower()
|
||||
|
||||
def test_ac3_username_detection_without_at_sign(self):
|
||||
"""AC3: Input without @ is treated as username."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="admin")
|
||||
# Username normalized to uppercase
|
||||
assert "@" not in request.identity
|
||||
assert request.identity == "ADMIN"
|
||||
|
||||
def test_ac4_check_email_backward_compatible(self):
|
||||
"""AC4: Old check-email models still work."""
|
||||
from shared.auth.models import CheckEmailRequest, CheckEmailResponse
|
||||
|
||||
# Both models should be importable and usable
|
||||
req = CheckEmailRequest(email="test@test.com")
|
||||
resp = CheckEmailResponse(exists=False, servers=[])
|
||||
|
||||
assert req.email == "test@test.com"
|
||||
assert resp.exists is False
|
||||
|
||||
def test_ac5_response_includes_identity_type(self):
|
||||
"""AC5: Response includes identity_type field."""
|
||||
from shared.auth.models import CheckIdentityResponse
|
||||
|
||||
response = CheckIdentityResponse(
|
||||
exists=True,
|
||||
servers=[],
|
||||
identity_type="email"
|
||||
)
|
||||
assert hasattr(response, 'identity_type')
|
||||
assert response.identity_type in ["email", "username", "unknown"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST UI REQUIREMENTS
|
||||
# ============================================================================
|
||||
|
||||
class TestUIRequirements:
|
||||
"""Tests verifying UI-related requirements."""
|
||||
|
||||
def test_placeholder_label_correct(self):
|
||||
"""UI should use 'Email sau utilizator' as label."""
|
||||
# This is a documentation test - the actual UI change is in Vue
|
||||
# We verify the backend accepts both formats
|
||||
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
# Should accept email format
|
||||
email_req = CheckIdentityRequest(identity="user@example.com")
|
||||
assert email_req.identity == "user@example.com"
|
||||
|
||||
# Should accept username format
|
||||
username_req = CheckIdentityRequest(identity="UTILIZATOR")
|
||||
assert username_req.identity == "UTILIZATOR"
|
||||
|
||||
def test_username_with_romanian_chars_handled(self):
|
||||
"""Username with spaces (like 'MARIUS M') should be handled."""
|
||||
from shared.auth.models import CheckIdentityRequest
|
||||
|
||||
request = CheckIdentityRequest(identity="marius m")
|
||||
assert request.identity == "MARIUS M"
|
||||
Reference in New Issue
Block a user