""" Tests for pre-signed URL functionality across all storage backends. TDD Phase 1: RED - Write tests first, then implement to pass. """ import shutil import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest @pytest.fixture def temp_storage_dir() -> Path: """Create a temporary directory for storage tests.""" temp_dir = Path(tempfile.mkdtemp()) yield temp_dir shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture def sample_file(temp_storage_dir: Path) -> Path: """Create a sample file for testing.""" file_path = temp_storage_dir / "sample.txt" file_path.write_text("Hello, World!") return file_path class TestStorageBackendInterfacePresignedUrl: """Tests for get_presigned_url in StorageBackend interface.""" def test_subclass_must_implement_get_presigned_url(self) -> None: """Test that subclass must implement get_presigned_url method.""" from shared.storage.base import StorageBackend class IncompleteBackend(StorageBackend): def upload( self, local_path: Path, remote_path: str, overwrite: bool = False ) -> str: return remote_path def download(self, remote_path: str, local_path: Path) -> Path: return local_path def exists(self, remote_path: str) -> bool: return False def list_files(self, prefix: str) -> list[str]: return [] def delete(self, remote_path: str) -> bool: return True def get_url(self, remote_path: str) -> str: return "" with pytest.raises(TypeError): IncompleteBackend() # type: ignore def test_valid_subclass_with_get_presigned_url_can_be_instantiated(self) -> None: """Test that a complete subclass with get_presigned_url can be instantiated.""" from shared.storage.base import StorageBackend class CompleteBackend(StorageBackend): def upload( self, local_path: Path, remote_path: str, overwrite: bool = False ) -> str: return remote_path def download(self, remote_path: str, local_path: Path) -> Path: return local_path def exists(self, remote_path: str) -> bool: return False def list_files(self, prefix: str) -> list[str]: return [] def delete(self, remote_path: str) -> bool: return True def get_url(self, remote_path: str) -> str: return "" def get_presigned_url( self, remote_path: str, expires_in_seconds: int = 3600 ) -> str: return f"https://example.com/{remote_path}?token=abc" backend = CompleteBackend() assert isinstance(backend, StorageBackend) class TestLocalStorageBackendPresignedUrl: """Tests for LocalStorageBackend.get_presigned_url method.""" def test_get_presigned_url_returns_file_uri( self, temp_storage_dir: Path, sample_file: Path ) -> None: """Test get_presigned_url returns file:// URI for existing file.""" from shared.storage.local import LocalStorageBackend storage_dir = temp_storage_dir / "storage" backend = LocalStorageBackend(base_path=storage_dir) backend.upload(sample_file, "sample.txt") url = backend.get_presigned_url("sample.txt") assert url.startswith("file://") assert "sample.txt" in url def test_get_presigned_url_with_custom_expiry( self, temp_storage_dir: Path, sample_file: Path ) -> None: """Test get_presigned_url accepts expires_in_seconds parameter.""" from shared.storage.local import LocalStorageBackend storage_dir = temp_storage_dir / "storage" backend = LocalStorageBackend(base_path=storage_dir) backend.upload(sample_file, "sample.txt") # For local storage, expiry is ignored but should not raise error url = backend.get_presigned_url("sample.txt", expires_in_seconds=7200) assert url.startswith("file://") def test_get_presigned_url_nonexistent_file_raises( self, temp_storage_dir: Path ) -> None: """Test get_presigned_url raises FileNotFoundStorageError for missing file.""" from shared.storage.base import FileNotFoundStorageError from shared.storage.local import LocalStorageBackend backend = LocalStorageBackend(base_path=temp_storage_dir) with pytest.raises(FileNotFoundStorageError): backend.get_presigned_url("nonexistent.txt") def test_get_presigned_url_path_traversal_blocked( self, temp_storage_dir: Path ) -> None: """Test that path traversal in get_presigned_url is blocked.""" from shared.storage.base import StorageError from shared.storage.local import LocalStorageBackend backend = LocalStorageBackend(base_path=temp_storage_dir) with pytest.raises(StorageError, match="Path traversal not allowed"): backend.get_presigned_url("../escape.txt") def test_get_presigned_url_nested_path( self, temp_storage_dir: Path, sample_file: Path ) -> None: """Test get_presigned_url works with nested paths.""" from shared.storage.local import LocalStorageBackend storage_dir = temp_storage_dir / "storage" backend = LocalStorageBackend(base_path=storage_dir) backend.upload(sample_file, "a/b/c/sample.txt") url = backend.get_presigned_url("a/b/c/sample.txt") assert url.startswith("file://") assert "sample.txt" in url class TestAzureBlobStorageBackendPresignedUrl: """Tests for AzureBlobStorageBackend.get_presigned_url method.""" @patch("shared.storage.azure.BlobServiceClient") def test_get_presigned_url_generates_sas_url( self, mock_blob_service_class: MagicMock ) -> None: """Test get_presigned_url generates URL with SAS token.""" from shared.storage.azure import AzureBlobStorageBackend # Setup mocks mock_blob_service = MagicMock() mock_blob_service.account_name = "testaccount" mock_blob_service_class.from_connection_string.return_value = mock_blob_service mock_container = MagicMock() mock_container.exists.return_value = True mock_blob_service.get_container_client.return_value = mock_container mock_blob_client = MagicMock() mock_blob_client.exists.return_value = True mock_blob_client.url = "https://testaccount.blob.core.windows.net/container/test.txt" mock_container.get_blob_client.return_value = mock_blob_client backend = AzureBlobStorageBackend( connection_string="DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey==;EndpointSuffix=core.windows.net", container_name="container", ) with patch("shared.storage.azure.generate_blob_sas") as mock_generate_sas: mock_generate_sas.return_value = "sv=2021-06-08&sr=b&sig=abc123" url = backend.get_presigned_url("test.txt", expires_in_seconds=3600) assert "https://testaccount.blob.core.windows.net" in url assert "sv=2021-06-08" in url or "test.txt" in url @patch("shared.storage.azure.BlobServiceClient") def test_get_presigned_url_nonexistent_blob_raises( self, mock_blob_service_class: MagicMock ) -> None: """Test get_presigned_url raises for nonexistent blob.""" from shared.storage.base import FileNotFoundStorageError from shared.storage.azure import AzureBlobStorageBackend mock_blob_service = MagicMock() mock_blob_service_class.from_connection_string.return_value = mock_blob_service mock_container = MagicMock() mock_container.exists.return_value = True mock_blob_service.get_container_client.return_value = mock_container mock_blob_client = MagicMock() mock_blob_client.exists.return_value = False mock_container.get_blob_client.return_value = mock_blob_client backend = AzureBlobStorageBackend( connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key==;EndpointSuffix=core.windows.net", container_name="container", ) with pytest.raises(FileNotFoundStorageError): backend.get_presigned_url("nonexistent.txt") @patch("shared.storage.azure.BlobServiceClient") def test_get_presigned_url_uses_custom_expiry( self, mock_blob_service_class: MagicMock ) -> None: """Test get_presigned_url uses custom expiry time.""" from shared.storage.azure import AzureBlobStorageBackend mock_blob_service = MagicMock() mock_blob_service.account_name = "testaccount" mock_blob_service_class.from_connection_string.return_value = mock_blob_service mock_container = MagicMock() mock_container.exists.return_value = True mock_blob_service.get_container_client.return_value = mock_container mock_blob_client = MagicMock() mock_blob_client.exists.return_value = True mock_blob_client.url = "https://testaccount.blob.core.windows.net/container/test.txt" mock_container.get_blob_client.return_value = mock_blob_client backend = AzureBlobStorageBackend( connection_string="DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey==;EndpointSuffix=core.windows.net", container_name="container", ) with patch("shared.storage.azure.generate_blob_sas") as mock_generate_sas: mock_generate_sas.return_value = "sv=2021-06-08&sr=b&sig=abc123" backend.get_presigned_url("test.txt", expires_in_seconds=7200) # Verify generate_blob_sas was called (expiry is part of the call) mock_generate_sas.assert_called_once()