Spaces:
Sleeping
Sleeping
| """Integration tests for authentication flow.""" | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from unittest.mock import Mock, patch, AsyncMock | |
| import json | |
| import base64 | |
| from typing import Dict, Any | |
| from main import app | |
| from infrastructure.services.jwt_validation_service import JWTValidationService | |
| class TestAuthenticationFlow: | |
| """Test complete authentication flow from request to response.""" | |
| def setup_method(self): | |
| """Set up test fixtures.""" | |
| self.client = TestClient(app) | |
| # Create valid JWT token structure for testing | |
| self.valid_token_header = {"alg": "HS256", "typ": "JWT"} | |
| self.valid_token_payload = {"sub": "test-user", "iat": 1234567890} | |
| # Create properly formatted JWT token (header.payload.signature) | |
| header_b64 = base64.urlsafe_b64encode( | |
| json.dumps(self.valid_token_header).encode() | |
| ).decode().rstrip('=') | |
| payload_b64 = base64.urlsafe_b64encode( | |
| json.dumps(self.valid_token_payload).encode() | |
| ).decode().rstrip('=') | |
| signature = "test-signature" | |
| self.valid_jwt_token = f"{header_b64}.{payload_b64}.{signature}" | |
| self.valid_auth_header = f"Bearer {self.valid_jwt_token}" | |
| def test_missing_authorization_header_returns_401(self): | |
| """Test that missing Authorization header returns 401.""" | |
| response = self.client.get("/api/v1/jobs/test-job-id") | |
| assert response.status_code == 401 | |
| assert response.json()["error"] == "Missing Authorization header" | |
| assert "WWW-Authenticate" in response.headers | |
| assert response.headers["WWW-Authenticate"] == "Bearer" | |
| def test_invalid_authorization_header_format_returns_401(self): | |
| """Test that invalid Authorization header format returns 401.""" | |
| invalid_headers = [ | |
| {"Authorization": "Invalid token"}, # Missing Bearer prefix | |
| {"Authorization": "Basic dGVzdA=="}, # Wrong auth type | |
| {"Authorization": "Bearer"}, # Missing token | |
| {"Authorization": "Bearer "}, # Empty token | |
| ] | |
| for headers in invalid_headers: | |
| response = self.client.get("/api/v1/jobs/test-job-id", headers=headers) | |
| assert response.status_code == 401 | |
| assert "Invalid Authorization header" in response.json()["error"] or \ | |
| "Empty bearer token" in response.json()["error"] | |
| assert "WWW-Authenticate" in response.headers | |
| def test_invalid_jwt_structure_returns_401(self): | |
| """Test that invalid JWT structure returns 401.""" | |
| invalid_tokens = [ | |
| "Bearer invalid-token", # Not a JWT structure | |
| "Bearer one.two", # Only two parts | |
| "Bearer one.two.three.four", # Too many parts | |
| "Bearer .payload.signature", # Empty header | |
| "Bearer header..signature", # Empty payload | |
| "Bearer header.payload.", # Empty signature | |
| ] | |
| for auth_header in invalid_tokens: | |
| response = self.client.get( | |
| "/api/v1/jobs/test-job-id", | |
| headers={"Authorization": auth_header} | |
| ) | |
| assert response.status_code == 401 | |
| assert "Invalid JWT token structure" in response.json()["error"] | |
| def test_valid_jwt_token_allows_access(self, mock_validate): | |
| """Test that valid JWT token allows access to protected endpoints.""" | |
| mock_validate.return_value = True | |
| # Mock the job repository to avoid database dependency | |
| with patch('application.use_cases.check_job_status.CheckJobStatusUseCase.execute') as mock_execute: | |
| mock_execute.side_effect = Exception("Job not found: test-job-id") # Expected for non-existent job | |
| response = self.client.get( | |
| "/api/v1/jobs/test-job-id", | |
| headers={"Authorization": self.valid_auth_header} | |
| ) | |
| # Should get past authentication (even if job doesn't exist) | |
| assert response.status_code != 401 | |
| mock_validate.assert_called_once_with(self.valid_jwt_token) | |
| def test_extraction_endpoint_requires_authentication(self): | |
| """Test that extraction endpoint requires authentication.""" | |
| # Create a minimal video file for testing | |
| test_file_content = b"fake video content" | |
| response = self.client.post( | |
| "/api/v1/extract", | |
| files={"video": ("test.mp4", test_file_content, "video/mp4")}, | |
| data={"output_format": "mp3", "quality": "medium"} | |
| ) | |
| assert response.status_code == 401 | |
| assert "Missing Authorization header" in response.json()["error"] | |
| def test_extraction_endpoint_with_valid_token(self, mock_validate): | |
| """Test extraction endpoint with valid authentication.""" | |
| mock_validate.return_value = True | |
| test_file_content = b"fake video content" | |
| # Mock all the dependencies to avoid actual processing | |
| with patch('interfaces.api.dependencies.get_use_cases') as mock_use_cases, \ | |
| patch('interfaces.api.dependencies.get_services') as mock_services: | |
| # Mock services | |
| mock_file_repo = Mock() | |
| mock_file_repo.save_stream = AsyncMock(return_value="/tmp/test.mp4") | |
| mock_file_repo.delete_file = AsyncMock() | |
| mock_services.return_value.file_repository = mock_file_repo | |
| # Mock use cases | |
| mock_validation_service = Mock() | |
| mock_validation_service.validate_external_job_id = Mock() | |
| mock_extract_use_case = Mock() | |
| mock_extract_use_case.execute_with_job = AsyncMock(return_value=Mock( | |
| job_id="test-job-123", | |
| external_job_id=None, | |
| status="processing", | |
| message="Job created", | |
| check_url="/api/v1/jobs/test-job-123", | |
| file_size_mb=0.001 | |
| )) | |
| mock_use_cases.return_value.validation_service = mock_validation_service | |
| mock_use_cases.return_value.extract_audio_async = mock_extract_use_case | |
| response = self.client.post( | |
| "/api/v1/extract", | |
| headers={"Authorization": self.valid_auth_header}, | |
| files={"video": ("test.mp4", test_file_content, "video/mp4")}, | |
| data={"output_format": "mp3", "quality": "medium"} | |
| ) | |
| # Should get past authentication | |
| assert response.status_code != 401 | |
| mock_validate.assert_called_with(self.valid_jwt_token) | |
| def test_download_endpoint_requires_authentication(self): | |
| """Test that download endpoint requires authentication.""" | |
| response = self.client.get("/api/v1/jobs/test-job-id/download") | |
| assert response.status_code == 401 | |
| assert "Missing Authorization header" in response.json()["error"] | |
| def test_download_endpoint_with_valid_token(self, mock_validate): | |
| """Test download endpoint with valid authentication.""" | |
| mock_validate.return_value = True | |
| # Mock the download use case to avoid database dependency | |
| with patch('application.use_cases.download_audio_result.DownloadAudioResultUseCase.execute') as mock_execute: | |
| mock_execute.side_effect = Exception("Job not found: test-job-id") # Expected for non-existent job | |
| response = self.client.get( | |
| "/api/v1/jobs/test-job-id/download", | |
| headers={"Authorization": self.valid_auth_header} | |
| ) | |
| # Should get past authentication (even if job doesn't exist) | |
| assert response.status_code != 401 | |
| mock_validate.assert_called_once_with(self.valid_jwt_token) | |
| def test_info_endpoint_does_not_require_authentication(self): | |
| """Test that info endpoint is public and doesn't require authentication.""" | |
| response = self.client.get("/api/v1/info") | |
| assert response.status_code == 200 | |
| assert "version" in response.json() | |
| assert "supported_video_formats" in response.json() | |
| def test_authentication_error_response_format(self): | |
| """Test that authentication errors return proper response format.""" | |
| response = self.client.get("/api/v1/jobs/test-job-id") | |
| assert response.status_code == 401 | |
| response_data = response.json() | |
| # Verify error response structure | |
| assert "error" in response_data | |
| assert isinstance(response_data["error"], str) | |
| # Verify WWW-Authenticate header | |
| assert "WWW-Authenticate" in response.headers | |
| assert response.headers["WWW-Authenticate"] == "Bearer" | |
| def test_bearer_token_passed_to_job_creation(self, mock_validate): | |
| """Test that bearer token is properly passed to job creation.""" | |
| mock_validate.return_value = True | |
| test_file_content = b"fake video content" | |
| # Mock dependencies | |
| with patch('interfaces.api.dependencies.get_use_cases') as mock_use_cases, \ | |
| patch('interfaces.api.dependencies.get_services') as mock_services, \ | |
| patch('domain.entities.job.Job.create_new') as mock_create_job: | |
| # Setup mocks | |
| mock_services.return_value.file_repository.save_stream = AsyncMock(return_value="/tmp/test.mp4") | |
| mock_use_cases.return_value.validation_service.validate_external_job_id = Mock() | |
| mock_job = Mock() | |
| mock_job.id = "test-job-123" | |
| mock_create_job.return_value = mock_job | |
| mock_use_cases.return_value.extract_audio_async.execute_with_job = AsyncMock( | |
| return_value=Mock( | |
| job_id="test-job-123", | |
| external_job_id=None, | |
| status="processing", | |
| message="Job created", | |
| check_url="/api/v1/jobs/test-job-123", | |
| file_size_mb=0.001 | |
| ) | |
| ) | |
| response = self.client.post( | |
| "/api/v1/extract", | |
| headers={"Authorization": self.valid_auth_header}, | |
| files={"video": ("test.mp4", test_file_content, "video/mp4")}, | |
| data={"output_format": "mp3", "quality": "medium"} | |
| ) | |
| # Verify job creation was called with bearer token | |
| mock_create_job.assert_called_once() | |
| call_kwargs = mock_create_job.call_args.kwargs | |
| assert call_kwargs["bearer_token"] == self.valid_jwt_token | |
| def test_concurrent_authentication_requests(self): | |
| """Test that authentication works correctly under concurrent load.""" | |
| import concurrent.futures | |
| import threading | |
| def make_authenticated_request(token_suffix: str): | |
| """Make a request with a unique token.""" | |
| # Create unique token for each thread | |
| unique_token = f"{self.valid_jwt_token}-{token_suffix}" | |
| with patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') as mock_validate: | |
| mock_validate.return_value = True | |
| response = self.client.get( | |
| f"/api/v1/jobs/test-job-{token_suffix}", | |
| headers={"Authorization": f"Bearer {unique_token}"} | |
| ) | |
| return response.status_code, unique_token | |
| # Make multiple concurrent requests | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: | |
| futures = [ | |
| executor.submit(make_authenticated_request, str(i)) | |
| for i in range(10) | |
| ] | |
| results = [future.result() for future in futures] | |
| # All requests should get past authentication (they'll fail on job lookup, but that's expected) | |
| for status_code, token in results: | |
| assert status_code != 401, f"Authentication failed for token {token}" | |
| class TestJWTValidationService: | |
| """Test JWT validation service directly.""" | |
| def setup_method(self): | |
| """Set up test fixtures.""" | |
| self.service = JWTValidationService() | |
| def test_validate_structure_with_valid_jwt(self): | |
| """Test JWT structure validation with valid tokens.""" | |
| valid_tokens = [ | |
| "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", | |
| "header.payload.signature", | |
| "a.b.c", | |
| ] | |
| for token in valid_tokens: | |
| assert self.service.validate_structure(token) is True | |
| def test_validate_structure_with_invalid_jwt(self): | |
| """Test JWT structure validation with invalid tokens.""" | |
| invalid_tokens = [ | |
| "", # Empty | |
| "invalid", # Single part | |
| "one.two", # Two parts | |
| "one.two.three.four", # Four parts | |
| ".payload.signature", # Empty header | |
| "header..signature", # Empty payload | |
| "header.payload.", # Empty signature | |
| None, # None value | |
| ] | |
| for token in invalid_tokens: | |
| assert self.service.validate_structure(token) is False | |
| def test_validate_structure_edge_cases(self): | |
| """Test JWT validation with edge cases.""" | |
| edge_cases = [ | |
| ("a" * 1000 + "." + "b" * 1000 + "." + "c" * 1000, True), # Very long token | |
| ("1.2.3", True), # Numeric parts | |
| ("a-b_c.d-e_f.g-h_i", True), # Special characters in parts | |
| ] | |
| for token, expected in edge_cases: | |
| assert self.service.validate_structure(token) is expected | |
| class TestAuthenticationMiddleware: | |
| """Test authentication middleware behavior.""" | |
| def test_authentication_preserves_request_data(self): | |
| """Test that authentication doesn't modify request data.""" | |
| client = TestClient(app) | |
| original_data = {"output_format": "mp3", "quality": "high"} | |
| test_file = ("test.mp4", b"fake content", "video/mp4") | |
| # Make request without auth (will fail auth, but request should be preserved) | |
| response = client.post( | |
| "/api/v1/extract", | |
| files={"video": test_file}, | |
| data=original_data | |
| ) | |
| # Should fail on auth, not on request parsing | |
| assert response.status_code == 401 | |
| assert "Missing Authorization header" in response.json()["error"] | |
| def test_authentication_allows_request_processing(self, mock_validate): | |
| """Test that valid authentication allows normal request processing.""" | |
| mock_validate.return_value = True | |
| client = TestClient(app) | |
| with patch('interfaces.api.dependencies.get_use_cases'), \ | |
| patch('interfaces.api.dependencies.get_services'): | |
| response = client.get( | |
| "/api/v1/jobs/test-job", | |
| headers={"Authorization": "Bearer valid.jwt.token"} | |
| ) | |
| # Should get past authentication (will fail on job lookup, but that's post-auth) | |
| assert response.status_code != 401 | |
| class TestTokenSecurity: | |
| """Test token security and handling.""" | |
| def test_token_not_logged_in_error_responses(self): | |
| """Test that tokens are not included in error response bodies.""" | |
| client = TestClient(app) | |
| sensitive_token = "Bearer eyJhbGciOiJIUzI1NiJ9.sensitive-payload.secret-signature" | |
| response = client.get( | |
| "/api/v1/jobs/test-job", | |
| headers={"Authorization": sensitive_token} | |
| ) | |
| response_text = response.text | |
| # Token should not appear in response | |
| assert "sensitive-payload" not in response_text | |
| assert "secret-signature" not in response_text | |
| assert "eyJhbGciOiJIUzI1NiJ9" not in response_text | |
| def test_malformed_token_handling(self): | |
| """Test that malformed tokens are handled securely.""" | |
| client = TestClient(app) | |
| malformed_tokens = [ | |
| "Bearer <script>alert('xss')</script>", | |
| "Bearer ' OR 1=1 --", | |
| "Bearer ../../../etc/passwd", | |
| "Bearer " + "A" * 10000, # Very long token | |
| ] | |
| for token in malformed_tokens: | |
| response = client.get( | |
| "/api/v1/jobs/test-job", | |
| headers={"Authorization": token} | |
| ) | |
| assert response.status_code == 401 | |
| # Ensure no token content appears in response | |
| response_text = response.text.lower() | |
| assert "script" not in response_text | |
| assert "alert" not in response_text | |
| assert "etc/passwd" not in response_text |