|
|
"""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) |
|
|
|
|
|
|
|
|
self.valid_token_header = {"alg": "HS256", "typ": "JWT"} |
|
|
self.valid_token_payload = {"sub": "test-user", "iat": 1234567890} |
|
|
|
|
|
|
|
|
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"}, |
|
|
{"Authorization": "Basic dGVzdA=="}, |
|
|
{"Authorization": "Bearer"}, |
|
|
{"Authorization": "Bearer "}, |
|
|
] |
|
|
|
|
|
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", |
|
|
"Bearer one.two", |
|
|
"Bearer one.two.three.four", |
|
|
"Bearer .payload.signature", |
|
|
"Bearer header..signature", |
|
|
"Bearer header.payload.", |
|
|
] |
|
|
|
|
|
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"] |
|
|
|
|
|
@patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') |
|
|
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 |
|
|
|
|
|
|
|
|
with patch('application.use_cases.check_job_status.CheckJobStatusUseCase.execute') as mock_execute: |
|
|
mock_execute.side_effect = Exception("Job not found: test-job-id") |
|
|
|
|
|
response = self.client.get( |
|
|
"/api/v1/jobs/test-job-id", |
|
|
headers={"Authorization": self.valid_auth_header} |
|
|
) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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"] |
|
|
|
|
|
@patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') |
|
|
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" |
|
|
|
|
|
|
|
|
with patch('interfaces.api.dependencies.get_use_cases') as mock_use_cases, \ |
|
|
patch('interfaces.api.dependencies.get_services') as 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_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"} |
|
|
) |
|
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
@patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') |
|
|
def test_download_endpoint_with_valid_token(self, mock_validate): |
|
|
"""Test download endpoint with valid authentication.""" |
|
|
mock_validate.return_value = True |
|
|
|
|
|
|
|
|
with patch('application.use_cases.download_audio_result.DownloadAudioResultUseCase.execute') as mock_execute: |
|
|
mock_execute.side_effect = Exception("Job not found: test-job-id") |
|
|
|
|
|
response = self.client.get( |
|
|
"/api/v1/jobs/test-job-id/download", |
|
|
headers={"Authorization": self.valid_auth_header} |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
assert "error" in response_data |
|
|
assert isinstance(response_data["error"], str) |
|
|
|
|
|
|
|
|
assert "WWW-Authenticate" in response.headers |
|
|
assert response.headers["WWW-Authenticate"] == "Bearer" |
|
|
|
|
|
@patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') |
|
|
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" |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
|
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"} |
|
|
) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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 = [ |
|
|
"", |
|
|
"invalid", |
|
|
"one.two", |
|
|
"one.two.three.four", |
|
|
".payload.signature", |
|
|
"header..signature", |
|
|
"header.payload.", |
|
|
None, |
|
|
] |
|
|
|
|
|
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), |
|
|
("1.2.3", True), |
|
|
("a-b_c.d-e_f.g-h_i", True), |
|
|
] |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
response = client.post( |
|
|
"/api/v1/extract", |
|
|
files={"video": test_file}, |
|
|
data=original_data |
|
|
) |
|
|
|
|
|
|
|
|
assert response.status_code == 401 |
|
|
assert "Missing Authorization header" in response.json()["error"] |
|
|
|
|
|
@patch('infrastructure.services.jwt_validation_service.JWTValidationService.validate_structure') |
|
|
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"} |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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, |
|
|
] |
|
|
|
|
|
for token in malformed_tokens: |
|
|
response = client.get( |
|
|
"/api/v1/jobs/test-job", |
|
|
headers={"Authorization": token} |
|
|
) |
|
|
|
|
|
assert response.status_code == 401 |
|
|
|
|
|
response_text = response.text.lower() |
|
|
assert "script" not in response_text |
|
|
assert "alert" not in response_text |
|
|
assert "etc/passwd" not in response_text |