gabriel / muse public
test_transport_snapshot_directories_dropped.py python
280 lines 11.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Bug 11: _coerce_snapshot_dict silently drops the ``directories``
2 (and ``note``) fields from incoming snapshot data.
3
4 Root cause: the original implementation returned a SnapshotDict with only
5 ``snapshot_id``, ``manifest``, and ``created_at`` — omitting ``directories``
6 and ``note``. Since ``directories`` feeds into ``compute_snapshot_id``, any
7 snapshot with non-empty directories would:
8
9 (before Bug-9 fix) be written with directories=[] → stored snapshot_id
10 doesn't match recomputed hash → read_snapshot returns None forever.
11
12 (after Bug-9 fix) be rejected by write_snapshot's incoming hash verification
13 → apply_pack logs a warning and skips it → the snapshot never reaches disk.
14
15 Either way, snapshots with non-empty directories could never be received via
16 the HTTP transport layer.
17
18 Scope of tests
19 --------------
20 Unit (_coerce_snapshot_dict):
21 - directories list is preserved (was silently dropped)
22 - note string is preserved (was silently dropped)
23 - missing directories defaults to empty list (no crash)
24 - empty string note preserved
25 - extra unknown keys in raw dict are safely ignored
26
27 Integration (_parse_bundle round-trip):
28 - bundle with snapshot directories survives parse
29 - bundle with snapshot note survives parse
30 - bundle without directories key produces empty list (not KeyError)
31
32 End-to-end (parse → write_snapshot → read_snapshot):
33 - snapshot with directories written from parsed bundle is readable
34 - snapshot_id matches after parse + write + read
35 - snapshot without directories still works after fix
36
37 Security:
38 - directories entries that are not strings are filtered out
39 - non-list directories value is treated as empty list
40 """
41 from __future__ import annotations
42
43 import datetime
44 import pathlib
45
46 import msgpack
47 import pytest
48
49 from muse.core.snapshot import compute_snapshot_id
50 from muse.core.store import (
51 SnapshotDict,
52 SnapshotRecord,
53 read_snapshot,
54 write_snapshot,
55 )
56 from muse.core._types import Manifest, MsgpackDict
57 from muse.core.transport import _coerce_snapshot_dict, _parse_bundle
58
59 _TS = "2024-06-15T10:00:00+00:00"
60
61
62 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
63 repo = tmp_path / "repo"
64 repo.mkdir()
65 (repo / ".muse").mkdir()
66 (repo / ".muse" / "snapshots").mkdir()
67 return repo
68
69
70 def _snap_wire(
71 manifest: Manifest,
72 directories: list[str],
73 note: str = "",
74 ) -> SnapshotDict:
75 snap_id = compute_snapshot_id(manifest, directories)
76 return SnapshotDict(
77 snapshot_id=snap_id,
78 manifest=manifest,
79 directories=directories,
80 created_at=_TS,
81 note=note,
82 )
83
84
85 # ──────────────────────────────────────────────────────────────────────────────
86 # Unit: _coerce_snapshot_dict
87 # ──────────────────────────────────────────────────────────────────────────────
88
89 class TestCoerceSnapshotDict:
90
91 def test_directories_preserved(self) -> None:
92 """Bug 11: directories must not be dropped by _coerce_snapshot_dict."""
93 raw = {
94 "snapshot_id": "a" * 64,
95 "manifest": {"src/main.py": "b" * 64},
96 "directories": ["src", "tests"],
97 "created_at": _TS,
98 "note": "",
99 }
100 result = _coerce_snapshot_dict(raw)
101 assert result["directories"] == ["src", "tests"], (
102 "BUG 11: directories were silently dropped during snapshot coercion"
103 )
104
105 def test_note_preserved(self) -> None:
106 """Bug 11: note must not be dropped by _coerce_snapshot_dict."""
107 raw = {
108 "snapshot_id": "a" * 64,
109 "manifest": {},
110 "directories": [],
111 "created_at": _TS,
112 "note": "initial snapshot",
113 }
114 result = _coerce_snapshot_dict(raw)
115 assert result["note"] == "initial snapshot"
116
117 def test_missing_directories_defaults_to_empty_list(self) -> None:
118 raw = {
119 "snapshot_id": "a" * 64,
120 "manifest": {},
121 "created_at": _TS,
122 }
123 result = _coerce_snapshot_dict(raw)
124 assert result["directories"] == []
125
126 def test_empty_string_note_preserved(self) -> None:
127 raw = {
128 "snapshot_id": "a" * 64,
129 "manifest": {},
130 "directories": [],
131 "created_at": _TS,
132 "note": "",
133 }
134 result = _coerce_snapshot_dict(raw)
135 assert result["note"] == ""
136
137 def test_non_list_directories_treated_as_empty(self) -> None:
138 """A non-list directories value must not crash — default to []."""
139 raw = {
140 "snapshot_id": "a" * 64,
141 "manifest": {},
142 "directories": "not-a-list",
143 "created_at": _TS,
144 "note": "",
145 }
146 result = _coerce_snapshot_dict(raw)
147 assert result["directories"] == []
148
149 def test_non_string_directory_entries_filtered(self) -> None:
150 """Non-string entries in the directories list must be filtered out."""
151 raw = {
152 "snapshot_id": "a" * 64,
153 "manifest": {},
154 "directories": ["src", 42, None, "tests", b"bytes"],
155 "created_at": _TS,
156 "note": "",
157 }
158 result = _coerce_snapshot_dict(raw)
159 assert result["directories"] == ["src", "tests"]
160
161
162 # ──────────────────────────────────────────────────────────────────────────────
163 # Integration: _parse_bundle round-trip
164 # ──────────────────────────────────────────────────────────────────────────────
165
166 class TestParseBundleSnapshotDirectories:
167
168 def _make_bundle_bytes(self, snapshots: list[SnapshotDict]) -> bytes:
169 return msgpack.packb({"snapshots": snapshots, "commits": [], "objects": [], "tags": []}, use_bin_type=True)
170
171 def test_bundle_snapshot_directories_survive_parse(self) -> None:
172 """Snapshot directories must be present after _parse_bundle."""
173 wire = _snap_wire({"src/main.py": "a" * 64}, ["src", "tests"])
174 bundle_bytes = self._make_bundle_bytes([wire])
175 bundle = _parse_bundle(bundle_bytes)
176
177 snaps = bundle.get("snapshots") or []
178 assert len(snaps) == 1
179 assert snaps[0]["directories"] == ["src", "tests"], (
180 "BUG 11: directories were dropped during _parse_bundle"
181 )
182
183 def test_bundle_snapshot_note_survives_parse(self) -> None:
184 wire = _snap_wire({"src/main.py": "a" * 64}, [], note="feature snapshot")
185 bundle_bytes = self._make_bundle_bytes([wire])
186 bundle = _parse_bundle(bundle_bytes)
187
188 snaps = bundle.get("snapshots") or []
189 assert snaps[0]["note"] == "feature snapshot"
190
191 def test_bundle_snapshot_without_directories_key_no_keyerror(self) -> None:
192 """A snapshot with no directories key must not raise KeyError."""
193 wire = {
194 "snapshot_id": compute_snapshot_id({"f.py": "a" * 64}, []),
195 "manifest": {"f.py": "a" * 64},
196 "created_at": _TS,
197 # deliberately omit "directories" and "note"
198 }
199 bundle_bytes = self._make_bundle_bytes([wire])
200 bundle = _parse_bundle(bundle_bytes) # must not raise
201 snaps = bundle.get("snapshots") or []
202 assert snaps[0]["directories"] == []
203
204
205 # ──────────────────────────────────────────────────────────────────────────────
206 # End-to-end: parse → write_snapshot → read_snapshot
207 # ──────────────────────────────────────────────────────────────────────────────
208
209 class TestSnapshotDirectoriesE2E:
210
211 def _apply_snapshot_from_bundle(self, repo: pathlib.Path, wire: MsgpackDict) -> None:
212 """Simulate apply_pack for a single snapshot via _parse_bundle."""
213 bundle_bytes = msgpack.packb(
214 {"snapshots": [wire], "commits": [], "objects": [], "tags": []},
215 use_bin_type=True,
216 )
217 bundle = _parse_bundle(bundle_bytes)
218 for snap_dict in bundle.get("snapshots") or []:
219 snap = SnapshotRecord.from_dict(snap_dict)
220 write_snapshot(repo, snap)
221
222 def test_snapshot_with_directories_readable_after_parse_and_write(self, tmp_path: pathlib.Path) -> None:
223 """Bug 11: snapshot with directories must be readable after HTTP parse + write."""
224 repo = _make_repo(tmp_path)
225 manifest = {"src/main.py": "a" * 64, "tests/test_main.py": "b" * 64}
226 directories = ["src", "tests"]
227 wire = _snap_wire(manifest, directories)
228
229 self._apply_snapshot_from_bundle(repo, wire)
230
231 stored = read_snapshot(repo, wire["snapshot_id"])
232 assert stored is not None, (
233 "BUG 11: snapshot with directories is unreadable after HTTP parse → write"
234 )
235 assert stored.snapshot_id == wire["snapshot_id"]
236 assert stored.directories == directories
237 assert stored.manifest == manifest
238
239 def test_snapshot_id_matches_after_parse_write_read(self, tmp_path: pathlib.Path) -> None:
240 """The snapshot_id must be the same at every stage of the pipeline."""
241 repo = _make_repo(tmp_path)
242 manifest = {"app/main.py": "c" * 64}
243 directories = ["app"]
244 wire = _snap_wire(manifest, directories)
245 expected_id = wire["snapshot_id"]
246
247 self._apply_snapshot_from_bundle(repo, wire)
248
249 stored = read_snapshot(repo, expected_id)
250 assert stored is not None
251 assert stored.snapshot_id == expected_id
252
253 def test_snapshot_without_directories_still_works_after_fix(self, tmp_path: pathlib.Path) -> None:
254 """Regression: snapshots without directories must still be stored after the fix."""
255 repo = _make_repo(tmp_path)
256 manifest = {"README.md": "d" * 64}
257 wire = _snap_wire(manifest, directories=[])
258
259 self._apply_snapshot_from_bundle(repo, wire)
260
261 stored = read_snapshot(repo, wire["snapshot_id"])
262 assert stored is not None
263 assert stored.directories == []
264
265 def test_snapshot_with_multiple_directories_round_trips(self, tmp_path: pathlib.Path) -> None:
266 """Snapshot with several directories must survive the full HTTP transport pipeline."""
267 repo = _make_repo(tmp_path)
268 manifest = {
269 "src/a.py": "a" * 64,
270 "lib/b.py": "b" * 64,
271 "docs/c.md": "c" * 64,
272 }
273 directories = ["docs", "lib", "src"]
274 wire = _snap_wire(manifest, directories)
275
276 self._apply_snapshot_from_bundle(repo, wire)
277
278 stored = read_snapshot(repo, wire["snapshot_id"])
279 assert stored is not None
280 assert sorted(stored.directories) == sorted(directories)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago