WIP
This commit is contained in:
264
tests/shared/storage/test_presigned_urls.py
Normal file
264
tests/shared/storage/test_presigned_urls.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user