gabriel / muse public
test_snapshot_supercharge.py python
827 lines 32.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Comprehensive tests for ``muse snapshot`` subcommands.
2
3 Covers gaps in the original test_cmd_snapshot.py:
4
5 * JSON envelope — duration_ms / exit_code on all four subcommands
6 * JSON schema completeness — all documented fields, correct types
7 * Bug regression — sha256: prefix round-trip through _list_all_snapshots /
8 _resolve_snapshot (bare-hex stem bug)
9 * Data integrity — create → export tar.gz/zip → extract → verify file content
10 * Security — ANSI escape injection in note, symlink skip in snapshots dir,
11 path traversal rejected by _validate_snapshot_id_prefix / _safe_arcname,
12 zip-slip guard for crafted manifest entries
13 * Text mode — ``snapshot read --text`` output format
14 * --prefix — files nested under prefix directory inside archive
15 * Limit validation — limit=0 rejected, limit=1 honoured, limit clamps output
16 * Idempotency — identical working-tree always produces the same snapshot_id
17 * Empty list envelope — snapshot list --json returns envelope even when empty
18 * Concurrent stress — N parallel snapshot creates, all independent and valid
19 * Large file export — single 5 MiB file round-trips correctly
20 """
21
22 from __future__ import annotations
23
24 import json
25 import os
26 import pathlib
27 import tarfile
28 import threading
29 import zipfile
30
31 import pytest
32
33 from tests.cli_test_helper import CliRunner
34
35 cli = None # argparse migration — CliRunner ignores this arg
36
37 runner = CliRunner()
38
39
40 # ---------------------------------------------------------------------------
41 # Shared helpers
42 # ---------------------------------------------------------------------------
43
44
45 def _init_repo(path: pathlib.Path) -> pathlib.Path:
46 muse = path / ".muse"
47 for d in ("commits", "snapshots", "objects", "refs/heads"):
48 (muse / d).mkdir(parents=True, exist_ok=True)
49 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (muse / "repo.json").write_text(
51 json.dumps({"repo_id": "snap-supercharge", "domain": "code"}),
52 encoding="utf-8",
53 )
54 return path
55
56
57 def _env(repo: pathlib.Path) -> dict[str, str]:
58 return {"MUSE_REPO_ROOT": str(repo)}
59
60
61 def _create_files(root: pathlib.Path, count: int = 3) -> list[str]:
62 names: list[str] = []
63 for i in range(count):
64 name = f"file_{i}.txt"
65 (root / name).write_text(f"content-{i}", encoding="utf-8")
66 names.append(name)
67 return names
68
69
70 def _create_snapshot(root: pathlib.Path, note: str = "") -> dict:
71 """Create a snapshot and return the parsed JSON output."""
72 cmd = ["snapshot", "create", "--json"]
73 if note:
74 cmd += ["-m", note]
75 result = runner.invoke(cli, cmd, env=_env(root))
76 assert result.exit_code == 0, result.output
77 return json.loads(result.output)
78
79
80 # ---------------------------------------------------------------------------
81 # JSON envelope — duration_ms / exit_code
82 # ---------------------------------------------------------------------------
83
84
85 class TestJsonEnvelope:
86 """Every --json subcommand must include duration_ms and exit_code."""
87
88 def test_create_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
89 _init_repo(tmp_path)
90 _create_files(tmp_path, 1)
91 data = _create_snapshot(tmp_path)
92 assert "duration_ms" in data
93 assert isinstance(data["duration_ms"], (int, float))
94 assert data["duration_ms"] >= 0
95
96 def test_create_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
97 _init_repo(tmp_path)
98 _create_files(tmp_path, 1)
99 data = _create_snapshot(tmp_path)
100 assert data["exit_code"] == 0
101
102 def test_list_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
103 _init_repo(tmp_path)
104 _create_files(tmp_path, 1)
105 _create_snapshot(tmp_path)
106 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
107 assert result.exit_code == 0
108 data = json.loads(result.output)
109 assert "duration_ms" in data
110 assert isinstance(data["duration_ms"], (int, float))
111 assert data["duration_ms"] >= 0
112
113 def test_list_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
114 _init_repo(tmp_path)
115 _create_files(tmp_path, 1)
116 _create_snapshot(tmp_path)
117 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
118 data = json.loads(result.output)
119 assert data["exit_code"] == 0
120
121 def test_list_empty_has_envelope(self, tmp_path: pathlib.Path) -> None:
122 _init_repo(tmp_path)
123 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
124 assert result.exit_code == 0
125 data = json.loads(result.output)
126 assert data["snapshots"] == []
127 assert "duration_ms" in data
128 assert data["exit_code"] == 0
129
130 def test_read_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
131 _init_repo(tmp_path)
132 _create_files(tmp_path, 1)
133 created = _create_snapshot(tmp_path)
134 snap_id = created["snapshot_id"]
135 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
136 assert result.exit_code == 0
137 data = json.loads(result.output)
138 assert "duration_ms" in data
139 assert isinstance(data["duration_ms"], (int, float))
140 assert data["duration_ms"] >= 0
141
142 def test_read_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
143 _init_repo(tmp_path)
144 _create_files(tmp_path, 1)
145 created = _create_snapshot(tmp_path)
146 snap_id = created["snapshot_id"]
147 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
148 data = json.loads(result.output)
149 assert data["exit_code"] == 0
150
151 def test_export_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
152 _init_repo(tmp_path)
153 _create_files(tmp_path, 1)
154 created = _create_snapshot(tmp_path)
155 snap_id = created["snapshot_id"]
156 out = tmp_path / "out.tar.gz"
157 result = runner.invoke(
158 cli,
159 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
160 env=_env(tmp_path),
161 )
162 assert result.exit_code == 0
163 data = json.loads(result.output)
164 assert "duration_ms" in data
165 assert isinstance(data["duration_ms"], (int, float))
166 assert data["duration_ms"] >= 0
167
168 def test_export_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
169 _init_repo(tmp_path)
170 _create_files(tmp_path, 1)
171 created = _create_snapshot(tmp_path)
172 snap_id = created["snapshot_id"]
173 out = tmp_path / "out.tar.gz"
174 result = runner.invoke(
175 cli,
176 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
177 env=_env(tmp_path),
178 )
179 data = json.loads(result.output)
180 assert data["exit_code"] == 0
181
182
183 # ---------------------------------------------------------------------------
184 # JSON schema completeness
185 # ---------------------------------------------------------------------------
186
187
188 class TestJsonSchemaCompleteness:
189 """All documented fields must be present with correct types."""
190
191 def test_create_schema(self, tmp_path: pathlib.Path) -> None:
192 _init_repo(tmp_path)
193 _create_files(tmp_path, 2)
194 data = _create_snapshot(tmp_path, note="schema-test")
195 assert isinstance(data["repo_id"], str)
196 assert isinstance(data["snapshot_id"], str)
197 assert data["snapshot_id"].startswith("sha256:")
198 assert isinstance(data["file_count"], int)
199 assert data["file_count"] >= 1
200 assert isinstance(data["note"], str)
201 assert data["note"] == "schema-test"
202 assert isinstance(data["created_at"], str)
203 # ISO-8601: basic sanity check
204 assert "T" in data["created_at"] or "-" in data["created_at"]
205 assert isinstance(data["duration_ms"], (int, float))
206 assert isinstance(data["exit_code"], int)
207
208 def test_list_schema(self, tmp_path: pathlib.Path) -> None:
209 _init_repo(tmp_path)
210 _create_files(tmp_path, 2)
211 _create_snapshot(tmp_path, note="list-schema")
212 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
213 assert result.exit_code == 0
214 data = json.loads(result.output)
215 assert "snapshots" in data
216 assert isinstance(data["snapshots"], list)
217 assert "duration_ms" in data
218 assert "exit_code" in data
219 item = data["snapshots"][0]
220 assert isinstance(item["snapshot_id"], str)
221 assert item["snapshot_id"].startswith("sha256:")
222 assert isinstance(item["file_count"], int)
223 assert isinstance(item["note"], str)
224 assert isinstance(item["created_at"], str)
225
226 def test_read_schema(self, tmp_path: pathlib.Path) -> None:
227 _init_repo(tmp_path)
228 _create_files(tmp_path, 2)
229 created = _create_snapshot(tmp_path, note="read-schema")
230 snap_id = created["snapshot_id"]
231 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
232 assert result.exit_code == 0
233 data = json.loads(result.output)
234 assert isinstance(data["snapshot_id"], str)
235 assert data["snapshot_id"].startswith("sha256:")
236 assert isinstance(data["created_at"], str)
237 assert isinstance(data["file_count"], int)
238 assert isinstance(data["note"], str)
239 assert isinstance(data["manifest"], dict)
240 assert len(data["manifest"]) == data["file_count"]
241 assert isinstance(data["duration_ms"], (int, float))
242 assert isinstance(data["exit_code"], int)
243
244 def test_export_schema(self, tmp_path: pathlib.Path) -> None:
245 _init_repo(tmp_path)
246 _create_files(tmp_path, 2)
247 created = _create_snapshot(tmp_path)
248 snap_id = created["snapshot_id"]
249 out = tmp_path / "schema.tar.gz"
250 result = runner.invoke(
251 cli,
252 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
253 env=_env(tmp_path),
254 )
255 assert result.exit_code == 0
256 data = json.loads(result.output)
257 assert isinstance(data["snapshot_id"], str)
258 assert isinstance(data["output"], str)
259 assert data["format"] in ("tar.gz", "zip")
260 assert isinstance(data["file_count"], int)
261 assert isinstance(data["size_bytes"], int)
262 assert data["size_bytes"] > 0
263 assert isinstance(data["duration_ms"], (int, float))
264 assert isinstance(data["exit_code"], int)
265
266 def test_manifest_keys_are_sorted(self, tmp_path: pathlib.Path) -> None:
267 _init_repo(tmp_path)
268 # Create files in reverse alpha order to verify manifest sorts them.
269 for name in ("zzz.txt", "aaa.txt", "mmm.txt"):
270 (tmp_path / name).write_text(name, encoding="utf-8")
271 created = _create_snapshot(tmp_path)
272 snap_id = created["snapshot_id"]
273 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
274 data = json.loads(result.output)
275 keys = list(data["manifest"].keys())
276 assert keys == sorted(keys)
277
278
279 # ---------------------------------------------------------------------------
280 # Bug regression — sha256: prefix round-trip
281 # ---------------------------------------------------------------------------
282
283
284 class TestSha256PrefixRoundTrip:
285 """Regression for the bare-hex-stem bug: _list_all_snapshots and
286 _resolve_snapshot were passing path.stem (bare hex) to read_snapshot,
287 which then compared it against compute_snapshot_id output (sha256: prefixed),
288 causing every snapshot to fail content-hash verification and appear missing."""
289
290 def test_list_after_create_returns_snapshot(self, tmp_path: pathlib.Path) -> None:
291 _init_repo(tmp_path)
292 _create_files(tmp_path, 2)
293 created = _create_snapshot(tmp_path)
294 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
295 assert result.exit_code == 0
296 data = json.loads(result.output)
297 ids = [s["snapshot_id"] for s in data["snapshots"]]
298 assert created["snapshot_id"] in ids
299
300 def test_read_by_full_id_succeeds(self, tmp_path: pathlib.Path) -> None:
301 _init_repo(tmp_path)
302 _create_files(tmp_path, 1)
303 created = _create_snapshot(tmp_path)
304 snap_id = created["snapshot_id"]
305 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
306 assert result.exit_code == 0
307
308 def test_bare_hex_prefix_rejected(self, tmp_path: pathlib.Path) -> None:
309 """Bare hex prefix (no sha256: type tag) must be rejected at the CLI boundary."""
310 _init_repo(tmp_path)
311 _create_files(tmp_path, 1)
312 created = _create_snapshot(tmp_path)
313 snap_id = created["snapshot_id"]
314 hex_only = snap_id[len("sha256:"):]
315 result = runner.invoke(cli, ["snapshot", "read", hex_only[:12]], env=_env(tmp_path))
316 assert result.exit_code != 0
317
318 def test_read_by_sha256_prefix_succeeds(self, tmp_path: pathlib.Path) -> None:
319 """Full sha256:... ID passed to snapshot read must resolve."""
320 _init_repo(tmp_path)
321 _create_files(tmp_path, 1)
322 created = _create_snapshot(tmp_path)
323 snap_id = created["snapshot_id"]
324 result = runner.invoke(cli, ["snapshot", "read", snap_id], env=_env(tmp_path))
325 assert result.exit_code == 0
326 data = json.loads(result.output)
327 assert data["snapshot_id"] == snap_id
328
329 def test_snapshot_id_in_read_matches_create(self, tmp_path: pathlib.Path) -> None:
330 _init_repo(tmp_path)
331 _create_files(tmp_path, 2)
332 created = _create_snapshot(tmp_path)
333 result = runner.invoke(cli, ["snapshot", "read", created["snapshot_id"]], env=_env(tmp_path))
334 data = json.loads(result.output)
335 assert data["snapshot_id"] == created["snapshot_id"]
336
337
338 # ---------------------------------------------------------------------------
339 # Data integrity — create → export → verify content
340 # ---------------------------------------------------------------------------
341
342
343 class TestDataIntegrity:
344 """File contents written to archives must match the original source files."""
345
346 def test_tar_gz_content_matches_source(self, tmp_path: pathlib.Path) -> None:
347 _init_repo(tmp_path)
348 names = _create_files(tmp_path, 3)
349 created = _create_snapshot(tmp_path)
350 snap_id = created["snapshot_id"]
351 out = tmp_path / "integrity.tar.gz"
352 runner.invoke(
353 cli,
354 ["snapshot", "export", snap_id, "--output", str(out)],
355 env=_env(tmp_path),
356 )
357 assert out.exists()
358 with tarfile.open(out, "r:gz") as tar:
359 members = {m.name: m for m in tar.getmembers()}
360 for name in names:
361 match = [k for k in members if k.endswith(name)]
362 assert match, f"{name} not found in archive"
363 content = tar.extractfile(members[match[0]])
364 assert content is not None
365 extracted = content.read().decode("utf-8")
366 expected = (tmp_path / name).read_text(encoding="utf-8")
367 assert extracted == expected, f"content mismatch for {name}"
368
369 def test_zip_content_matches_source(self, tmp_path: pathlib.Path) -> None:
370 _init_repo(tmp_path)
371 names = _create_files(tmp_path, 3)
372 created = _create_snapshot(tmp_path)
373 snap_id = created["snapshot_id"]
374 out = tmp_path / "integrity.zip"
375 runner.invoke(
376 cli,
377 ["snapshot", "export", snap_id, "--format", "zip", "--output", str(out)],
378 env=_env(tmp_path),
379 )
380 assert out.exists()
381 with zipfile.ZipFile(out, "r") as zf:
382 namelist = zf.namelist()
383 for name in names:
384 match = [k for k in namelist if k.endswith(name)]
385 assert match, f"{name} not found in zip"
386 extracted = zf.read(match[0]).decode("utf-8")
387 expected = (tmp_path / name).read_text(encoding="utf-8")
388 assert extracted == expected, f"content mismatch for {name}"
389
390 def test_export_file_count_matches_snapshot(self, tmp_path: pathlib.Path) -> None:
391 _init_repo(tmp_path)
392 _create_files(tmp_path, 4)
393 created = _create_snapshot(tmp_path)
394 snap_id = created["snapshot_id"]
395 out = tmp_path / "count.tar.gz"
396 result = runner.invoke(
397 cli,
398 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
399 env=_env(tmp_path),
400 )
401 assert result.exit_code == 0
402 data = json.loads(result.output)
403 assert data["file_count"] == created["file_count"]
404
405 def test_export_size_bytes_matches_disk(self, tmp_path: pathlib.Path) -> None:
406 _init_repo(tmp_path)
407 _create_files(tmp_path, 2)
408 created = _create_snapshot(tmp_path)
409 snap_id = created["snapshot_id"]
410 out = tmp_path / "size.tar.gz"
411 result = runner.invoke(
412 cli,
413 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
414 env=_env(tmp_path),
415 )
416 data = json.loads(result.output)
417 assert data["size_bytes"] == out.stat().st_size
418
419
420 # ---------------------------------------------------------------------------
421 # Security
422 # ---------------------------------------------------------------------------
423
424
425 class TestSecurity:
426 """Security properties of snapshot commands."""
427
428 def test_ansi_escape_in_note_sanitized_in_text_output(self, tmp_path: pathlib.Path) -> None:
429 """ANSI escape sequences in notes must not reach the terminal raw."""
430 _init_repo(tmp_path)
431 _create_files(tmp_path, 1)
432 evil_note = "\x1b[31mred\x1b[0m"
433 result = runner.invoke(
434 cli, ["snapshot", "create", "-m", evil_note], env=_env(tmp_path)
435 )
436 assert result.exit_code == 0
437 # ANSI escape character should not appear verbatim in text output.
438 assert "\x1b" not in result.output
439
440 def test_note_appears_sanitized_in_list_text(self, tmp_path: pathlib.Path) -> None:
441 _init_repo(tmp_path)
442 _create_files(tmp_path, 1)
443 evil_note = "\x1b[1mBOLD\x1b[0m"
444 _create_snapshot(tmp_path, note=evil_note)
445 result = runner.invoke(cli, ["snapshot", "list"], env=_env(tmp_path))
446 assert result.exit_code == 0
447 assert "\x1b" not in result.output
448
449 def test_symlink_in_snapshots_dir_is_skipped(self, tmp_path: pathlib.Path) -> None:
450 """A symlink inside .muse/snapshots/ must not be read as a snapshot."""
451 _init_repo(tmp_path)
452 _create_files(tmp_path, 1)
453 created = _create_snapshot(tmp_path)
454 snaps_dir = tmp_path / ".muse" / "snapshots"
455 # Plant a symlink pointing to a real file outside the snapshot namespace.
456 target = tmp_path / "some_file.txt"
457 target.write_bytes(b"payload")
458 fake_stem = "deadbeef" + "0" * 56
459 link = snaps_dir / f"{fake_stem}.msgpack"
460 try:
461 link.symlink_to(target)
462 except (OSError, NotImplementedError):
463 pytest.skip("symlinks not supported on this platform")
464 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
465 assert result.exit_code == 0
466 data = json.loads(result.output)
467 # Only the legitimately created snapshot should appear.
468 ids = [s["snapshot_id"] for s in data["snapshots"]]
469 assert len(ids) == 1
470 assert ids[0] == created["snapshot_id"]
471
472 def test_path_traversal_in_snapshot_id_prefix_is_safe(self, tmp_path: pathlib.Path) -> None:
473 """A crafted snapshot_id with ../ must not escape the snapshots dir."""
474 _init_repo(tmp_path)
475 result = runner.invoke(
476 cli,
477 ["snapshot", "read", "../../etc/passwd"],
478 env=_env(tmp_path),
479 )
480 # Must fail gracefully — not crash, not read /etc/passwd.
481 assert result.exit_code != 0
482
483 def test_safe_arcname_rejects_dotdot_path(self, tmp_path: pathlib.Path) -> None:
484 """_safe_arcname must return None for paths with .. segments."""
485 from muse.cli.commands.snapshot_cmd import _safe_arcname
486
487 assert _safe_arcname("", "../etc/passwd") is None
488 assert _safe_arcname("prefix", "../../secret") is None
489
490 def test_safe_arcname_rejects_absolute_path(self, tmp_path: pathlib.Path) -> None:
491 from muse.cli.commands.snapshot_cmd import _safe_arcname
492
493 assert _safe_arcname("", "/etc/passwd") is None
494 assert _safe_arcname("prefix", "/root/.ssh/id_rsa") is None
495
496 def test_safe_arcname_accepts_normal_path(self, tmp_path: pathlib.Path) -> None:
497 from muse.cli.commands.snapshot_cmd import _safe_arcname
498
499 assert _safe_arcname("", "src/main.py") == "src/main.py"
500 assert _safe_arcname("myproject", "lib/util.py") == "myproject/lib/util.py"
501
502 def test_safe_arcname_rejects_dotdot_in_prefix(self) -> None:
503 from muse.cli.commands.snapshot_cmd import _safe_arcname
504
505 assert _safe_arcname("../escape", "file.txt") is None
506
507
508 # ---------------------------------------------------------------------------
509 # Text mode — snapshot read --text
510 # ---------------------------------------------------------------------------
511
512
513 class TestTextMode:
514 def test_read_text_shows_snapshot_id(self, tmp_path: pathlib.Path) -> None:
515 _init_repo(tmp_path)
516 _create_files(tmp_path, 2)
517 created = _create_snapshot(tmp_path)
518 snap_id = created["snapshot_id"]
519 result = runner.invoke(
520 cli, ["snapshot", "read", snap_id, "--text"], env=_env(tmp_path)
521 )
522 assert result.exit_code == 0
523 assert "snapshot_id" in result.output
524 assert snap_id in result.output
525
526 def test_read_text_shows_file_list(self, tmp_path: pathlib.Path) -> None:
527 _init_repo(tmp_path)
528 _create_files(tmp_path, 2)
529 created = _create_snapshot(tmp_path)
530 snap_id = created["snapshot_id"]
531 result = runner.invoke(
532 cli, ["snapshot", "read", snap_id, "--text"], env=_env(tmp_path)
533 )
534 assert result.exit_code == 0
535 assert "file" in result.output.lower() or "files" in result.output.lower()
536
537 def test_read_text_shows_note_when_set(self, tmp_path: pathlib.Path) -> None:
538 _init_repo(tmp_path)
539 _create_files(tmp_path, 1)
540 created = _create_snapshot(tmp_path, note="my-label")
541 snap_id = created["snapshot_id"]
542 result = runner.invoke(
543 cli, ["snapshot", "read", snap_id, "--text"], env=_env(tmp_path)
544 )
545 assert result.exit_code == 0
546 assert "my-label" in result.output
547
548 def test_read_text_is_not_valid_json(self, tmp_path: pathlib.Path) -> None:
549 """--text output must not be machine-parseable JSON."""
550 _init_repo(tmp_path)
551 _create_files(tmp_path, 1)
552 created = _create_snapshot(tmp_path)
553 snap_id = created["snapshot_id"]
554 result = runner.invoke(
555 cli, ["snapshot", "read", snap_id, "--text"], env=_env(tmp_path)
556 )
557 assert result.exit_code == 0
558 with pytest.raises((json.JSONDecodeError, ValueError)):
559 json.loads(result.output)
560
561
562 # ---------------------------------------------------------------------------
563 # --prefix export
564 # ---------------------------------------------------------------------------
565
566
567 class TestPrefixExport:
568 def test_tar_gz_files_nested_under_prefix(self, tmp_path: pathlib.Path) -> None:
569 _init_repo(tmp_path)
570 _create_files(tmp_path, 2)
571 created = _create_snapshot(tmp_path)
572 snap_id = created["snapshot_id"]
573 out = tmp_path / "prefixed.tar.gz"
574 runner.invoke(
575 cli,
576 ["snapshot", "export", snap_id, "--prefix", "myproject", "--output", str(out)],
577 env=_env(tmp_path),
578 )
579 assert out.exists()
580 with tarfile.open(out, "r:gz") as tar:
581 names = tar.getnames()
582 assert all(n.startswith("myproject/") for n in names), names
583
584 def test_zip_files_nested_under_prefix(self, tmp_path: pathlib.Path) -> None:
585 _init_repo(tmp_path)
586 _create_files(tmp_path, 2)
587 created = _create_snapshot(tmp_path)
588 snap_id = created["snapshot_id"]
589 out = tmp_path / "prefixed.zip"
590 runner.invoke(
591 cli,
592 [
593 "snapshot", "export", snap_id,
594 "--format", "zip",
595 "--prefix", "release",
596 "--output", str(out),
597 ],
598 env=_env(tmp_path),
599 )
600 assert out.exists()
601 with zipfile.ZipFile(out, "r") as zf:
602 names = zf.namelist()
603 assert all(n.startswith("release/") for n in names), names
604
605 def test_empty_prefix_uses_flat_layout(self, tmp_path: pathlib.Path) -> None:
606 _init_repo(tmp_path)
607 _create_files(tmp_path, 2)
608 created = _create_snapshot(tmp_path)
609 snap_id = created["snapshot_id"]
610 out = tmp_path / "flat.tar.gz"
611 runner.invoke(
612 cli,
613 ["snapshot", "export", snap_id, "--prefix", "", "--output", str(out)],
614 env=_env(tmp_path),
615 )
616 assert out.exists()
617 with tarfile.open(out, "r:gz") as tar:
618 names = tar.getnames()
619 assert all(not n.startswith("/") for n in names)
620
621
622 # ---------------------------------------------------------------------------
623 # Limit validation
624 # ---------------------------------------------------------------------------
625
626
627 class TestLimitValidation:
628 def test_limit_zero_rejected(self, tmp_path: pathlib.Path) -> None:
629 _init_repo(tmp_path)
630 result = runner.invoke(
631 cli, ["snapshot", "list", "--limit", "0"], env=_env(tmp_path)
632 )
633 assert result.exit_code != 0
634
635 def test_limit_one_returns_at_most_one(self, tmp_path: pathlib.Path) -> None:
636 _init_repo(tmp_path)
637 _create_files(tmp_path, 1)
638 for _ in range(3):
639 _create_snapshot(tmp_path)
640 result = runner.invoke(
641 cli, ["snapshot", "list", "--limit", "1", "--json"], env=_env(tmp_path)
642 )
643 assert result.exit_code == 0
644 data = json.loads(result.output)
645 assert len(data["snapshots"]) <= 1
646
647 def test_negative_limit_rejected(self, tmp_path: pathlib.Path) -> None:
648 _init_repo(tmp_path)
649 result = runner.invoke(
650 cli, ["snapshot", "list", "--limit", "-1"], env=_env(tmp_path)
651 )
652 assert result.exit_code != 0
653
654 def test_short_flag_n_respected(self, tmp_path: pathlib.Path) -> None:
655 _init_repo(tmp_path)
656 _create_files(tmp_path, 1)
657 for _ in range(4):
658 _create_snapshot(tmp_path)
659 result = runner.invoke(
660 cli, ["snapshot", "list", "-n", "2", "--json"], env=_env(tmp_path)
661 )
662 assert result.exit_code == 0
663 data = json.loads(result.output)
664 assert len(data["snapshots"]) <= 2
665
666
667 # ---------------------------------------------------------------------------
668 # Idempotency — same tree → same snapshot_id
669 # ---------------------------------------------------------------------------
670
671
672 class TestIdempotency:
673 def test_same_files_same_snapshot_id(self, tmp_path: pathlib.Path) -> None:
674 _init_repo(tmp_path)
675 _create_files(tmp_path, 3)
676 first = _create_snapshot(tmp_path)
677 second = _create_snapshot(tmp_path)
678 assert first["snapshot_id"] == second["snapshot_id"]
679
680 def test_different_content_different_snapshot_id(self, tmp_path: pathlib.Path) -> None:
681 _init_repo(tmp_path)
682 _create_files(tmp_path, 2)
683 first = _create_snapshot(tmp_path)
684 # Modify a file.
685 (tmp_path / "file_0.txt").write_text("changed-content", encoding="utf-8")
686 second = _create_snapshot(tmp_path)
687 assert first["snapshot_id"] != second["snapshot_id"]
688
689 def test_list_shows_only_one_when_idempotent(self, tmp_path: pathlib.Path) -> None:
690 """write_snapshot is idempotent — same ID written twice → one file."""
691 _init_repo(tmp_path)
692 _create_files(tmp_path, 2)
693 _create_snapshot(tmp_path)
694 _create_snapshot(tmp_path)
695 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
696 data = json.loads(result.output)
697 # De-duplicate by snapshot_id.
698 ids = {s["snapshot_id"] for s in data["snapshots"]}
699 assert len(ids) == 1
700
701
702 # ---------------------------------------------------------------------------
703 # List ordering — newest first
704 # ---------------------------------------------------------------------------
705
706
707 class TestListOrdering:
708 def test_list_newest_first(self, tmp_path: pathlib.Path) -> None:
709 """Multiple distinct snapshots must be returned newest-first."""
710 _init_repo(tmp_path)
711 snap_ids: list[str] = []
712 for i in range(3):
713 (tmp_path / f"round_{i}.txt").write_text(f"v{i}", encoding="utf-8")
714 created = _create_snapshot(tmp_path)
715 snap_ids.append(created["snapshot_id"])
716 result = runner.invoke(cli, ["snapshot", "list", "--json"], env=_env(tmp_path))
717 data = json.loads(result.output)
718 returned = [s["snapshot_id"] for s in data["snapshots"]]
719 # Newest (last created) must appear first.
720 assert returned[0] == snap_ids[-1]
721
722
723 # ---------------------------------------------------------------------------
724 # Concurrent stress
725 # ---------------------------------------------------------------------------
726
727
728 class TestConcurrentStress:
729 def test_concurrent_creates_all_succeed(self, tmp_path: pathlib.Path) -> None:
730 """N threads creating snapshots concurrently must all succeed."""
731 _init_repo(tmp_path)
732 _create_files(tmp_path, 5)
733 n_threads = 8
734 errors: list[str] = []
735 results: list[dict] = []
736 lock = threading.Lock()
737
738 def _do_create() -> None:
739 result = runner.invoke(
740 cli, ["snapshot", "create", "--json"], env=_env(tmp_path)
741 )
742 with lock:
743 if result.exit_code != 0:
744 errors.append(result.output)
745 else:
746 results.append(json.loads(result.output))
747
748 threads = [threading.Thread(target=_do_create) for _ in range(n_threads)]
749 for t in threads:
750 t.start()
751 for t in threads:
752 t.join()
753
754 assert not errors, f"Some creates failed: {errors}"
755 assert len(results) == n_threads
756 # All results have a valid snapshot_id.
757 for r in results:
758 assert r["snapshot_id"].startswith("sha256:")
759 assert r["exit_code"] == 0
760
761
762 # ---------------------------------------------------------------------------
763 # Large file stress
764 # ---------------------------------------------------------------------------
765
766
767 class TestLargeFileExport:
768 def test_large_file_round_trips_correctly(self, tmp_path: pathlib.Path) -> None:
769 """A 5 MiB file must survive create → export → extract unchanged."""
770 _init_repo(tmp_path)
771 payload = os.urandom(5 * 1024 * 1024)
772 (tmp_path / "big.bin").write_bytes(payload)
773 created = _create_snapshot(tmp_path)
774 snap_id = created["snapshot_id"]
775 out = tmp_path / "big.tar.gz"
776 result = runner.invoke(
777 cli,
778 ["snapshot", "export", snap_id, "--output", str(out), "--json"],
779 env=_env(tmp_path),
780 )
781 assert result.exit_code == 0
782 data = json.loads(result.output)
783 assert data["file_count"] >= 1
784 assert data["size_bytes"] > 0
785 assert out.exists()
786 # Verify archive actually opens.
787 assert tarfile.is_tarfile(str(out))
788 with tarfile.open(out, "r:gz") as tar:
789 members = [m for m in tar.getmembers() if m.name.endswith("big.bin")]
790 assert members, "big.bin not found in archive"
791 content = tar.extractfile(members[0])
792 assert content is not None
793 assert content.read() == payload
794
795
796 # ---------------------------------------------------------------------------
797 # Export to default filename
798 # ---------------------------------------------------------------------------
799
800
801 class TestDefaultFilename:
802 def test_export_default_filename_is_short_id_dot_format(self, tmp_path: pathlib.Path) -> None:
803 """When --output is omitted, the archive uses <short_id>.<fmt>."""
804 _init_repo(tmp_path)
805 _create_files(tmp_path, 1)
806 created = _create_snapshot(tmp_path)
807 snap_id = created["snapshot_id"]
808 # Run from tmp_path so the default output lands there.
809 orig_dir = pathlib.Path.cwd()
810 os.chdir(tmp_path)
811 try:
812 result = runner.invoke(
813 cli, ["snapshot", "export", snap_id, "--json"], env=_env(tmp_path)
814 )
815 finally:
816 os.chdir(orig_dir)
817 assert result.exit_code == 0
818 data = json.loads(result.output)
819 assert data["output"].endswith(".tar.gz")
820 assert pathlib.Path(tmp_path / data["output"]).exists() or pathlib.Path(data["output"]).exists()
821
822 def test_export_not_found_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
823 _init_repo(tmp_path)
824 result = runner.invoke(
825 cli, ["snapshot", "export", "nonexistent"], env=_env(tmp_path)
826 )
827 assert result.exit_code != 0
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago