"""Section 33 — Storage Backends: 7-layer test suite. Covers: musehub/storage/backends.py — StorageBackend protocol, LocalBackend, S3Backend, get_backend, decode_b64 Key behaviour under test: LocalBackend: - put/get/exists/delete round-trip - _path sanitises repo_id and object_id (strips colon/slash from object_id) - path traversal in repo_id raises ValueError - put is idempotent (second write to same path is a no-op) - uri_for returns "local://" prefix - missing object → get returns None, exists returns False, delete is a no-op S3Backend: - _key replaces colons with underscores - uri_for returns "s3:///..." prefix - put/get/exists/delete delegated to boto3 client (tested via mock) - credential/boto3 errors on get/exists → return None/False (no raise) - _s3_put skips upload when HeadObject succeeds get_backend: - returns LocalBackend when aws_s3_asset_bucket is unset - returns S3Backend when aws_s3_asset_bucket is set decode_b64: - decodes standard base64 - handles missing padding (1, 2, 3 missing "=" chars) """ from __future__ import annotations import asyncio import time import uuid from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, patch import pytest from musehub.storage.backends import LocalBackend, S3Backend, decode_b64, get_backend # ── helpers ─────────────────────────────────────────────────────────────────── def _uid() -> str: return str(uuid.uuid4()) def _backend(tmp_path: Path) -> LocalBackend: return LocalBackend(objects_dir=str(tmp_path / "objects")) # ═══════════════════════════════════════════════════════════════════════════════ # Layer 1 — Unit (pure logic, no filesystem I/O) # ═══════════════════════════════════════════════════════════════════════════════ class TestUnitLocalBackend: def test_path_basic(self, tmp_path: Path) -> None: b = _backend(tmp_path) p = b._path("repo1", "abc123") assert p.parent.name == "repo1" assert p.name == "abc123" def test_path_sanitises_colon_in_object_id(self, tmp_path: Path) -> None: b = _backend(tmp_path) p = b._path("repo1", "sha256:deadbeef") assert ":" not in p.name assert p.name == "sha256_deadbeef" def test_path_sanitises_slash_in_object_id(self, tmp_path: Path) -> None: b = _backend(tmp_path) p = b._path("repo1", "some/nested/id") assert "/" not in p.name def test_path_traversal_in_repo_id_raises(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError, match="traversal"): b._path("../../etc", "passwd") def test_path_traversal_deep_raises(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError): b._path("../../../etc/shadow", "id") def test_uri_for_has_local_prefix(self, tmp_path: Path) -> None: b = _backend(tmp_path) uri = b.uri_for("repo1", "obj1") assert uri.startswith("local://") def test_uri_for_contains_repo_and_obj(self, tmp_path: Path) -> None: b = _backend(tmp_path) uri = b.uri_for("my-repo", "myobj") assert "my-repo" in uri assert "myobj" in uri class TestUnitS3Backend: def test_key_basic(self) -> None: b = S3Backend(bucket="test-bucket", region="us-east-1") key = b._key("repo1", "abc123") assert key == "objects/repo1/abc123" def test_key_replaces_colon(self) -> None: b = S3Backend(bucket="b", region="us-east-1") key = b._key("repo1", "sha256:deadbeef") assert ":" not in key assert "sha256_deadbeef" in key def test_uri_for_s3_prefix(self) -> None: b = S3Backend(bucket="my-bucket", region="us-east-1") uri = b.uri_for("repo1", "obj1") assert uri.startswith("s3://my-bucket/") assert "repo1" in uri def test_uri_for_no_colon_in_key(self) -> None: b = S3Backend(bucket="b", region="us-east-1") uri = b.uri_for("repo1", "sha256:abc") assert "sha256:abc" not in uri assert "sha256_abc" in uri class TestUnitDecodeb64: def test_standard_base64(self) -> None: import base64 data = b"hello world" encoded = base64.b64encode(data).decode() assert decode_b64(encoded) == data def test_missing_one_padding(self) -> None: import base64 data = b"hi" encoded = base64.b64encode(data).decode().rstrip("=") assert decode_b64(encoded) == data def test_missing_two_padding(self) -> None: import base64 data = b"h" encoded = base64.b64encode(data).decode().rstrip("=") assert decode_b64(encoded) == data def test_already_padded(self) -> None: import base64 data = b"test" encoded = base64.b64encode(data).decode() assert decode_b64(encoded) == data def test_empty_string(self) -> None: assert decode_b64("") == b"" class TestUnitGetBackend: def test_get_backend_returns_local_when_no_s3_bucket(self) -> None: with patch("musehub.storage.backends.settings") as mock_settings: mock_settings.r2_bucket = None mock_settings.aws_s3_asset_bucket = None mock_settings.musehub_objects_dir = "/tmp/test-objects" result = get_backend() assert isinstance(result, LocalBackend) def test_get_backend_returns_s3_when_bucket_set(self) -> None: with patch("musehub.storage.backends.settings") as mock_settings: mock_settings.aws_s3_asset_bucket = "my-bucket" mock_settings.aws_region = "us-east-1" result = get_backend() assert isinstance(result, S3Backend) # ═══════════════════════════════════════════════════════════════════════════════ # Layer 2 — Integration (real filesystem I/O via tmp_path) # ═══════════════════════════════════════════════════════════════════════════════ class TestIntegrationLocalBackend: @pytest.mark.anyio async def test_put_and_get_round_trip(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"hello storage" uri = await b.put("repo1", "obj1", data) assert uri.startswith("local://") result = await b.get("repo1", "obj1") assert result == data @pytest.mark.anyio async def test_get_missing_returns_none(self, tmp_path: Path) -> None: b = _backend(tmp_path) result = await b.get("repo1", "nonexistent") assert result is None @pytest.mark.anyio async def test_exists_after_put(self, tmp_path: Path) -> None: b = _backend(tmp_path) assert await b.exists("repo1", "obj1") is False await b.put("repo1", "obj1", b"data") assert await b.exists("repo1", "obj1") is True @pytest.mark.anyio async def test_exists_missing_returns_false(self, tmp_path: Path) -> None: b = _backend(tmp_path) assert await b.exists("repo1", "ghost") is False @pytest.mark.anyio async def test_delete_removes_object(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "obj1", b"bye") assert await b.exists("repo1", "obj1") is True await b.delete("repo1", "obj1") assert await b.exists("repo1", "obj1") is False @pytest.mark.anyio async def test_delete_nonexistent_is_noop(self, tmp_path: Path) -> None: b = _backend(tmp_path) # Must not raise await b.delete("repo1", "ghost") @pytest.mark.anyio async def test_put_idempotent_no_overwrite(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "obj1", b"first") await b.put("repo1", "obj1", b"second") # _write skips if path exists result = await b.get("repo1", "obj1") assert result == b"first" # original data preserved @pytest.mark.anyio async def test_put_creates_parent_dirs(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("deep/nested/repo", "obj", b"data") assert await b.exists("deep/nested/repo", "obj") is True @pytest.mark.anyio async def test_put_colon_in_object_id(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"sha content" await b.put("repo1", "sha256:abc", data) result = await b.get("repo1", "sha256:abc") assert result == data @pytest.mark.anyio async def test_multiple_repos_isolated(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "obj", b"r1 data") await b.put("repo2", "obj", b"r2 data") assert await b.get("repo1", "obj") == b"r1 data" assert await b.get("repo2", "obj") == b"r2 data" class TestIntegrationS3BackendMocked: """S3Backend with a mock boto3 client — no real AWS calls.""" def _mock_s3_backend(self, *, head_raises: bool = False) -> S3Backend: b = S3Backend(bucket="test-bucket", region="us-east-1") mock_client = MagicMock() if head_raises: mock_client.head_object.side_effect = Exception("NoSuchKey") b._client = mock_client return b @pytest.mark.anyio async def test_put_calls_head_then_put_when_missing(self) -> None: b = self._mock_s3_backend(head_raises=True) await b.put("repo1", "obj1", b"data") b._client.head_object.assert_called_once() b._client.put_object.assert_called_once() @pytest.mark.anyio async def test_put_skips_upload_when_object_exists(self) -> None: b = self._mock_s3_backend(head_raises=False) await b.put("repo1", "obj1", b"data") b._client.head_object.assert_called_once() b._client.put_object.assert_not_called() @pytest.mark.anyio async def test_get_returns_body_bytes(self) -> None: b = self._mock_s3_backend() mock_body = MagicMock() mock_body.read.return_value = b"s3 content" b._client.get_object.return_value = {"Body": mock_body} result = await b.get("repo1", "obj1") assert result == b"s3 content" @pytest.mark.anyio async def test_get_returns_none_on_error(self) -> None: b = self._mock_s3_backend() b._client.get_object.side_effect = Exception("NoSuchKey") result = await b.get("repo1", "obj1") assert result is None @pytest.mark.anyio async def test_exists_returns_true_when_head_succeeds(self) -> None: b = self._mock_s3_backend() b._client.head_object.return_value = {} assert await b.exists("repo1", "obj1") is True @pytest.mark.anyio async def test_exists_returns_false_on_error(self) -> None: b = self._mock_s3_backend(head_raises=True) assert await b.exists("repo1", "obj1") is False @pytest.mark.anyio async def test_delete_calls_delete_object(self) -> None: b = self._mock_s3_backend() await b.delete("repo1", "obj1") b._client.delete_object.assert_called_once() # ═══════════════════════════════════════════════════════════════════════════════ # Layer 3 — End-to-End (LocalBackend as the full stack backend) # ═══════════════════════════════════════════════════════════════════════════════ class TestE2ELocalBackend: @pytest.mark.anyio async def test_full_lifecycle(self, tmp_path: Path) -> None: """put → exists(True) → get → delete → exists(False) → get(None).""" b = _backend(tmp_path) repo_id = _uid() obj_id = _uid() data = b"end-to-end content" uri = await b.put(repo_id, obj_id, data) assert uri.startswith("local://") assert await b.exists(repo_id, obj_id) is True assert await b.get(repo_id, obj_id) == data await b.delete(repo_id, obj_id) assert await b.exists(repo_id, obj_id) is False assert await b.get(repo_id, obj_id) is None @pytest.mark.anyio async def test_binary_data_preserved(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = bytes(range(256)) # all byte values await b.put("repo1", "bin-obj", data) result = await b.get("repo1", "bin-obj") assert result == data @pytest.mark.anyio async def test_large_object(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"x" * (4 * 1024 * 1024) # 4 MiB await b.put("repo1", "large-obj", data) result = await b.get("repo1", "large-obj") assert result == data @pytest.mark.anyio async def test_uri_resolves_to_disk_path(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"uri content" uri = await b.put("repo1", "uri-obj", data) disk_path = Path(uri.replace("local://", "")) assert disk_path.exists() assert disk_path.read_bytes() == data # ═══════════════════════════════════════════════════════════════════════════════ # Layer 4 — Stress # ═══════════════════════════════════════════════════════════════════════════════ class TestStressLocalBackend: @pytest.mark.anyio async def test_write_many_objects(self, tmp_path: Path) -> None: b = _backend(tmp_path) n = 100 obj_ids = [f"obj-{i}" for i in range(n)] for obj_id in obj_ids: await b.put("repo1", obj_id, obj_id.encode()) for obj_id in obj_ids: assert await b.exists("repo1", obj_id) is True @pytest.mark.anyio async def test_concurrent_writes_different_objects(self, tmp_path: Path) -> None: b = _backend(tmp_path) obj_ids = [f"concurrent-{i}" for i in range(20)] await asyncio.gather(*[b.put("repo1", oid, oid.encode()) for oid in obj_ids]) for oid in obj_ids: assert await b.get("repo1", oid) == oid.encode() @pytest.mark.anyio async def test_concurrent_reads(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "shared", b"shared content") results = await asyncio.gather(*[b.get("repo1", "shared") for _ in range(20)]) assert all(r == b"shared content" for r in results) @pytest.mark.anyio async def test_write_many_repos(self, tmp_path: Path) -> None: b = _backend(tmp_path) repos = [f"repo-{i}" for i in range(30)] for repo in repos: await b.put(repo, "obj", repo.encode()) for repo in repos: assert await b.get(repo, "obj") == repo.encode() # ═══════════════════════════════════════════════════════════════════════════════ # Layer 5 — Data Integrity # ═══════════════════════════════════════════════════════════════════════════════ class TestDataIntegrityLocalBackend: @pytest.mark.anyio async def test_put_returns_deterministic_uri(self, tmp_path: Path) -> None: b = _backend(tmp_path) uri1 = await b.put("repo1", "obj1", b"data") uri2 = b.uri_for("repo1", "obj1") assert uri1 == uri2 @pytest.mark.anyio async def test_data_not_corrupted(self, tmp_path: Path) -> None: b = _backend(tmp_path) payload = b"\x00\x01\x02\x03" * 1000 await b.put("repo1", "integrity", payload) result = await b.get("repo1", "integrity") assert result == payload @pytest.mark.anyio async def test_second_put_same_key_does_not_corrupt(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "obj", b"original") await b.put("repo1", "obj", b"overwrite-attempt") result = await b.get("repo1", "obj") assert result == b"original" @pytest.mark.anyio async def test_delete_only_removes_target(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "keep", b"keep") await b.put("repo1", "remove", b"remove") await b.delete("repo1", "remove") assert await b.get("repo1", "keep") == b"keep" assert await b.get("repo1", "remove") is None def test_path_traversal_variations(self, tmp_path: Path) -> None: b = _backend(tmp_path) traversal_attempts = [ "../../../etc", "repo/../../../etc", "repo/../../secret", ] for repo_id in traversal_attempts: with pytest.raises(ValueError): b._path(repo_id, "obj") @pytest.mark.anyio async def test_empty_bytes_stored_and_retrieved(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "empty-obj", b"") result = await b.get("repo1", "empty-obj") assert result == b"" # ═══════════════════════════════════════════════════════════════════════════════ # Layer 6 — Security # ═══════════════════════════════════════════════════════════════════════════════ class TestSecurityLocalBackend: def test_path_traversal_repo_id_raises(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError, match="traversal"): b._path("../secrets", "obj") def test_path_traversal_dotdot_deep_raises(self, tmp_path: Path) -> None: # Multiple levels of ".." can escape storage root b = _backend(tmp_path) with pytest.raises(ValueError): b._path("../../../../../../etc", "passwd") def test_object_id_colon_sanitised_prevents_ambiguity(self, tmp_path: Path) -> None: b = _backend(tmp_path) p1 = b._path("repo1", "sha256:abc") p2 = b._path("repo1", "sha256_abc") # Both sanitise to the same safe filename assert p1 == p2 @pytest.mark.anyio async def test_put_does_not_allow_path_outside_root(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError): await b.put("../outside", "obj", b"evil") @pytest.mark.anyio async def test_get_does_not_allow_path_outside_root(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError): await b.get("../../etc", "passwd") @pytest.mark.anyio async def test_exists_does_not_allow_traversal(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError): await b.exists("../outside", "obj") @pytest.mark.anyio async def test_delete_does_not_allow_traversal(self, tmp_path: Path) -> None: b = _backend(tmp_path) with pytest.raises(ValueError): await b.delete("../outside", "obj") def test_s3_key_no_colon_injection(self) -> None: b = S3Backend(bucket="b", region="us-east-1") key = b._key("repo", "sha256:evil:colons") assert ":" not in key # ═══════════════════════════════════════════════════════════════════════════════ # Layer 7 — Performance # ═══════════════════════════════════════════════════════════════════════════════ class TestPerformanceLocalBackend: @pytest.mark.anyio async def test_put_latency(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"perf-test-payload" * 100 start = time.perf_counter() await b.put("repo1", "perf-obj", data) elapsed = time.perf_counter() - start assert elapsed < 0.5 @pytest.mark.anyio async def test_get_latency(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"x" * (512 * 1024) # 512 KiB await b.put("repo1", "big-obj", data) start = time.perf_counter() result = await b.get("repo1", "big-obj") elapsed = time.perf_counter() - start assert result == data assert elapsed < 0.5 @pytest.mark.anyio async def test_exists_latency(self, tmp_path: Path) -> None: b = _backend(tmp_path) await b.put("repo1", "perf-exists", b"data") start = time.perf_counter() for _ in range(50): await b.exists("repo1", "perf-exists") elapsed = time.perf_counter() - start assert elapsed < 1.0 @pytest.mark.anyio async def test_50_sequential_puts_under_budget(self, tmp_path: Path) -> None: b = _backend(tmp_path) data = b"payload" * 100 start = time.perf_counter() for i in range(50): await b.put("repo1", f"seq-{i}", data) elapsed = time.perf_counter() - start assert elapsed < 2.0