gabriel / musehub public
test_storage_backends_section33.py python
562 lines 22.3 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 33 — Storage Backends: 7-layer test suite.
2
3 Covers:
4 musehub/storage/backends.py — StorageBackend protocol, LocalBackend, S3Backend, get_backend, decode_b64
5
6 Key behaviour under test:
7 LocalBackend:
8 - put/get/exists/delete round-trip
9 - _path sanitises repo_id and object_id (strips colon/slash from object_id)
10 - path traversal in repo_id raises ValueError
11 - put is idempotent (second write to same path is a no-op)
12 - uri_for returns "local://" prefix
13 - missing object → get returns None, exists returns False, delete is a no-op
14
15 S3Backend:
16 - _key replaces colons with underscores
17 - uri_for returns "s3://<bucket>/..." prefix
18 - put/get/exists/delete delegated to boto3 client (tested via mock)
19 - credential/boto3 errors on get/exists → return None/False (no raise)
20 - _s3_put skips upload when HeadObject succeeds
21
22 get_backend:
23 - returns LocalBackend when aws_s3_asset_bucket is unset
24 - returns S3Backend when aws_s3_asset_bucket is set
25
26 decode_b64:
27 - decodes standard base64
28 - handles missing padding (1, 2, 3 missing "=" chars)
29 """
30 from __future__ import annotations
31
32 import asyncio
33 import time
34 import uuid
35 from pathlib import Path
36 from types import SimpleNamespace
37 from typing import Any
38 from unittest.mock import MagicMock, patch
39
40 import pytest
41
42 from musehub.storage.backends import LocalBackend, S3Backend, decode_b64, get_backend
43
44
45 # ── helpers ───────────────────────────────────────────────────────────────────
46
47
48 def _uid() -> str:
49 return str(uuid.uuid4())
50
51
52 def _backend(tmp_path: Path) -> LocalBackend:
53 return LocalBackend(objects_dir=str(tmp_path / "objects"))
54
55
56 # ═══════════════════════════════════════════════════════════════════════════════
57 # Layer 1 — Unit (pure logic, no filesystem I/O)
58 # ═══════════════════════════════════════════════════════════════════════════════
59
60
61 class TestUnitLocalBackend:
62 def test_path_basic(self, tmp_path: Path) -> None:
63 b = _backend(tmp_path)
64 p = b._path("repo1", "abc123")
65 assert p.parent.name == "repo1"
66 assert p.name == "abc123"
67
68 def test_path_sanitises_colon_in_object_id(self, tmp_path: Path) -> None:
69 b = _backend(tmp_path)
70 p = b._path("repo1", "sha256:deadbeef")
71 assert ":" not in p.name
72 assert p.name == "sha256_deadbeef"
73
74 def test_path_sanitises_slash_in_object_id(self, tmp_path: Path) -> None:
75 b = _backend(tmp_path)
76 p = b._path("repo1", "some/nested/id")
77 assert "/" not in p.name
78
79 def test_path_traversal_in_repo_id_raises(self, tmp_path: Path) -> None:
80 b = _backend(tmp_path)
81 with pytest.raises(ValueError, match="traversal"):
82 b._path("../../etc", "passwd")
83
84 def test_path_traversal_deep_raises(self, tmp_path: Path) -> None:
85 b = _backend(tmp_path)
86 with pytest.raises(ValueError):
87 b._path("../../../etc/shadow", "id")
88
89 def test_uri_for_has_local_prefix(self, tmp_path: Path) -> None:
90 b = _backend(tmp_path)
91 uri = b.uri_for("repo1", "obj1")
92 assert uri.startswith("local://")
93
94 def test_uri_for_contains_repo_and_obj(self, tmp_path: Path) -> None:
95 b = _backend(tmp_path)
96 uri = b.uri_for("my-repo", "myobj")
97 assert "my-repo" in uri
98 assert "myobj" in uri
99
100
101 class TestUnitS3Backend:
102 def test_key_basic(self) -> None:
103 b = S3Backend(bucket="test-bucket", region="us-east-1")
104 key = b._key("repo1", "abc123")
105 assert key == "objects/repo1/abc123"
106
107 def test_key_replaces_colon(self) -> None:
108 b = S3Backend(bucket="b", region="us-east-1")
109 key = b._key("repo1", "sha256:deadbeef")
110 assert ":" not in key
111 assert "sha256_deadbeef" in key
112
113 def test_uri_for_s3_prefix(self) -> None:
114 b = S3Backend(bucket="my-bucket", region="us-east-1")
115 uri = b.uri_for("repo1", "obj1")
116 assert uri.startswith("s3://my-bucket/")
117 assert "repo1" in uri
118
119 def test_uri_for_no_colon_in_key(self) -> None:
120 b = S3Backend(bucket="b", region="us-east-1")
121 uri = b.uri_for("repo1", "sha256:abc")
122 assert "sha256:abc" not in uri
123 assert "sha256_abc" in uri
124
125
126 class TestUnitDecodeb64:
127 def test_standard_base64(self) -> None:
128 import base64
129 data = b"hello world"
130 encoded = base64.b64encode(data).decode()
131 assert decode_b64(encoded) == data
132
133 def test_missing_one_padding(self) -> None:
134 import base64
135 data = b"hi"
136 encoded = base64.b64encode(data).decode().rstrip("=")
137 assert decode_b64(encoded) == data
138
139 def test_missing_two_padding(self) -> None:
140 import base64
141 data = b"h"
142 encoded = base64.b64encode(data).decode().rstrip("=")
143 assert decode_b64(encoded) == data
144
145 def test_already_padded(self) -> None:
146 import base64
147 data = b"test"
148 encoded = base64.b64encode(data).decode()
149 assert decode_b64(encoded) == data
150
151 def test_empty_string(self) -> None:
152 assert decode_b64("") == b""
153
154
155 class TestUnitGetBackend:
156 def test_get_backend_returns_local_when_no_s3_bucket(self) -> None:
157 with patch("musehub.storage.backends.settings") as mock_settings:
158 mock_settings.r2_bucket = None
159 mock_settings.aws_s3_asset_bucket = None
160 mock_settings.musehub_objects_dir = "/tmp/test-objects"
161 result = get_backend()
162 assert isinstance(result, LocalBackend)
163
164 def test_get_backend_returns_s3_when_bucket_set(self) -> None:
165 with patch("musehub.storage.backends.settings") as mock_settings:
166 mock_settings.aws_s3_asset_bucket = "my-bucket"
167 mock_settings.aws_region = "us-east-1"
168 result = get_backend()
169 assert isinstance(result, S3Backend)
170
171
172 # ═══════════════════════════════════════════════════════════════════════════════
173 # Layer 2 — Integration (real filesystem I/O via tmp_path)
174 # ═══════════════════════════════════════════════════════════════════════════════
175
176
177 class TestIntegrationLocalBackend:
178 @pytest.mark.anyio
179 async def test_put_and_get_round_trip(self, tmp_path: Path) -> None:
180 b = _backend(tmp_path)
181 data = b"hello storage"
182 uri = await b.put("repo1", "obj1", data)
183 assert uri.startswith("local://")
184 result = await b.get("repo1", "obj1")
185 assert result == data
186
187 @pytest.mark.anyio
188 async def test_get_missing_returns_none(self, tmp_path: Path) -> None:
189 b = _backend(tmp_path)
190 result = await b.get("repo1", "nonexistent")
191 assert result is None
192
193 @pytest.mark.anyio
194 async def test_exists_after_put(self, tmp_path: Path) -> None:
195 b = _backend(tmp_path)
196 assert await b.exists("repo1", "obj1") is False
197 await b.put("repo1", "obj1", b"data")
198 assert await b.exists("repo1", "obj1") is True
199
200 @pytest.mark.anyio
201 async def test_exists_missing_returns_false(self, tmp_path: Path) -> None:
202 b = _backend(tmp_path)
203 assert await b.exists("repo1", "ghost") is False
204
205 @pytest.mark.anyio
206 async def test_delete_removes_object(self, tmp_path: Path) -> None:
207 b = _backend(tmp_path)
208 await b.put("repo1", "obj1", b"bye")
209 assert await b.exists("repo1", "obj1") is True
210 await b.delete("repo1", "obj1")
211 assert await b.exists("repo1", "obj1") is False
212
213 @pytest.mark.anyio
214 async def test_delete_nonexistent_is_noop(self, tmp_path: Path) -> None:
215 b = _backend(tmp_path)
216 # Must not raise
217 await b.delete("repo1", "ghost")
218
219 @pytest.mark.anyio
220 async def test_put_idempotent_no_overwrite(self, tmp_path: Path) -> None:
221 b = _backend(tmp_path)
222 await b.put("repo1", "obj1", b"first")
223 await b.put("repo1", "obj1", b"second") # _write skips if path exists
224 result = await b.get("repo1", "obj1")
225 assert result == b"first" # original data preserved
226
227 @pytest.mark.anyio
228 async def test_put_creates_parent_dirs(self, tmp_path: Path) -> None:
229 b = _backend(tmp_path)
230 await b.put("deep/nested/repo", "obj", b"data")
231 assert await b.exists("deep/nested/repo", "obj") is True
232
233 @pytest.mark.anyio
234 async def test_put_colon_in_object_id(self, tmp_path: Path) -> None:
235 b = _backend(tmp_path)
236 data = b"sha content"
237 await b.put("repo1", "sha256:abc", data)
238 result = await b.get("repo1", "sha256:abc")
239 assert result == data
240
241 @pytest.mark.anyio
242 async def test_multiple_repos_isolated(self, tmp_path: Path) -> None:
243 b = _backend(tmp_path)
244 await b.put("repo1", "obj", b"r1 data")
245 await b.put("repo2", "obj", b"r2 data")
246 assert await b.get("repo1", "obj") == b"r1 data"
247 assert await b.get("repo2", "obj") == b"r2 data"
248
249
250 class TestIntegrationS3BackendMocked:
251 """S3Backend with a mock boto3 client — no real AWS calls."""
252
253 def _mock_s3_backend(self, *, head_raises: bool = False) -> S3Backend:
254 b = S3Backend(bucket="test-bucket", region="us-east-1")
255 mock_client = MagicMock()
256 if head_raises:
257 mock_client.head_object.side_effect = Exception("NoSuchKey")
258 b._client = mock_client
259 return b
260
261 @pytest.mark.anyio
262 async def test_put_calls_head_then_put_when_missing(self) -> None:
263 b = self._mock_s3_backend(head_raises=True)
264 await b.put("repo1", "obj1", b"data")
265 b._client.head_object.assert_called_once()
266 b._client.put_object.assert_called_once()
267
268 @pytest.mark.anyio
269 async def test_put_skips_upload_when_object_exists(self) -> None:
270 b = self._mock_s3_backend(head_raises=False)
271 await b.put("repo1", "obj1", b"data")
272 b._client.head_object.assert_called_once()
273 b._client.put_object.assert_not_called()
274
275 @pytest.mark.anyio
276 async def test_get_returns_body_bytes(self) -> None:
277 b = self._mock_s3_backend()
278 mock_body = MagicMock()
279 mock_body.read.return_value = b"s3 content"
280 b._client.get_object.return_value = {"Body": mock_body}
281 result = await b.get("repo1", "obj1")
282 assert result == b"s3 content"
283
284 @pytest.mark.anyio
285 async def test_get_returns_none_on_error(self) -> None:
286 b = self._mock_s3_backend()
287 b._client.get_object.side_effect = Exception("NoSuchKey")
288 result = await b.get("repo1", "obj1")
289 assert result is None
290
291 @pytest.mark.anyio
292 async def test_exists_returns_true_when_head_succeeds(self) -> None:
293 b = self._mock_s3_backend()
294 b._client.head_object.return_value = {}
295 assert await b.exists("repo1", "obj1") is True
296
297 @pytest.mark.anyio
298 async def test_exists_returns_false_on_error(self) -> None:
299 b = self._mock_s3_backend(head_raises=True)
300 assert await b.exists("repo1", "obj1") is False
301
302 @pytest.mark.anyio
303 async def test_delete_calls_delete_object(self) -> None:
304 b = self._mock_s3_backend()
305 await b.delete("repo1", "obj1")
306 b._client.delete_object.assert_called_once()
307
308
309 # ═══════════════════════════════════════════════════════════════════════════════
310 # Layer 3 — End-to-End (LocalBackend as the full stack backend)
311 # ═══════════════════════════════════════════════════════════════════════════════
312
313
314 class TestE2ELocalBackend:
315 @pytest.mark.anyio
316 async def test_full_lifecycle(self, tmp_path: Path) -> None:
317 """put → exists(True) → get → delete → exists(False) → get(None)."""
318 b = _backend(tmp_path)
319 repo_id = _uid()
320 obj_id = _uid()
321 data = b"end-to-end content"
322
323 uri = await b.put(repo_id, obj_id, data)
324 assert uri.startswith("local://")
325
326 assert await b.exists(repo_id, obj_id) is True
327 assert await b.get(repo_id, obj_id) == data
328
329 await b.delete(repo_id, obj_id)
330 assert await b.exists(repo_id, obj_id) is False
331 assert await b.get(repo_id, obj_id) is None
332
333 @pytest.mark.anyio
334 async def test_binary_data_preserved(self, tmp_path: Path) -> None:
335 b = _backend(tmp_path)
336 data = bytes(range(256)) # all byte values
337 await b.put("repo1", "bin-obj", data)
338 result = await b.get("repo1", "bin-obj")
339 assert result == data
340
341 @pytest.mark.anyio
342 async def test_large_object(self, tmp_path: Path) -> None:
343 b = _backend(tmp_path)
344 data = b"x" * (4 * 1024 * 1024) # 4 MiB
345 await b.put("repo1", "large-obj", data)
346 result = await b.get("repo1", "large-obj")
347 assert result == data
348
349 @pytest.mark.anyio
350 async def test_uri_resolves_to_disk_path(self, tmp_path: Path) -> None:
351 b = _backend(tmp_path)
352 data = b"uri content"
353 uri = await b.put("repo1", "uri-obj", data)
354 disk_path = Path(uri.replace("local://", ""))
355 assert disk_path.exists()
356 assert disk_path.read_bytes() == data
357
358
359 # ═══════════════════════════════════════════════════════════════════════════════
360 # Layer 4 — Stress
361 # ═══════════════════════════════════════════════════════════════════════════════
362
363
364 class TestStressLocalBackend:
365 @pytest.mark.anyio
366 async def test_write_many_objects(self, tmp_path: Path) -> None:
367 b = _backend(tmp_path)
368 n = 100
369 obj_ids = [f"obj-{i}" for i in range(n)]
370 for obj_id in obj_ids:
371 await b.put("repo1", obj_id, obj_id.encode())
372
373 for obj_id in obj_ids:
374 assert await b.exists("repo1", obj_id) is True
375
376 @pytest.mark.anyio
377 async def test_concurrent_writes_different_objects(self, tmp_path: Path) -> None:
378 b = _backend(tmp_path)
379 obj_ids = [f"concurrent-{i}" for i in range(20)]
380 await asyncio.gather(*[b.put("repo1", oid, oid.encode()) for oid in obj_ids])
381 for oid in obj_ids:
382 assert await b.get("repo1", oid) == oid.encode()
383
384 @pytest.mark.anyio
385 async def test_concurrent_reads(self, tmp_path: Path) -> None:
386 b = _backend(tmp_path)
387 await b.put("repo1", "shared", b"shared content")
388 results = await asyncio.gather(*[b.get("repo1", "shared") for _ in range(20)])
389 assert all(r == b"shared content" for r in results)
390
391 @pytest.mark.anyio
392 async def test_write_many_repos(self, tmp_path: Path) -> None:
393 b = _backend(tmp_path)
394 repos = [f"repo-{i}" for i in range(30)]
395 for repo in repos:
396 await b.put(repo, "obj", repo.encode())
397 for repo in repos:
398 assert await b.get(repo, "obj") == repo.encode()
399
400
401 # ═══════════════════════════════════════════════════════════════════════════════
402 # Layer 5 — Data Integrity
403 # ═══════════════════════════════════════════════════════════════════════════════
404
405
406 class TestDataIntegrityLocalBackend:
407 @pytest.mark.anyio
408 async def test_put_returns_deterministic_uri(self, tmp_path: Path) -> None:
409 b = _backend(tmp_path)
410 uri1 = await b.put("repo1", "obj1", b"data")
411 uri2 = b.uri_for("repo1", "obj1")
412 assert uri1 == uri2
413
414 @pytest.mark.anyio
415 async def test_data_not_corrupted(self, tmp_path: Path) -> None:
416 b = _backend(tmp_path)
417 payload = b"\x00\x01\x02\x03" * 1000
418 await b.put("repo1", "integrity", payload)
419 result = await b.get("repo1", "integrity")
420 assert result == payload
421
422 @pytest.mark.anyio
423 async def test_second_put_same_key_does_not_corrupt(self, tmp_path: Path) -> None:
424 b = _backend(tmp_path)
425 await b.put("repo1", "obj", b"original")
426 await b.put("repo1", "obj", b"overwrite-attempt")
427 result = await b.get("repo1", "obj")
428 assert result == b"original"
429
430 @pytest.mark.anyio
431 async def test_delete_only_removes_target(self, tmp_path: Path) -> None:
432 b = _backend(tmp_path)
433 await b.put("repo1", "keep", b"keep")
434 await b.put("repo1", "remove", b"remove")
435 await b.delete("repo1", "remove")
436 assert await b.get("repo1", "keep") == b"keep"
437 assert await b.get("repo1", "remove") is None
438
439 def test_path_traversal_variations(self, tmp_path: Path) -> None:
440 b = _backend(tmp_path)
441 traversal_attempts = [
442 "../../../etc",
443 "repo/../../../etc",
444 "repo/../../secret",
445 ]
446 for repo_id in traversal_attempts:
447 with pytest.raises(ValueError):
448 b._path(repo_id, "obj")
449
450 @pytest.mark.anyio
451 async def test_empty_bytes_stored_and_retrieved(self, tmp_path: Path) -> None:
452 b = _backend(tmp_path)
453 await b.put("repo1", "empty-obj", b"")
454 result = await b.get("repo1", "empty-obj")
455 assert result == b""
456
457
458 # ═══════════════════════════════════════════════════════════════════════════════
459 # Layer 6 — Security
460 # ═══════════════════════════════════════════════════════════════════════════════
461
462
463 class TestSecurityLocalBackend:
464 def test_path_traversal_repo_id_raises(self, tmp_path: Path) -> None:
465 b = _backend(tmp_path)
466 with pytest.raises(ValueError, match="traversal"):
467 b._path("../secrets", "obj")
468
469 def test_path_traversal_dotdot_deep_raises(self, tmp_path: Path) -> None:
470 # Multiple levels of ".." can escape storage root
471 b = _backend(tmp_path)
472 with pytest.raises(ValueError):
473 b._path("../../../../../../etc", "passwd")
474
475 def test_object_id_colon_sanitised_prevents_ambiguity(self, tmp_path: Path) -> None:
476 b = _backend(tmp_path)
477 p1 = b._path("repo1", "sha256:abc")
478 p2 = b._path("repo1", "sha256_abc")
479 # Both sanitise to the same safe filename
480 assert p1 == p2
481
482 @pytest.mark.anyio
483 async def test_put_does_not_allow_path_outside_root(self, tmp_path: Path) -> None:
484 b = _backend(tmp_path)
485 with pytest.raises(ValueError):
486 await b.put("../outside", "obj", b"evil")
487
488 @pytest.mark.anyio
489 async def test_get_does_not_allow_path_outside_root(self, tmp_path: Path) -> None:
490 b = _backend(tmp_path)
491 with pytest.raises(ValueError):
492 await b.get("../../etc", "passwd")
493
494 @pytest.mark.anyio
495 async def test_exists_does_not_allow_traversal(self, tmp_path: Path) -> None:
496 b = _backend(tmp_path)
497 with pytest.raises(ValueError):
498 await b.exists("../outside", "obj")
499
500 @pytest.mark.anyio
501 async def test_delete_does_not_allow_traversal(self, tmp_path: Path) -> None:
502 b = _backend(tmp_path)
503 with pytest.raises(ValueError):
504 await b.delete("../outside", "obj")
505
506 def test_s3_key_no_colon_injection(self) -> None:
507 b = S3Backend(bucket="b", region="us-east-1")
508 key = b._key("repo", "sha256:evil:colons")
509 assert ":" not in key
510
511
512 # ═══════════════════════════════════════════════════════════════════════════════
513 # Layer 7 — Performance
514 # ═══════════════════════════════════════════════════════════════════════════════
515
516
517 class TestPerformanceLocalBackend:
518 @pytest.mark.anyio
519 async def test_put_latency(self, tmp_path: Path) -> None:
520 b = _backend(tmp_path)
521 data = b"perf-test-payload" * 100
522 start = time.perf_counter()
523 await b.put("repo1", "perf-obj", data)
524 elapsed = time.perf_counter() - start
525 assert elapsed < 0.5
526
527 @pytest.mark.anyio
528 async def test_get_latency(self, tmp_path: Path) -> None:
529 b = _backend(tmp_path)
530 data = b"x" * (512 * 1024) # 512 KiB
531 await b.put("repo1", "big-obj", data)
532
533 start = time.perf_counter()
534 result = await b.get("repo1", "big-obj")
535 elapsed = time.perf_counter() - start
536
537 assert result == data
538 assert elapsed < 0.5
539
540 @pytest.mark.anyio
541 async def test_exists_latency(self, tmp_path: Path) -> None:
542 b = _backend(tmp_path)
543 await b.put("repo1", "perf-exists", b"data")
544
545 start = time.perf_counter()
546 for _ in range(50):
547 await b.exists("repo1", "perf-exists")
548 elapsed = time.perf_counter() - start
549
550 assert elapsed < 1.0
551
552 @pytest.mark.anyio
553 async def test_50_sequential_puts_under_budget(self, tmp_path: Path) -> None:
554 b = _backend(tmp_path)
555 data = b"payload" * 100
556
557 start = time.perf_counter()
558 for i in range(50):
559 await b.put("repo1", f"seq-{i}", data)
560 elapsed = time.perf_counter() - start
561
562 assert elapsed < 2.0
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago