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>
523 lines
17 KiB
Python
523 lines
17 KiB
Python
"""
|
|
Unit tests for JWT with server_id parameter (US-006).
|
|
|
|
Tests cover:
|
|
- TokenData model includes server_id field
|
|
- create_access_token includes server_id in payload
|
|
- create_refresh_token includes server_id in payload
|
|
- create_token_response passes server_id to both methods
|
|
- refresh_access_token preserves server_id from refresh token
|
|
- verify_token correctly extracts server_id
|
|
- Middleware extracts server_id into request.state
|
|
|
|
US-006: JWT cu Server ID
|
|
|
|
Note: These tests mock dependencies where necessary to avoid importing
|
|
oracledb which requires Oracle Instant Client.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import MagicMock, AsyncMock, patch
|
|
import sys
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
# Add project paths
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../shared'))
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../'))
|
|
|
|
|
|
class TestTokenDataModel:
|
|
"""Tests for TokenData model with server_id."""
|
|
|
|
def test_token_data_has_server_id_field(self):
|
|
"""Test TokenData model has server_id field."""
|
|
from auth.jwt_handler import TokenData
|
|
|
|
# Check field exists in model
|
|
assert 'server_id' in TokenData.model_fields
|
|
|
|
def test_token_data_server_id_is_optional(self):
|
|
"""Test server_id field is optional with None default."""
|
|
from auth.jwt_handler import TokenData
|
|
|
|
field_info = TokenData.model_fields['server_id']
|
|
# Check that default is None (optional field)
|
|
assert field_info.default is None
|
|
|
|
def test_token_data_parses_server_id_from_payload(self):
|
|
"""Test TokenData correctly parses server_id from JWT payload."""
|
|
from auth.jwt_handler import TokenData
|
|
|
|
now = datetime.utcnow()
|
|
payload = {
|
|
"username": "testuser",
|
|
"user_id": 123,
|
|
"companies": ["FIRMA1"],
|
|
"permissions": ["read"],
|
|
"server_id": "romfast",
|
|
"exp": now + timedelta(hours=1),
|
|
"iat": now,
|
|
"type": "access"
|
|
}
|
|
|
|
token_data = TokenData(**payload)
|
|
|
|
assert token_data.server_id == "romfast"
|
|
assert token_data.username == "testuser"
|
|
|
|
def test_token_data_parses_null_server_id(self):
|
|
"""Test TokenData handles null server_id (single-server mode)."""
|
|
from auth.jwt_handler import TokenData
|
|
|
|
now = datetime.utcnow()
|
|
payload = {
|
|
"username": "testuser",
|
|
"companies": ["FIRMA1"],
|
|
"permissions": ["read"],
|
|
"server_id": None,
|
|
"exp": now + timedelta(hours=1),
|
|
"iat": now,
|
|
"type": "access"
|
|
}
|
|
|
|
token_data = TokenData(**payload)
|
|
|
|
assert token_data.server_id is None
|
|
|
|
|
|
class TestCreateAccessToken:
|
|
"""Tests for create_access_token with server_id."""
|
|
|
|
def test_create_access_token_signature_has_server_id(self):
|
|
"""Test create_access_token accepts server_id parameter."""
|
|
import inspect
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
sig = inspect.signature(JWTHandler.create_access_token)
|
|
params = list(sig.parameters.keys())
|
|
|
|
assert 'server_id' in params
|
|
assert sig.parameters['server_id'].default is None
|
|
|
|
def test_create_access_token_includes_server_id_in_payload(self):
|
|
"""Test server_id is included in JWT payload when provided."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1"],
|
|
server_id="romfast"
|
|
)
|
|
|
|
# Decode without verification to check payload
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] == "romfast"
|
|
assert payload['username'] == "testuser"
|
|
assert payload['type'] == "access"
|
|
|
|
def test_create_access_token_without_server_id(self):
|
|
"""Test token creation works without server_id (backward compatible)."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1"]
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] is None
|
|
assert payload['username'] == "testuser"
|
|
|
|
|
|
class TestCreateRefreshToken:
|
|
"""Tests for create_refresh_token with server_id."""
|
|
|
|
def test_create_refresh_token_signature_has_server_id(self):
|
|
"""Test create_refresh_token accepts server_id parameter."""
|
|
import inspect
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
sig = inspect.signature(JWTHandler.create_refresh_token)
|
|
params = list(sig.parameters.keys())
|
|
|
|
assert 'server_id' in params
|
|
assert sig.parameters['server_id'].default is None
|
|
|
|
def test_create_refresh_token_includes_server_id_in_payload(self):
|
|
"""Test server_id is included in refresh token payload."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_refresh_token(
|
|
username="testuser",
|
|
server_id="romfast"
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] == "romfast"
|
|
assert payload['type'] == "refresh"
|
|
|
|
def test_create_refresh_token_without_server_id(self):
|
|
"""Test refresh token works without server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_refresh_token(username="testuser")
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] is None
|
|
|
|
|
|
class TestCreateTokenResponse:
|
|
"""Tests for create_token_response with server_id."""
|
|
|
|
def test_create_token_response_signature_has_server_id(self):
|
|
"""Test create_token_response accepts server_id parameter."""
|
|
import inspect
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
sig = inspect.signature(JWTHandler.create_token_response)
|
|
params = list(sig.parameters.keys())
|
|
|
|
assert 'server_id' in params
|
|
assert sig.parameters['server_id'].default is None
|
|
|
|
def test_create_token_response_passes_server_id_to_both_tokens(self):
|
|
"""Test server_id is passed to both access and refresh tokens."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
response = handler.create_token_response(
|
|
username="testuser",
|
|
companies=["FIRMA1"],
|
|
server_id="romfast"
|
|
)
|
|
|
|
# Check access token
|
|
access_payload = jwt.decode(
|
|
response.access_token, "test-secret", algorithms=["HS256"]
|
|
)
|
|
assert access_payload['server_id'] == "romfast"
|
|
|
|
# Check refresh token
|
|
refresh_payload = jwt.decode(
|
|
response.refresh_token, "test-secret", algorithms=["HS256"]
|
|
)
|
|
assert refresh_payload['server_id'] == "romfast"
|
|
|
|
|
|
class TestRefreshAccessToken:
|
|
"""Tests for refresh_access_token preserving server_id."""
|
|
|
|
def test_refresh_access_token_preserves_server_id(self):
|
|
"""Test that server_id from refresh token is preserved in new access token."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Create refresh token with server_id
|
|
refresh_token = handler.create_refresh_token(
|
|
username="testuser",
|
|
server_id="romfast"
|
|
)
|
|
|
|
# Refresh to get new access token
|
|
new_access_token = handler.refresh_access_token(
|
|
refresh_token=refresh_token,
|
|
companies=["FIRMA1", "FIRMA2"]
|
|
)
|
|
|
|
assert new_access_token is not None
|
|
|
|
# Verify server_id is preserved
|
|
payload = jwt.decode(new_access_token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] == "romfast"
|
|
assert payload['companies'] == ["FIRMA1", "FIRMA2"]
|
|
|
|
def test_refresh_access_token_preserves_null_server_id(self):
|
|
"""Test that null server_id is preserved in refreshed token."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Create refresh token without server_id (single-server mode)
|
|
refresh_token = handler.create_refresh_token(username="testuser")
|
|
|
|
# Refresh
|
|
new_access_token = handler.refresh_access_token(
|
|
refresh_token=refresh_token,
|
|
companies=["FIRMA1"]
|
|
)
|
|
|
|
payload = jwt.decode(new_access_token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert payload['server_id'] is None
|
|
|
|
|
|
class TestVerifyToken:
|
|
"""Tests for verify_token with server_id extraction."""
|
|
|
|
def test_verify_token_extracts_server_id(self):
|
|
"""Test verify_token correctly extracts server_id from payload."""
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Create token with server_id
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1"],
|
|
server_id="romfast"
|
|
)
|
|
|
|
# Verify and extract
|
|
token_data = handler.verify_token(token)
|
|
|
|
assert token_data is not None
|
|
assert token_data.server_id == "romfast"
|
|
assert token_data.username == "testuser"
|
|
|
|
def test_verify_token_handles_null_server_id(self):
|
|
"""Test verify_token handles null server_id correctly."""
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1"]
|
|
)
|
|
|
|
token_data = handler.verify_token(token)
|
|
|
|
assert token_data is not None
|
|
assert token_data.server_id is None
|
|
|
|
|
|
class TestMiddlewareServerIdExtraction:
|
|
"""Tests for middleware extracting server_id into request.state."""
|
|
|
|
def test_middleware_create_current_user_preserves_token_data(self):
|
|
"""Test that middleware sets token_data which includes server_id."""
|
|
# The middleware sets request.state.token_data which contains server_id
|
|
# Read the source file directly to avoid oracledb dependency
|
|
middleware_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
'../../shared/auth/middleware.py'
|
|
)
|
|
with open(middleware_path, 'r') as f:
|
|
source = f.read()
|
|
|
|
# Verify the middleware sets token_data on request.state
|
|
assert 'request.state.token_data = token_data' in source
|
|
|
|
def test_middleware_extracts_server_id_from_token_data(self):
|
|
"""Test that middleware extracts server_id into request.state.server_id."""
|
|
# Read the source file directly to avoid oracledb dependency
|
|
middleware_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
'../../shared/auth/middleware.py'
|
|
)
|
|
with open(middleware_path, 'r') as f:
|
|
source = f.read()
|
|
|
|
# Verify the code contains server_id extraction
|
|
assert 'request.state.server_id' in source
|
|
|
|
|
|
class TestAuthServiceJWTIntegration:
|
|
"""Tests for auth_service passing server_id to jwt_handler."""
|
|
|
|
def test_authenticate_and_create_tokens_passes_server_id(self):
|
|
"""Test that auth_service passes server_id to jwt_handler."""
|
|
# Read the source file directly to avoid oracledb dependency
|
|
auth_service_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
'../../shared/auth/auth_service.py'
|
|
)
|
|
with open(auth_service_path, 'r') as f:
|
|
source = f.read()
|
|
|
|
# Verify server_id is passed to create_token_response
|
|
assert 'server_id=server_id' in source
|
|
|
|
|
|
class TestBackwardCompatibility:
|
|
"""Tests ensuring backward compatibility when server_id is not provided."""
|
|
|
|
def test_token_creation_without_server_id(self):
|
|
"""Test all token creation methods work without server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Access token
|
|
access = handler.create_access_token(username="user", companies=[])
|
|
assert access is not None
|
|
|
|
# Refresh token
|
|
refresh = handler.create_refresh_token(username="user")
|
|
assert refresh is not None
|
|
|
|
# Token response
|
|
response = handler.create_token_response(username="user", companies=[])
|
|
assert response is not None
|
|
assert response.access_token is not None
|
|
assert response.refresh_token is not None
|
|
|
|
def test_token_verification_without_server_id(self):
|
|
"""Test token verification works for tokens without server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Create token without server_id
|
|
token = handler.create_access_token(username="user", companies=[])
|
|
|
|
# Verify should work
|
|
token_data = handler.verify_token(token)
|
|
|
|
assert token_data is not None
|
|
assert token_data.username == "user"
|
|
assert token_data.server_id is None
|
|
|
|
|
|
class TestAcceptanceCriteria:
|
|
"""Tests validating all acceptance criteria for US-006."""
|
|
|
|
def test_ac1_jwt_handler_includes_server_id_in_payload(self):
|
|
"""AC1: jwt_handler.py include server_id în payload la generare token."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1"],
|
|
server_id="romfast"
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
assert 'server_id' in payload
|
|
assert payload['server_id'] == "romfast"
|
|
|
|
def test_ac2_middleware_extracts_server_id_to_request_state(self):
|
|
"""AC2: Middleware extrage server_id din token și îl pune în request.state."""
|
|
# Read the source file directly to avoid oracledb dependency
|
|
middleware_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
'../../shared/auth/middleware.py'
|
|
)
|
|
with open(middleware_path, 'r') as f:
|
|
source = f.read()
|
|
|
|
# Verify server_id is set on request.state
|
|
assert 'request.state.server_id' in source
|
|
|
|
def test_ac3_all_oracle_queries_should_use_server_id(self):
|
|
"""AC3: Toate query-urile Oracle folosesc request.state.server_id pentru pool."""
|
|
# This is an integration test - we verify the middleware sets server_id
|
|
# which should then be used by routes/services
|
|
|
|
from auth.jwt_handler import TokenData
|
|
|
|
# Verify TokenData has server_id field
|
|
assert 'server_id' in TokenData.model_fields
|
|
|
|
def test_ac4_jwt_decode_validates_server_id_presence(self):
|
|
"""AC4: JWT decode validează prezența server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
# Create and verify token with server_id
|
|
token = handler.create_access_token(
|
|
username="user",
|
|
companies=[],
|
|
server_id="romfast"
|
|
)
|
|
|
|
token_data = handler.verify_token(token)
|
|
|
|
assert token_data is not None
|
|
assert hasattr(token_data, 'server_id')
|
|
assert token_data.server_id == "romfast"
|
|
|
|
|
|
class TestJWTPayloadStructure:
|
|
"""Tests verifying JWT payload structure includes server_id."""
|
|
|
|
def test_access_token_payload_structure(self):
|
|
"""Test access token has complete payload structure with server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_access_token(
|
|
username="testuser",
|
|
companies=["FIRMA1", "FIRMA2"],
|
|
user_id=123,
|
|
permissions=["read", "write"],
|
|
server_id="romfast"
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
# Verify all expected fields
|
|
expected_fields = [
|
|
'username', 'user_id', 'companies', 'permissions',
|
|
'server_id', 'exp', 'iat', 'type'
|
|
]
|
|
|
|
for field in expected_fields:
|
|
assert field in payload, f"Missing field: {field}"
|
|
|
|
assert payload['server_id'] == "romfast"
|
|
assert payload['type'] == "access"
|
|
|
|
def test_refresh_token_payload_structure(self):
|
|
"""Test refresh token has correct payload structure with server_id."""
|
|
from auth.jwt_handler import JWTHandler
|
|
from jose import jwt
|
|
|
|
handler = JWTHandler(secret_key="test-secret")
|
|
|
|
token = handler.create_refresh_token(
|
|
username="testuser",
|
|
user_id=123,
|
|
server_id="romfast"
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
|
|
|
|
# Verify expected fields for refresh token
|
|
expected_fields = ['username', 'user_id', 'server_id', 'exp', 'iat', 'type']
|
|
|
|
for field in expected_fields:
|
|
assert field in payload, f"Missing field: {field}"
|
|
|
|
assert payload['server_id'] == "romfast"
|
|
assert payload['type'] == "refresh"
|