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