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:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View 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"