"""Phase 1 — Per-repo directory isolation: 7-tier TDD spec. Each repo gets its own object directory under ``musehub_repos_dir``. Objects are no longer globally namespaced across all repos. Target layout (Phase 1 — still flat within the repo, sharding comes in Phase 2): /data/repos///objects/ API contract: - ``LocalBackend._path(object_id, repo_root=None)`` → with repo_root: ``repo_root / "objects" / safe_id`` → without repo_root: ``self._root / safe_id`` (backward compat) - ``LocalBackend.put/get/exists/delete/uri_for`` all accept ``repo_root`` - ``settings.musehub_repos_dir`` → ``/data/repos`` - ``repo_root_for(owner, slug, repos_dir=None)`` → ``Path(//)`` All tests here are RED until Phase 1 is implemented. """ from __future__ import annotations import asyncio import secrets import time from pathlib import Path from unittest.mock import patch import pytest from muse.core.types import long_id # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _uid() -> str: return secrets.token_hex(16) def _oid() -> str: """A valid sha256-prefixed object ID.""" return long_id(secrets.token_hex(32)) def _backend(tmp_path: Path, objects_dir: str | None = None) -> "LocalBackend": from musehub.storage.backends import LocalBackend return LocalBackend() def _repo_root(tmp_path: Path, owner: str = "alice", slug: str = "myrepo") -> Path: root = tmp_path / "repos" / owner / slug root.mkdir(parents=True, exist_ok=True) return root # ═══════════════════════════════════════════════════════════════════════════════ # Tier 1 — Unit (pure logic, no I/O) # ═══════════════════════════════════════════════════════════════════════════════ class TestUnit: """Pure path logic — no filesystem writes.""" def test_path_with_repo_root_uses_algo_sharded_layout(self, tmp_path: Path) -> None: """_path(oid, repo_root=...) → repo_root/objects/sha256/<2-hex>/<62-hex>""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() path = b._path(oid, repo_root=rr) hex_part = oid.removeprefix("sha256:") # algo/shard/rest layout assert path.name == hex_part[2:] # 62-char remainder assert path.parent.name == hex_part[:2] # 2-char shard assert path.parent.parent.name == "sha256" assert path.parent.parent.parent == (rr / "objects").resolve() def test_path_without_repo_root_raises(self, tmp_path: Path) -> None: """_path(oid) without repo_root raises ValueError — repo_root is required.""" from musehub.storage.backends import LocalBackend b = LocalBackend() oid = _oid() with pytest.raises(ValueError): b._path(oid) def test_path_with_repo_root_no_colon_on_disk(self, tmp_path: Path) -> None: """The algo/shard/rest layout never puts a colon on disk.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() path = b._path(oid, repo_root=rr) assert ":" not in str(path) def test_path_two_repos_different_directories(self, tmp_path: Path) -> None: """Objects in different repos resolve to different directories.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr_alice = _repo_root(tmp_path, owner="alice", slug="repo1") rr_bob = _repo_root(tmp_path, owner="bob", slug="repo2") oid = _oid() p_alice = b._path(oid, repo_root=rr_alice) p_bob = b._path(oid, repo_root=rr_bob) assert p_alice != p_bob assert str(p_alice).startswith(str(rr_alice)) assert str(p_bob).startswith(str(rr_bob)) def test_uri_for_with_repo_root_contains_algo_and_objects(self, tmp_path: Path) -> None: """uri_for with repo_root encodes the full algo-namespaced path.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() uri = b.uri_for(oid, repo_root=rr) assert uri.startswith("local://") assert "/objects/sha256/" in uri def test_uri_for_without_repo_root_raises(self, tmp_path: Path) -> None: """uri_for without repo_root raises ValueError — repo_root is required.""" from musehub.storage.backends import LocalBackend b = LocalBackend() oid = _oid() with pytest.raises(ValueError): b.uri_for(oid) def test_path_traversal_in_object_id_raises_with_repo_root(self, tmp_path: Path) -> None: """object_path validates object IDs strictly — traversal raises ValueError.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises(ValueError): b._path("../../etc/passwd", repo_root=rr) def test_settings_has_musehub_repos_dir(self) -> None: """settings.musehub_repos_dir must exist and be a non-empty string.""" from musehub.config import settings assert hasattr(settings, "musehub_repos_dir") assert isinstance(settings.musehub_repos_dir, str) assert settings.musehub_repos_dir != "" def test_repo_root_for_returns_correct_path(self, tmp_path: Path) -> None: """repo_root_for(owner, slug) → repos_dir/owner/slug""" from musehub.storage.backends import repo_root_for root = repo_root_for("alice", "muse", repos_dir=str(tmp_path / "repos")) assert root == tmp_path / "repos" / "alice" / "muse" def test_repo_root_for_uses_settings_default(self) -> None: """repo_root_for without repos_dir falls back to settings.musehub_repos_dir.""" from musehub.storage.backends import repo_root_for from musehub.config import settings root = repo_root_for("alice", "muse") assert root == Path(settings.musehub_repos_dir) / "alice" / "muse" def test_path_with_repo_root_same_oid_same_path(self, tmp_path: Path) -> None: """Same object_id + same repo_root always resolves to the same path.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() p1 = b._path(oid, repo_root=rr) p2 = b._path(oid, repo_root=rr) assert p1 == p2 # ═══════════════════════════════════════════════════════════════════════════════ # Tier 2 — Integration (real filesystem I/O) # ═══════════════════════════════════════════════════════════════════════════════ class TestIntegration: """Real filesystem — put, get, exists, delete with repo_root.""" async def test_put_with_repo_root_creates_file_in_repo_dir(self, tmp_path: Path) -> None: """put(oid, data, repo_root=...) writes file under repo_root/objects/.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() data = b"per-repo data" uri = await b.put(oid, data, repo_root=rr) assert uri.startswith("local://") disk = Path(uri.removeprefix("local://")) assert disk.exists() assert disk.read_bytes() == data assert str(rr) in str(disk) async def test_get_with_repo_root_reads_from_repo_dir(self, tmp_path: Path) -> None: """get(oid, repo_root=...) retrieves data written by put with same repo_root.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() data = b"read me back" await b.put(oid, data, repo_root=rr) result = await b.get(oid, repo_root=rr) assert result == data async def test_exists_with_repo_root_true_after_put(self, tmp_path: Path) -> None: from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() assert await b.exists(oid, repo_root=rr) is False await b.put(oid, b"data", repo_root=rr) assert await b.exists(oid, repo_root=rr) is True async def test_delete_with_repo_root_removes_file(self, tmp_path: Path) -> None: from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() await b.put(oid, b"bye", repo_root=rr) assert await b.exists(oid, repo_root=rr) is True await b.delete(oid, repo_root=rr) assert await b.exists(oid, repo_root=rr) is False async def test_put_without_repo_root_raises(self, tmp_path: Path) -> None: """put without repo_root raises ValueError — repo_root is required.""" from musehub.storage.backends import LocalBackend b = LocalBackend() oid = _oid() with pytest.raises(ValueError): await b.put(oid, b"no root data") async def test_two_repos_same_oid_isolated(self, tmp_path: Path) -> None: """Same object_id in two repos is stored independently — no cross-contamination.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr_alice = _repo_root(tmp_path, "alice", "repo1") rr_bob = _repo_root(tmp_path, "bob", "repo2") oid = long_id("ab" * 32) await b.put(oid, b"alice content", repo_root=rr_alice) # Bob's repo must not see alice's object assert await b.exists(oid, repo_root=rr_bob) is False # Alice's repo object is still intact assert await b.get(oid, repo_root=rr_alice) == b"alice content" async def test_delete_from_one_repo_leaves_other_intact(self, tmp_path: Path) -> None: """Deleting an object from repo A does not affect the same oid in repo B.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr_a = _repo_root(tmp_path, "alice", "repo1") rr_b = _repo_root(tmp_path, "bob", "repo2") oid = long_id("cd" * 32) await b.put(oid, b"shared content", repo_root=rr_a) await b.put(oid, b"shared content", repo_root=rr_b) await b.delete(oid, repo_root=rr_a) assert await b.exists(oid, repo_root=rr_a) is False assert await b.exists(oid, repo_root=rr_b) is True async def test_put_creates_algo_shard_dirs_automatically(self, tmp_path: Path) -> None: """put() must create repo_root/objects/sha256// if it does not exist yet.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = tmp_path / "repos" / "new-user" / "new-repo" # Do NOT mkdir — put() must create the full directory tree oid = _oid() await b.put(oid, b"auto-create dirs", repo_root=rr) hex_part = oid.removeprefix("sha256:") assert (rr / "objects" / "sha256" / hex_part[:2]).is_dir() async def test_get_missing_with_repo_root_returns_none(self, tmp_path: Path) -> None: from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) result = await b.get(long_id("ff" * 32), repo_root=rr) assert result is None # ═══════════════════════════════════════════════════════════════════════════════ # Tier 3 — End-to-End (full lifecycle via repo_root_for) # ═══════════════════════════════════════════════════════════════════════════════ class TestE2E: """Full lifecycle — put → exists → get → delete using repo_root_for().""" async def test_full_lifecycle_via_repo_root_for(self, tmp_path: Path) -> None: from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() rr = repo_root_for("alice", "muse", repos_dir=str(tmp_path / "repos")) oid = _oid() data = b"e2e content" uri = await b.put(oid, data, repo_root=rr) assert uri.startswith("local://") assert await b.exists(oid, repo_root=rr) is True assert await b.get(oid, repo_root=rr) == data await b.delete(oid, repo_root=rr) assert await b.exists(oid, repo_root=rr) is False assert await b.get(oid, repo_root=rr) is None async def test_disk_path_resolves_to_algo_shard_structure(self, tmp_path: Path) -> None: """URI from put() maps to /objects/sha256//.""" from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() rr = repo_root_for("gabriel", "musehub", repos_dir=str(tmp_path / "repos")) oid = _oid() uri = await b.put(oid, b"disk resolve", repo_root=rr) disk = Path(uri.removeprefix("local://")) assert disk.is_relative_to(rr) # objects/sha256/<2-char-shard>/<62-char-rest> assert disk.parent.parent.parent == (rr / "objects").resolve() assert disk.parent.parent.name == "sha256" async def test_binary_content_preserved(self, tmp_path: Path) -> None: from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() rr = repo_root_for("alice", "muse", repos_dir=str(tmp_path / "repos")) data = bytes(range(256)) oid = _oid() await b.put(oid, data, repo_root=rr) assert await b.get(oid, repo_root=rr) == data # ═══════════════════════════════════════════════════════════════════════════════ # Tier 4 — Stress # ═══════════════════════════════════════════════════════════════════════════════ class TestStress: async def test_50_objects_per_repo_all_isolated(self, tmp_path: Path) -> None: """50 objects written to each of 3 repos — no cross-repo leakage.""" from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() repos = [ repo_root_for("alice", "repo1", repos_dir=str(tmp_path / "repos")), repo_root_for("bob", "repo2", repos_dir=str(tmp_path / "repos")), repo_root_for("carol", "repo3", repos_dir=str(tmp_path / "repos")), ] oids = [_oid() for _ in range(50)] for rr in repos: for oid in oids: await b.put(oid, oid.encode(), repo_root=rr) for rr in repos: for oid in oids: assert await b.exists(oid, repo_root=rr) is True assert await b.get(oid, repo_root=rr) == oid.encode() async def test_concurrent_puts_to_different_repos(self, tmp_path: Path) -> None: """Concurrent puts to N repos do not interfere with each other.""" from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() N = 20 repos = [ repo_root_for(f"user{i}", "repo", repos_dir=str(tmp_path / "repos")) for i in range(N) ] oid = _oid() data = b"concurrent content" await asyncio.gather(*[b.put(oid, data, repo_root=rr) for rr in repos]) for rr in repos: assert await b.get(oid, repo_root=rr) == data async def test_20_repos_under_5_seconds(self, tmp_path: Path) -> None: """Writing 10 objects to each of 20 repos must complete in under 5 seconds.""" from musehub.storage.backends import LocalBackend, repo_root_for b = LocalBackend() repos = [ repo_root_for(f"user{i}", "r", repos_dir=str(tmp_path / "repos")) for i in range(20) ] oids = [_oid() for _ in range(10)] start = time.perf_counter() for rr in repos: for oid in oids: await b.put(oid, b"x" * 100, repo_root=rr) elapsed = time.perf_counter() - start assert elapsed < 5.0, f"20-repo × 10-object write took {elapsed:.2f}s" # ═══════════════════════════════════════════════════════════════════════════════ # Tier 5 — Data Integrity # ═══════════════════════════════════════════════════════════════════════════════ class TestDataIntegrity: async def test_put_idempotent_with_repo_root(self, tmp_path: Path) -> None: """Putting the same bytes twice with repo_root is idempotent.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() await b.put(oid, b"content", repo_root=rr) await b.put(oid, b"content", repo_root=rr) assert await b.get(oid, repo_root=rr) == b"content" async def test_uri_for_with_repo_root_matches_put_uri(self, tmp_path: Path) -> None: """uri_for(oid, repo_root=...) must return the same URI that put returns.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() put_uri = await b.put(oid, b"check", repo_root=rr) computed_uri = b.uri_for(oid, repo_root=rr) assert put_uri == computed_uri async def test_two_repo_paths_do_not_collide(self, tmp_path: Path) -> None: """Same oid written to two different repo roots are stored independently.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr_a = _repo_root(tmp_path, owner="alice", slug="repo") rr_b = _repo_root(tmp_path, owner="bob", slug="repo") oid = _oid() await b.put(oid, b"alice version", repo_root=rr_a) await b.put(oid, b"bob version", repo_root=rr_b) assert await b.get(oid, repo_root=rr_a) == b"alice version" assert await b.get(oid, repo_root=rr_b) == b"bob version" async def test_delete_from_one_repo_does_not_affect_other(self, tmp_path: Path) -> None: """Deleting with repo_root only removes that repo's copy; another repo's copy survives.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr_a = _repo_root(tmp_path, owner="alice", slug="repo") rr_b = _repo_root(tmp_path, owner="bob", slug="repo") oid = _oid() await b.put(oid, b"alice copy", repo_root=rr_a) await b.put(oid, b"bob copy", repo_root=rr_b) await b.delete(oid, repo_root=rr_a) assert await b.get(oid, repo_root=rr_a) is None assert await b.get(oid, repo_root=rr_b) == b"bob copy" async def test_file_inside_repo_algo_shard_structure(self, tmp_path: Path) -> None: """After put, the file exists at repo_root/objects/sha256//.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = long_id("ab" * 32) await b.put(oid, b"exact path check", repo_root=rr) hex_part = "ab" * 32 expected = rr / "objects" / "sha256" / hex_part[:2] / hex_part[2:] assert expected.exists() assert expected.read_bytes() == b"exact path check" async def test_repo_root_for_path_structure(self, tmp_path: Path) -> None: """repo_root_for produces the exact expected path structure.""" from musehub.storage.backends import repo_root_for rr = repo_root_for("gabriel", "musehub", repos_dir=str(tmp_path)) assert rr == tmp_path / "gabriel" / "musehub" # ═══════════════════════════════════════════════════════════════════════════════ # Tier 6 — Security # ═══════════════════════════════════════════════════════════════════════════════ class TestSecurity: def test_path_traversal_in_object_id_raises_with_repo_root(self, tmp_path: Path) -> None: """object_path validates object IDs strictly — traversal raises ValueError.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises(ValueError): b._path("../../etc/passwd", repo_root=rr) def test_invalid_object_id_raises_with_repo_root(self, tmp_path: Path) -> None: """Any non-sha256:<64-hex> object_id raises ValueError in per-repo mode.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises(ValueError): b._path("../../../../../root/.ssh/authorized_keys", repo_root=rr) async def test_put_with_invalid_object_id_raises(self, tmp_path: Path) -> None: """put with a non-valid object_id raises — no file written outside repo.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises((ValueError, Exception)): await b.put("../escaped", b"data", repo_root=rr) async def test_get_with_invalid_object_id_raises(self, tmp_path: Path) -> None: """get with invalid object_id raises ValueError — no filesystem access.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises(ValueError): await b.get("../../etc/passwd", repo_root=rr) async def test_exists_with_invalid_object_id_raises(self, tmp_path: Path) -> None: """exists with invalid object_id raises ValueError.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) with pytest.raises(ValueError): await b.exists("../outside", repo_root=rr) def test_repo_root_for_does_not_allow_traversal_in_owner(self, tmp_path: Path) -> None: """repo_root_for must not allow path traversal via owner or slug.""" from musehub.storage.backends import repo_root_for import pytest with pytest.raises((ValueError, Exception)): repo_root_for("../../etc", "passwd", repos_dir=str(tmp_path)) def test_repo_root_for_does_not_allow_traversal_in_slug(self, tmp_path: Path) -> None: from musehub.storage.backends import repo_root_for import pytest with pytest.raises((ValueError, Exception)): repo_root_for("alice", "../../shadow", repos_dir=str(tmp_path)) async def test_file_immutable_after_put_with_repo_root(self, tmp_path: Path) -> None: """After put with repo_root, the file must be 0o444 (immutable).""" import stat as _stat from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() await b.put(oid, b"immutable", repo_root=rr) path = b._path(oid, repo_root=rr) mode = _stat.S_IMODE(path.stat().st_mode) assert mode == 0o444 # ═══════════════════════════════════════════════════════════════════════════════ # Tier 7 — Performance # ═══════════════════════════════════════════════════════════════════════════════ class TestPerformance: async def test_put_with_repo_root_latency(self, tmp_path: Path) -> None: """Single put with repo_root must complete in under 0.5s.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) data = b"perf payload" * 100 start = time.perf_counter() await b.put(_oid(), data, repo_root=rr) elapsed = time.perf_counter() - start assert elapsed < 0.5 async def test_50_sequential_puts_with_repo_root_under_budget(self, tmp_path: Path) -> None: """50 sequential puts with repo_root must complete in under 2s.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) data = b"payload" * 100 start = time.perf_counter() for i in range(50): await b.put(_oid(), data, repo_root=rr) elapsed = time.perf_counter() - start assert elapsed < 2.0 async def test_exists_50_calls_with_repo_root_under_budget(self, tmp_path: Path) -> None: """50 exists() calls with repo_root must complete in under 1s.""" from musehub.storage.backends import LocalBackend b = LocalBackend() rr = _repo_root(tmp_path) oid = _oid() await b.put(oid, b"data", repo_root=rr) start = time.perf_counter() for _ in range(50): await b.exists(oid, repo_root=rr) elapsed = time.perf_counter() - start assert elapsed < 1.0 async def test_repo_root_for_1000_calls_under_1_second(self, tmp_path: Path) -> None: """repo_root_for is pure path math — 1000 calls must be under 1s.""" from musehub.storage.backends import repo_root_for start = time.perf_counter() for i in range(1000): repo_root_for(f"user{i}", "repo", repos_dir=str(tmp_path)) elapsed = time.perf_counter() - start assert elapsed < 1.0