test_backup.py
1 """Tests for hermes backup and import commands.""" 2 3 import json 4 import os 5 import sqlite3 6 import zipfile 7 from argparse import Namespace 8 from pathlib import Path 9 from unittest.mock import patch 10 11 import pytest 12 13 14 # --------------------------------------------------------------------------- 15 # Helpers 16 # --------------------------------------------------------------------------- 17 18 def _make_hermes_tree(root: Path) -> None: 19 """Create a realistic ~/.hermes directory structure for testing.""" 20 (root / "config.yaml").write_text("model:\n provider: openrouter\n") 21 (root / ".env").write_text("OPENROUTER_API_KEY=sk-test-123\n") 22 (root / "memory_store.db").write_bytes(b"fake-sqlite") 23 (root / "hermes_state.db").write_bytes(b"fake-state") 24 25 # Sessions 26 (root / "sessions").mkdir(exist_ok=True) 27 (root / "sessions" / "abc123.json").write_text("{}") 28 29 # Skills 30 (root / "skills").mkdir(exist_ok=True) 31 (root / "skills" / "my-skill").mkdir() 32 (root / "skills" / "my-skill" / "SKILL.md").write_text("# My Skill\n") 33 34 # Skins 35 (root / "skins").mkdir(exist_ok=True) 36 (root / "skins" / "cyber.yaml").write_text("name: cyber\n") 37 38 # Cron 39 (root / "cron").mkdir(exist_ok=True) 40 (root / "cron" / "jobs.json").write_text("[]") 41 42 # Memories 43 (root / "memories").mkdir(exist_ok=True) 44 (root / "memories" / "notes.json").write_text("{}") 45 46 # Profiles 47 (root / "profiles").mkdir(exist_ok=True) 48 (root / "profiles" / "coder").mkdir() 49 (root / "profiles" / "coder" / "config.yaml").write_text("model:\n provider: anthropic\n") 50 (root / "profiles" / "coder" / ".env").write_text("ANTHROPIC_API_KEY=sk-ant-123\n") 51 52 # hermes-agent repo (should be EXCLUDED) 53 (root / "hermes-agent").mkdir(exist_ok=True) 54 (root / "hermes-agent" / "run_agent.py").write_text("# big file\n") 55 (root / "hermes-agent" / ".git").mkdir() 56 (root / "hermes-agent" / ".git" / "HEAD").write_text("ref: refs/heads/main\n") 57 58 # __pycache__ (should be EXCLUDED) 59 (root / "plugins").mkdir(exist_ok=True) 60 (root / "plugins" / "__pycache__").mkdir() 61 (root / "plugins" / "__pycache__" / "mod.cpython-312.pyc").write_bytes(b"\x00") 62 63 # PID files (should be EXCLUDED) 64 (root / "gateway.pid").write_text("12345") 65 66 # Logs (should be included) 67 (root / "logs").mkdir(exist_ok=True) 68 (root / "logs" / "agent.log").write_text("log line\n") 69 70 71 # --------------------------------------------------------------------------- 72 # _should_exclude tests 73 # --------------------------------------------------------------------------- 74 75 class TestShouldExclude: 76 def test_excludes_hermes_agent(self): 77 from hermes_cli.backup import _should_exclude 78 assert _should_exclude(Path("hermes-agent/run_agent.py")) 79 assert _should_exclude(Path("hermes-agent/.git/HEAD")) 80 81 def test_excludes_pycache(self): 82 from hermes_cli.backup import _should_exclude 83 assert _should_exclude(Path("plugins/__pycache__/mod.cpython-312.pyc")) 84 85 def test_excludes_pyc_files(self): 86 from hermes_cli.backup import _should_exclude 87 assert _should_exclude(Path("some/module.pyc")) 88 89 def test_excludes_pid_files(self): 90 from hermes_cli.backup import _should_exclude 91 assert _should_exclude(Path("gateway.pid")) 92 assert _should_exclude(Path("cron.pid")) 93 94 def test_excludes_checkpoints(self): 95 """checkpoints/ is session-local trajectory cache — hash-keyed, 96 regenerated per-session, won't port to another machine anyway.""" 97 from hermes_cli.backup import _should_exclude 98 assert _should_exclude(Path("checkpoints/abc123/trajectory.json")) 99 assert _should_exclude(Path("checkpoints/deadbeef/step_0001.json")) 100 101 def test_excludes_backups_dir(self): 102 """backups/ is excluded so pre-update backups don't nest exponentially.""" 103 from hermes_cli.backup import _should_exclude 104 assert _should_exclude(Path("backups/pre-update-2026-04-27-063400.zip")) 105 106 def test_excludes_sqlite_sidecars(self): 107 """SQLite WAL/SHM/journal sidecars must not ship alongside the 108 safe-copied .db — pairing a fresh snapshot with stale sidecar state 109 produces a torn restore.""" 110 from hermes_cli.backup import _should_exclude 111 assert _should_exclude(Path("state.db-wal")) 112 assert _should_exclude(Path("state.db-shm")) 113 assert _should_exclude(Path("state.db-journal")) 114 assert _should_exclude(Path("memory_store.db-wal")) 115 # The .db itself is still included (and safe-copied separately) 116 assert not _should_exclude(Path("state.db")) 117 118 def test_includes_config(self): 119 from hermes_cli.backup import _should_exclude 120 assert not _should_exclude(Path("config.yaml")) 121 122 def test_includes_env(self): 123 from hermes_cli.backup import _should_exclude 124 assert not _should_exclude(Path(".env")) 125 126 def test_includes_skills(self): 127 from hermes_cli.backup import _should_exclude 128 assert not _should_exclude(Path("skills/my-skill/SKILL.md")) 129 130 def test_includes_profiles(self): 131 from hermes_cli.backup import _should_exclude 132 assert not _should_exclude(Path("profiles/coder/config.yaml")) 133 134 def test_includes_sessions(self): 135 from hermes_cli.backup import _should_exclude 136 assert not _should_exclude(Path("sessions/abc.json")) 137 138 def test_includes_logs(self): 139 from hermes_cli.backup import _should_exclude 140 assert not _should_exclude(Path("logs/agent.log")) 141 142 143 # --------------------------------------------------------------------------- 144 # Backup tests 145 # --------------------------------------------------------------------------- 146 147 class TestBackup: 148 def test_creates_zip(self, tmp_path, monkeypatch): 149 """Backup creates a valid zip containing expected files.""" 150 hermes_home = tmp_path / ".hermes" 151 hermes_home.mkdir() 152 _make_hermes_tree(hermes_home) 153 154 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 155 # get_default_hermes_root needs this 156 monkeypatch.setattr(Path, "home", lambda: tmp_path) 157 158 out_zip = tmp_path / "backup.zip" 159 args = Namespace(output=str(out_zip)) 160 161 from hermes_cli.backup import run_backup 162 run_backup(args) 163 164 assert out_zip.exists() 165 with zipfile.ZipFile(out_zip, "r") as zf: 166 names = zf.namelist() 167 # Config should be present 168 assert "config.yaml" in names 169 assert ".env" in names 170 # Skills 171 assert "skills/my-skill/SKILL.md" in names 172 # Profiles 173 assert "profiles/coder/config.yaml" in names 174 assert "profiles/coder/.env" in names 175 # Sessions 176 assert "sessions/abc123.json" in names 177 # Logs 178 assert "logs/agent.log" in names 179 # Skins 180 assert "skins/cyber.yaml" in names 181 182 def test_excludes_hermes_agent(self, tmp_path, monkeypatch): 183 """Backup does NOT include hermes-agent/ directory.""" 184 hermes_home = tmp_path / ".hermes" 185 hermes_home.mkdir() 186 _make_hermes_tree(hermes_home) 187 188 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 189 monkeypatch.setattr(Path, "home", lambda: tmp_path) 190 191 out_zip = tmp_path / "backup.zip" 192 args = Namespace(output=str(out_zip)) 193 194 from hermes_cli.backup import run_backup 195 run_backup(args) 196 197 with zipfile.ZipFile(out_zip, "r") as zf: 198 names = zf.namelist() 199 agent_files = [n for n in names if "hermes-agent" in n] 200 assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}" 201 202 def test_excludes_pycache(self, tmp_path, monkeypatch): 203 """Backup does NOT include __pycache__ dirs.""" 204 hermes_home = tmp_path / ".hermes" 205 hermes_home.mkdir() 206 _make_hermes_tree(hermes_home) 207 208 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 209 monkeypatch.setattr(Path, "home", lambda: tmp_path) 210 211 out_zip = tmp_path / "backup.zip" 212 args = Namespace(output=str(out_zip)) 213 214 from hermes_cli.backup import run_backup 215 run_backup(args) 216 217 with zipfile.ZipFile(out_zip, "r") as zf: 218 names = zf.namelist() 219 pycache_files = [n for n in names if "__pycache__" in n] 220 assert pycache_files == [] 221 222 def test_excludes_pid_files(self, tmp_path, monkeypatch): 223 """Backup does NOT include PID files.""" 224 hermes_home = tmp_path / ".hermes" 225 hermes_home.mkdir() 226 _make_hermes_tree(hermes_home) 227 228 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 229 monkeypatch.setattr(Path, "home", lambda: tmp_path) 230 231 out_zip = tmp_path / "backup.zip" 232 args = Namespace(output=str(out_zip)) 233 234 from hermes_cli.backup import run_backup 235 run_backup(args) 236 237 with zipfile.ZipFile(out_zip, "r") as zf: 238 names = zf.namelist() 239 pid_files = [n for n in names if n.endswith(".pid")] 240 assert pid_files == [] 241 242 def test_default_output_path(self, tmp_path, monkeypatch): 243 """When no output path given, zip goes to ~/hermes-backup-*.zip.""" 244 hermes_home = tmp_path / ".hermes" 245 hermes_home.mkdir() 246 (hermes_home / "config.yaml").write_text("model: test\n") 247 248 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 249 monkeypatch.setattr(Path, "home", lambda: tmp_path) 250 251 args = Namespace(output=None) 252 253 from hermes_cli.backup import run_backup 254 run_backup(args) 255 256 # Should exist in home dir 257 zips = list(tmp_path.glob("hermes-backup-*.zip")) 258 assert len(zips) == 1 259 260 261 # --------------------------------------------------------------------------- 262 # _validate_backup_zip tests 263 # --------------------------------------------------------------------------- 264 265 class TestValidateBackupZip: 266 def _make_zip(self, zip_path: Path, filenames: list[str]) -> None: 267 with zipfile.ZipFile(zip_path, "w") as zf: 268 for name in filenames: 269 zf.writestr(name, "dummy") 270 271 def test_state_db_passes(self, tmp_path): 272 """A zip containing state.db is accepted as a valid Hermes backup.""" 273 from hermes_cli.backup import _validate_backup_zip 274 zip_path = tmp_path / "backup.zip" 275 self._make_zip(zip_path, ["state.db", "sessions/abc.json"]) 276 with zipfile.ZipFile(zip_path, "r") as zf: 277 ok, reason = _validate_backup_zip(zf) 278 assert ok, reason 279 280 def test_old_wrong_db_name_fails(self, tmp_path): 281 """A zip with only hermes_state.db (old wrong name) is rejected.""" 282 from hermes_cli.backup import _validate_backup_zip 283 zip_path = tmp_path / "old.zip" 284 self._make_zip(zip_path, ["hermes_state.db", "memory_store.db"]) 285 with zipfile.ZipFile(zip_path, "r") as zf: 286 ok, reason = _validate_backup_zip(zf) 287 assert not ok 288 289 def test_config_yaml_passes(self, tmp_path): 290 """A zip containing config.yaml is accepted (existing behaviour preserved).""" 291 from hermes_cli.backup import _validate_backup_zip 292 zip_path = tmp_path / "backup.zip" 293 self._make_zip(zip_path, ["config.yaml", "skills/x/SKILL.md"]) 294 with zipfile.ZipFile(zip_path, "r") as zf: 295 ok, reason = _validate_backup_zip(zf) 296 assert ok, reason 297 298 299 # --------------------------------------------------------------------------- 300 # Import tests 301 # --------------------------------------------------------------------------- 302 303 class TestImport: 304 def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None: 305 """Create a test zip with given files.""" 306 with zipfile.ZipFile(zip_path, "w") as zf: 307 for name, content in files.items(): 308 if isinstance(content, bytes): 309 zf.writestr(name, content) 310 else: 311 zf.writestr(name, content) 312 313 def test_restores_files(self, tmp_path, monkeypatch): 314 """Import extracts files into hermes home.""" 315 hermes_home = tmp_path / ".hermes" 316 hermes_home.mkdir() 317 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 318 monkeypatch.setattr(Path, "home", lambda: tmp_path) 319 320 zip_path = tmp_path / "backup.zip" 321 self._make_backup_zip(zip_path, { 322 "config.yaml": "model:\n provider: openrouter\n", 323 ".env": "OPENROUTER_API_KEY=sk-test\n", 324 "skills/my-skill/SKILL.md": "# My Skill\n", 325 "profiles/coder/config.yaml": "model:\n provider: anthropic\n", 326 }) 327 328 args = Namespace(zipfile=str(zip_path), force=True) 329 330 from hermes_cli.backup import run_import 331 run_import(args) 332 333 assert (hermes_home / "config.yaml").read_text() == "model:\n provider: openrouter\n" 334 assert (hermes_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test\n" 335 assert (hermes_home / "skills" / "my-skill" / "SKILL.md").read_text() == "# My Skill\n" 336 assert (hermes_home / "profiles" / "coder" / "config.yaml").exists() 337 338 def test_strips_hermes_prefix(self, tmp_path, monkeypatch): 339 """Import strips .hermes/ prefix if all entries share it.""" 340 hermes_home = tmp_path / ".hermes" 341 hermes_home.mkdir() 342 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 343 monkeypatch.setattr(Path, "home", lambda: tmp_path) 344 345 zip_path = tmp_path / "backup.zip" 346 self._make_backup_zip(zip_path, { 347 ".hermes/config.yaml": "model: test\n", 348 ".hermes/skills/a/SKILL.md": "# A\n", 349 }) 350 351 args = Namespace(zipfile=str(zip_path), force=True) 352 353 from hermes_cli.backup import run_import 354 run_import(args) 355 356 assert (hermes_home / "config.yaml").read_text() == "model: test\n" 357 assert (hermes_home / "skills" / "a" / "SKILL.md").read_text() == "# A\n" 358 359 def test_rejects_empty_zip(self, tmp_path, monkeypatch): 360 """Import rejects an empty zip.""" 361 hermes_home = tmp_path / ".hermes" 362 hermes_home.mkdir() 363 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 364 monkeypatch.setattr(Path, "home", lambda: tmp_path) 365 366 zip_path = tmp_path / "empty.zip" 367 with zipfile.ZipFile(zip_path, "w"): 368 pass # empty 369 370 args = Namespace(zipfile=str(zip_path), force=True) 371 372 from hermes_cli.backup import run_import 373 with pytest.raises(SystemExit): 374 run_import(args) 375 376 def test_rejects_non_hermes_zip(self, tmp_path, monkeypatch): 377 """Import rejects a zip that doesn't look like a hermes backup.""" 378 hermes_home = tmp_path / ".hermes" 379 hermes_home.mkdir() 380 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 381 monkeypatch.setattr(Path, "home", lambda: tmp_path) 382 383 zip_path = tmp_path / "random.zip" 384 self._make_backup_zip(zip_path, { 385 "some/random/file.txt": "hello", 386 "another/thing.json": "{}", 387 }) 388 389 args = Namespace(zipfile=str(zip_path), force=True) 390 391 from hermes_cli.backup import run_import 392 with pytest.raises(SystemExit): 393 run_import(args) 394 395 def test_blocks_path_traversal(self, tmp_path, monkeypatch): 396 """Import blocks zip entries with path traversal.""" 397 hermes_home = tmp_path / ".hermes" 398 hermes_home.mkdir() 399 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 400 monkeypatch.setattr(Path, "home", lambda: tmp_path) 401 402 zip_path = tmp_path / "evil.zip" 403 # Include a marker file so validation passes 404 self._make_backup_zip(zip_path, { 405 "config.yaml": "model: test\n", 406 "../../etc/passwd": "root:x:0:0\n", 407 }) 408 409 args = Namespace(zipfile=str(zip_path), force=True) 410 411 from hermes_cli.backup import run_import 412 run_import(args) 413 414 # config.yaml should be restored 415 assert (hermes_home / "config.yaml").exists() 416 # traversal file should NOT exist outside hermes home 417 assert not (tmp_path / "etc" / "passwd").exists() 418 419 def test_confirmation_prompt_abort(self, tmp_path, monkeypatch): 420 """Import aborts when user says no to confirmation.""" 421 hermes_home = tmp_path / ".hermes" 422 hermes_home.mkdir() 423 # Pre-existing config triggers the confirmation 424 (hermes_home / "config.yaml").write_text("existing: true\n") 425 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 426 monkeypatch.setattr(Path, "home", lambda: tmp_path) 427 428 zip_path = tmp_path / "backup.zip" 429 self._make_backup_zip(zip_path, { 430 "config.yaml": "model: restored\n", 431 }) 432 433 args = Namespace(zipfile=str(zip_path), force=False) 434 435 from hermes_cli.backup import run_import 436 with patch("builtins.input", return_value="n"): 437 run_import(args) 438 439 # Original config should be unchanged 440 assert (hermes_home / "config.yaml").read_text() == "existing: true\n" 441 442 def test_force_skips_confirmation(self, tmp_path, monkeypatch): 443 """Import with --force skips confirmation and overwrites.""" 444 hermes_home = tmp_path / ".hermes" 445 hermes_home.mkdir() 446 (hermes_home / "config.yaml").write_text("existing: true\n") 447 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 448 monkeypatch.setattr(Path, "home", lambda: tmp_path) 449 450 zip_path = tmp_path / "backup.zip" 451 self._make_backup_zip(zip_path, { 452 "config.yaml": "model: restored\n", 453 }) 454 455 args = Namespace(zipfile=str(zip_path), force=True) 456 457 from hermes_cli.backup import run_import 458 run_import(args) 459 460 assert (hermes_home / "config.yaml").read_text() == "model: restored\n" 461 462 def test_missing_file_exits(self, tmp_path, monkeypatch): 463 """Import exits with error for nonexistent file.""" 464 hermes_home = tmp_path / ".hermes" 465 hermes_home.mkdir() 466 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 467 468 args = Namespace(zipfile=str(tmp_path / "nonexistent.zip"), force=True) 469 470 from hermes_cli.backup import run_import 471 with pytest.raises(SystemExit): 472 run_import(args) 473 474 @pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only") 475 def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch): 476 """Secret files must end up at 0600 after restore (zipfile drops mode bits).""" 477 hermes_home = tmp_path / ".hermes" 478 hermes_home.mkdir() 479 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 480 monkeypatch.setattr(Path, "home", lambda: tmp_path) 481 482 zip_path = tmp_path / "backup.zip" 483 self._make_backup_zip(zip_path, { 484 "config.yaml": "model: openrouter\n", 485 ".env": "OPENROUTER_API_KEY=sk-secret\n", 486 "auth.json": '{"providers": {"nous": "token"}}', 487 "state.db": b"SQLite format 3\x00", 488 "profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n", 489 }) 490 491 args = Namespace(zipfile=str(zip_path), force=True) 492 493 from hermes_cli.backup import run_import 494 run_import(args) 495 496 for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"): 497 mode = (hermes_home / rel).stat().st_mode & 0o777 498 assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600" 499 500 501 # --------------------------------------------------------------------------- 502 # Round-trip test 503 # --------------------------------------------------------------------------- 504 505 class TestRoundTrip: 506 def test_backup_then_import(self, tmp_path, monkeypatch): 507 """Full round-trip: backup -> import to a new location -> verify.""" 508 # Source 509 src_home = tmp_path / "source" / ".hermes" 510 src_home.mkdir(parents=True) 511 _make_hermes_tree(src_home) 512 513 monkeypatch.setenv("HERMES_HOME", str(src_home)) 514 monkeypatch.setattr(Path, "home", lambda: tmp_path / "source") 515 516 # Backup 517 out_zip = tmp_path / "roundtrip.zip" 518 from hermes_cli.backup import run_backup, run_import 519 520 run_backup(Namespace(output=str(out_zip))) 521 assert out_zip.exists() 522 523 # Import into a different location 524 dst_home = tmp_path / "dest" / ".hermes" 525 dst_home.mkdir(parents=True) 526 monkeypatch.setenv("HERMES_HOME", str(dst_home)) 527 monkeypatch.setattr(Path, "home", lambda: tmp_path / "dest") 528 529 run_import(Namespace(zipfile=str(out_zip), force=True)) 530 531 # Verify key files 532 assert (dst_home / "config.yaml").read_text() == "model:\n provider: openrouter\n" 533 assert (dst_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test-123\n" 534 assert (dst_home / "skills" / "my-skill" / "SKILL.md").exists() 535 assert (dst_home / "profiles" / "coder" / "config.yaml").exists() 536 assert (dst_home / "sessions" / "abc123.json").exists() 537 assert (dst_home / "logs" / "agent.log").exists() 538 539 # hermes-agent should NOT be present 540 assert not (dst_home / "hermes-agent").exists() 541 # __pycache__ should NOT be present 542 assert not (dst_home / "plugins" / "__pycache__").exists() 543 # PID files should NOT be present 544 assert not (dst_home / "gateway.pid").exists() 545 546 547 # --------------------------------------------------------------------------- 548 # Validate / detect-prefix unit tests 549 # --------------------------------------------------------------------------- 550 551 class TestFormatSize: 552 def test_bytes(self): 553 from hermes_cli.backup import _format_size 554 assert _format_size(512) == "512 B" 555 556 def test_kilobytes(self): 557 from hermes_cli.backup import _format_size 558 assert "KB" in _format_size(2048) 559 560 def test_megabytes(self): 561 from hermes_cli.backup import _format_size 562 assert "MB" in _format_size(5 * 1024 * 1024) 563 564 def test_gigabytes(self): 565 from hermes_cli.backup import _format_size 566 assert "GB" in _format_size(3 * 1024 ** 3) 567 568 def test_terabytes(self): 569 from hermes_cli.backup import _format_size 570 assert "TB" in _format_size(2 * 1024 ** 4) 571 572 573 class TestValidation: 574 def test_validate_with_config(self): 575 """Zip with config.yaml passes validation.""" 576 import io 577 from hermes_cli.backup import _validate_backup_zip 578 579 buf = io.BytesIO() 580 with zipfile.ZipFile(buf, "w") as zf: 581 zf.writestr("config.yaml", "test") 582 buf.seek(0) 583 with zipfile.ZipFile(buf, "r") as zf: 584 ok, reason = _validate_backup_zip(zf) 585 assert ok 586 587 def test_validate_with_env(self): 588 """Zip with .env passes validation.""" 589 import io 590 from hermes_cli.backup import _validate_backup_zip 591 592 buf = io.BytesIO() 593 with zipfile.ZipFile(buf, "w") as zf: 594 zf.writestr(".env", "KEY=val") 595 buf.seek(0) 596 with zipfile.ZipFile(buf, "r") as zf: 597 ok, reason = _validate_backup_zip(zf) 598 assert ok 599 600 def test_validate_rejects_random(self): 601 """Zip without hermes markers fails validation.""" 602 import io 603 from hermes_cli.backup import _validate_backup_zip 604 605 buf = io.BytesIO() 606 with zipfile.ZipFile(buf, "w") as zf: 607 zf.writestr("random/file.txt", "hello") 608 buf.seek(0) 609 with zipfile.ZipFile(buf, "r") as zf: 610 ok, reason = _validate_backup_zip(zf) 611 assert not ok 612 613 def test_detect_prefix_hermes(self): 614 """Detects .hermes/ prefix wrapping all entries.""" 615 import io 616 from hermes_cli.backup import _detect_prefix 617 618 buf = io.BytesIO() 619 with zipfile.ZipFile(buf, "w") as zf: 620 zf.writestr(".hermes/config.yaml", "test") 621 zf.writestr(".hermes/skills/a/SKILL.md", "skill") 622 buf.seek(0) 623 with zipfile.ZipFile(buf, "r") as zf: 624 assert _detect_prefix(zf) == ".hermes/" 625 626 def test_detect_prefix_none(self): 627 """No prefix when entries are at root.""" 628 import io 629 from hermes_cli.backup import _detect_prefix 630 631 buf = io.BytesIO() 632 with zipfile.ZipFile(buf, "w") as zf: 633 zf.writestr("config.yaml", "test") 634 zf.writestr("skills/a/SKILL.md", "skill") 635 buf.seek(0) 636 with zipfile.ZipFile(buf, "r") as zf: 637 assert _detect_prefix(zf) == "" 638 639 def test_detect_prefix_only_dirs(self): 640 """Prefix detection returns empty for zip with only directory entries.""" 641 import io 642 from hermes_cli.backup import _detect_prefix 643 644 buf = io.BytesIO() 645 with zipfile.ZipFile(buf, "w") as zf: 646 # Only directory entries (trailing slash) 647 zf.writestr(".hermes/", "") 648 zf.writestr(".hermes/skills/", "") 649 buf.seek(0) 650 with zipfile.ZipFile(buf, "r") as zf: 651 assert _detect_prefix(zf) == "" 652 653 654 # --------------------------------------------------------------------------- 655 # Edge case tests for uncovered paths 656 # --------------------------------------------------------------------------- 657 658 class TestBackupEdgeCases: 659 def test_nonexistent_hermes_home(self, tmp_path, monkeypatch): 660 """Backup exits when hermes home doesn't exist.""" 661 fake_home = tmp_path / "nonexistent" / ".hermes" 662 monkeypatch.setenv("HERMES_HOME", str(fake_home)) 663 monkeypatch.setattr(Path, "home", lambda: tmp_path / "nonexistent") 664 665 args = Namespace(output=str(tmp_path / "out.zip")) 666 667 from hermes_cli.backup import run_backup 668 with pytest.raises(SystemExit): 669 run_backup(args) 670 671 def test_output_is_directory(self, tmp_path, monkeypatch): 672 """When output path is a directory, zip is created inside it.""" 673 hermes_home = tmp_path / ".hermes" 674 hermes_home.mkdir() 675 (hermes_home / "config.yaml").write_text("model: test\n") 676 677 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 678 monkeypatch.setattr(Path, "home", lambda: tmp_path) 679 680 out_dir = tmp_path / "backups" 681 out_dir.mkdir() 682 683 args = Namespace(output=str(out_dir)) 684 685 from hermes_cli.backup import run_backup 686 run_backup(args) 687 688 zips = list(out_dir.glob("hermes-backup-*.zip")) 689 assert len(zips) == 1 690 691 def test_output_without_zip_suffix(self, tmp_path, monkeypatch): 692 """Output path without .zip gets suffix appended.""" 693 hermes_home = tmp_path / ".hermes" 694 hermes_home.mkdir() 695 (hermes_home / "config.yaml").write_text("model: test\n") 696 697 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 698 monkeypatch.setattr(Path, "home", lambda: tmp_path) 699 700 out_path = tmp_path / "mybackup.tar" 701 args = Namespace(output=str(out_path)) 702 703 from hermes_cli.backup import run_backup 704 run_backup(args) 705 706 # Should have .tar.zip suffix 707 assert (tmp_path / "mybackup.tar.zip").exists() 708 709 def test_empty_hermes_home(self, tmp_path, monkeypatch): 710 """Backup handles empty hermes home (no files to back up).""" 711 hermes_home = tmp_path / ".hermes" 712 hermes_home.mkdir() 713 # Only excluded dirs, no actual files 714 (hermes_home / "__pycache__").mkdir() 715 (hermes_home / "__pycache__" / "foo.pyc").write_bytes(b"\x00") 716 717 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 718 monkeypatch.setattr(Path, "home", lambda: tmp_path) 719 720 args = Namespace(output=str(tmp_path / "out.zip")) 721 722 from hermes_cli.backup import run_backup 723 run_backup(args) 724 725 # No zip should be created 726 assert not (tmp_path / "out.zip").exists() 727 728 def test_permission_error_during_backup(self, tmp_path, monkeypatch): 729 """Backup handles permission errors gracefully.""" 730 hermes_home = tmp_path / ".hermes" 731 hermes_home.mkdir() 732 (hermes_home / "config.yaml").write_text("model: test\n") 733 734 # Create an unreadable file 735 bad_file = hermes_home / "secret.db" 736 bad_file.write_text("data") 737 bad_file.chmod(0o000) 738 739 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 740 monkeypatch.setattr(Path, "home", lambda: tmp_path) 741 742 out_zip = tmp_path / "out.zip" 743 args = Namespace(output=str(out_zip)) 744 745 from hermes_cli.backup import run_backup 746 try: 747 run_backup(args) 748 finally: 749 # Restore permissions for cleanup 750 bad_file.chmod(0o644) 751 752 # Zip should still be created with the readable files 753 assert out_zip.exists() 754 755 def test_pre1980_timestamp_skipped(self, tmp_path, monkeypatch): 756 """Backup skips files with pre-1980 timestamps (ZIP limitation).""" 757 hermes_home = tmp_path / ".hermes" 758 hermes_home.mkdir() 759 (hermes_home / "config.yaml").write_text("model: test\n") 760 761 # Create a file with epoch timestamp (1970-01-01) 762 old_file = hermes_home / "ancient.txt" 763 old_file.write_text("old data") 764 os.utime(old_file, (0, 0)) 765 766 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 767 monkeypatch.setattr(Path, "home", lambda: tmp_path) 768 769 out_zip = tmp_path / "out.zip" 770 args = Namespace(output=str(out_zip)) 771 772 from hermes_cli.backup import run_backup 773 run_backup(args) 774 775 # Zip should still be created with the valid files 776 assert out_zip.exists() 777 with zipfile.ZipFile(out_zip, "r") as zf: 778 names = zf.namelist() 779 assert "config.yaml" in names 780 # The pre-1980 file should be skipped, not crash the backup 781 assert "ancient.txt" not in names 782 783 def test_skips_output_zip_inside_hermes(self, tmp_path, monkeypatch): 784 """Backup skips its own output zip if it's inside hermes root.""" 785 hermes_home = tmp_path / ".hermes" 786 hermes_home.mkdir() 787 (hermes_home / "config.yaml").write_text("model: test\n") 788 789 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 790 monkeypatch.setattr(Path, "home", lambda: tmp_path) 791 792 # Output inside hermes home 793 out_zip = hermes_home / "backup.zip" 794 args = Namespace(output=str(out_zip)) 795 796 from hermes_cli.backup import run_backup 797 run_backup(args) 798 799 # The zip should exist but not contain itself 800 assert out_zip.exists() 801 with zipfile.ZipFile(out_zip, "r") as zf: 802 assert "backup.zip" not in zf.namelist() 803 804 805 class TestImportEdgeCases: 806 def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None: 807 with zipfile.ZipFile(zip_path, "w") as zf: 808 for name, content in files.items(): 809 zf.writestr(name, content) 810 811 def test_not_a_zip(self, tmp_path, monkeypatch): 812 """Import rejects a non-zip file.""" 813 hermes_home = tmp_path / ".hermes" 814 hermes_home.mkdir() 815 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 816 817 not_zip = tmp_path / "fake.zip" 818 not_zip.write_text("this is not a zip") 819 820 args = Namespace(zipfile=str(not_zip), force=True) 821 822 from hermes_cli.backup import run_import 823 with pytest.raises(SystemExit): 824 run_import(args) 825 826 def test_eof_during_confirmation(self, tmp_path, monkeypatch): 827 """Import handles EOFError during confirmation prompt.""" 828 hermes_home = tmp_path / ".hermes" 829 hermes_home.mkdir() 830 (hermes_home / "config.yaml").write_text("existing\n") 831 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 832 monkeypatch.setattr(Path, "home", lambda: tmp_path) 833 834 zip_path = tmp_path / "backup.zip" 835 self._make_backup_zip(zip_path, {"config.yaml": "new\n"}) 836 837 args = Namespace(zipfile=str(zip_path), force=False) 838 839 from hermes_cli.backup import run_import 840 with patch("builtins.input", side_effect=EOFError): 841 with pytest.raises(SystemExit): 842 run_import(args) 843 844 def test_keyboard_interrupt_during_confirmation(self, tmp_path, monkeypatch): 845 """Import handles KeyboardInterrupt during confirmation prompt.""" 846 hermes_home = tmp_path / ".hermes" 847 hermes_home.mkdir() 848 (hermes_home / ".env").write_text("KEY=val\n") 849 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 850 monkeypatch.setattr(Path, "home", lambda: tmp_path) 851 852 zip_path = tmp_path / "backup.zip" 853 self._make_backup_zip(zip_path, {"config.yaml": "new\n"}) 854 855 args = Namespace(zipfile=str(zip_path), force=False) 856 857 from hermes_cli.backup import run_import 858 with patch("builtins.input", side_effect=KeyboardInterrupt): 859 with pytest.raises(SystemExit): 860 run_import(args) 861 862 def test_permission_error_during_import(self, tmp_path, monkeypatch): 863 """Import handles permission errors during extraction.""" 864 hermes_home = tmp_path / ".hermes" 865 hermes_home.mkdir() 866 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 867 monkeypatch.setattr(Path, "home", lambda: tmp_path) 868 869 # Create a read-only directory so extraction fails 870 locked_dir = hermes_home / "locked" 871 locked_dir.mkdir() 872 locked_dir.chmod(0o555) 873 874 zip_path = tmp_path / "backup.zip" 875 self._make_backup_zip(zip_path, { 876 "config.yaml": "model: test\n", 877 "locked/secret.txt": "data", 878 }) 879 880 args = Namespace(zipfile=str(zip_path), force=True) 881 882 from hermes_cli.backup import run_import 883 try: 884 run_import(args) 885 finally: 886 locked_dir.chmod(0o755) 887 888 # config.yaml should still be restored despite the error 889 assert (hermes_home / "config.yaml").exists() 890 891 def test_progress_with_many_files(self, tmp_path, monkeypatch): 892 """Import shows progress with 500+ files.""" 893 hermes_home = tmp_path / ".hermes" 894 hermes_home.mkdir() 895 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 896 monkeypatch.setattr(Path, "home", lambda: tmp_path) 897 898 zip_path = tmp_path / "big.zip" 899 files = {"config.yaml": "model: test\n"} 900 for i in range(600): 901 files[f"sessions/s{i:04d}.json"] = "{}" 902 903 self._make_backup_zip(zip_path, files) 904 905 args = Namespace(zipfile=str(zip_path), force=True) 906 907 from hermes_cli.backup import run_import 908 run_import(args) 909 910 assert (hermes_home / "config.yaml").exists() 911 assert (hermes_home / "sessions" / "s0599.json").exists() 912 913 914 # --------------------------------------------------------------------------- 915 # Profile restoration tests 916 # --------------------------------------------------------------------------- 917 918 class TestProfileRestoration: 919 def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None: 920 with zipfile.ZipFile(zip_path, "w") as zf: 921 for name, content in files.items(): 922 zf.writestr(name, content) 923 924 def test_import_creates_profile_wrappers(self, tmp_path, monkeypatch): 925 """Import auto-creates wrapper scripts for restored profiles.""" 926 hermes_home = tmp_path / ".hermes" 927 hermes_home.mkdir() 928 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 929 monkeypatch.setattr(Path, "home", lambda: tmp_path) 930 931 # Mock the wrapper dir to be inside tmp_path 932 wrapper_dir = tmp_path / ".local" / "bin" 933 wrapper_dir.mkdir(parents=True) 934 935 zip_path = tmp_path / "backup.zip" 936 self._make_backup_zip(zip_path, { 937 "config.yaml": "model:\n provider: openrouter\n", 938 "profiles/coder/config.yaml": "model:\n provider: anthropic\n", 939 "profiles/coder/.env": "ANTHROPIC_API_KEY=sk-test\n", 940 "profiles/researcher/config.yaml": "model:\n provider: deepseek\n", 941 }) 942 943 args = Namespace(zipfile=str(zip_path), force=True) 944 945 from hermes_cli.backup import run_import 946 run_import(args) 947 948 # Profile directories should exist 949 assert (hermes_home / "profiles" / "coder" / "config.yaml").exists() 950 assert (hermes_home / "profiles" / "researcher" / "config.yaml").exists() 951 952 # Wrapper scripts should be created 953 assert (wrapper_dir / "coder").exists() 954 assert (wrapper_dir / "researcher").exists() 955 956 # Wrappers should contain the right content 957 coder_wrapper = (wrapper_dir / "coder").read_text() 958 assert "hermes -p coder" in coder_wrapper 959 960 def test_import_skips_profile_dirs_without_config(self, tmp_path, monkeypatch): 961 """Import doesn't create wrappers for profile dirs without config.""" 962 hermes_home = tmp_path / ".hermes" 963 hermes_home.mkdir() 964 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 965 monkeypatch.setattr(Path, "home", lambda: tmp_path) 966 967 wrapper_dir = tmp_path / ".local" / "bin" 968 wrapper_dir.mkdir(parents=True) 969 970 zip_path = tmp_path / "backup.zip" 971 self._make_backup_zip(zip_path, { 972 "config.yaml": "model: test\n", 973 "profiles/valid/config.yaml": "model: test\n", 974 "profiles/empty/readme.txt": "nothing here\n", 975 }) 976 977 args = Namespace(zipfile=str(zip_path), force=True) 978 979 from hermes_cli.backup import run_import 980 run_import(args) 981 982 # Only valid profile should get a wrapper 983 assert (wrapper_dir / "valid").exists() 984 assert not (wrapper_dir / "empty").exists() 985 986 def test_import_without_profiles_module(self, tmp_path, monkeypatch): 987 """Import gracefully handles missing profiles module (fresh install).""" 988 hermes_home = tmp_path / ".hermes" 989 hermes_home.mkdir() 990 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 991 monkeypatch.setattr(Path, "home", lambda: tmp_path) 992 993 zip_path = tmp_path / "backup.zip" 994 self._make_backup_zip(zip_path, { 995 "config.yaml": "model: test\n", 996 "profiles/coder/config.yaml": "model: test\n", 997 }) 998 999 args = Namespace(zipfile=str(zip_path), force=True) 1000 1001 # Simulate profiles module not being available 1002 import hermes_cli.backup as backup_mod 1003 original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ 1004 1005 def fake_import(name, *a, **kw): 1006 if name == "hermes_cli.profiles": 1007 raise ImportError("no profiles module") 1008 return original_import(name, *a, **kw) 1009 1010 from hermes_cli.backup import run_import 1011 with patch("builtins.__import__", side_effect=fake_import): 1012 run_import(args) 1013 1014 # Files should still be restored even if wrappers can't be created 1015 assert (hermes_home / "profiles" / "coder" / "config.yaml").exists() 1016 1017 1018 # --------------------------------------------------------------------------- 1019 # SQLite safe copy tests 1020 # --------------------------------------------------------------------------- 1021 1022 class TestSafeCopyDb: 1023 def test_copies_valid_database(self, tmp_path): 1024 from hermes_cli.backup import _safe_copy_db 1025 src = tmp_path / "test.db" 1026 dst = tmp_path / "copy.db" 1027 1028 conn = sqlite3.connect(str(src)) 1029 conn.execute("CREATE TABLE t (x INTEGER)") 1030 conn.execute("INSERT INTO t VALUES (42)") 1031 conn.commit() 1032 conn.close() 1033 1034 result = _safe_copy_db(src, dst) 1035 assert result is True 1036 1037 conn = sqlite3.connect(str(dst)) 1038 rows = conn.execute("SELECT x FROM t").fetchall() 1039 conn.close() 1040 assert rows == [(42,)] 1041 1042 def test_copies_wal_mode_database(self, tmp_path): 1043 from hermes_cli.backup import _safe_copy_db 1044 src = tmp_path / "wal.db" 1045 dst = tmp_path / "copy.db" 1046 1047 conn = sqlite3.connect(str(src)) 1048 conn.execute("PRAGMA journal_mode=WAL") 1049 conn.execute("CREATE TABLE t (x TEXT)") 1050 conn.execute("INSERT INTO t VALUES ('wal-test')") 1051 conn.commit() 1052 conn.close() 1053 1054 result = _safe_copy_db(src, dst) 1055 assert result is True 1056 1057 conn = sqlite3.connect(str(dst)) 1058 rows = conn.execute("SELECT x FROM t").fetchall() 1059 conn.close() 1060 assert rows == [("wal-test",)] 1061 1062 1063 # --------------------------------------------------------------------------- 1064 # Quick state snapshot tests 1065 # --------------------------------------------------------------------------- 1066 1067 class TestQuickSnapshot: 1068 @pytest.fixture 1069 def hermes_home(self, tmp_path): 1070 """Create a fake HERMES_HOME with critical state files.""" 1071 home = tmp_path / ".hermes" 1072 home.mkdir() 1073 (home / "config.yaml").write_text("model:\n provider: openrouter\n") 1074 (home / ".env").write_text("OPENROUTER_API_KEY=test-key-123\n") 1075 (home / "auth.json").write_text('{"providers": {}}\n') 1076 (home / "cron").mkdir() 1077 (home / "cron" / "jobs.json").write_text('{"jobs": []}\n') 1078 1079 # Real SQLite database 1080 db_path = home / "state.db" 1081 conn = sqlite3.connect(str(db_path)) 1082 conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, data TEXT)") 1083 conn.execute("INSERT INTO sessions VALUES ('s1', 'hello world')") 1084 conn.commit() 1085 conn.close() 1086 return home 1087 1088 def test_creates_snapshot(self, hermes_home): 1089 from hermes_cli.backup import create_quick_snapshot 1090 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1091 assert snap_id is not None 1092 snap_dir = hermes_home / "state-snapshots" / snap_id 1093 assert snap_dir.is_dir() 1094 assert (snap_dir / "manifest.json").exists() 1095 1096 def test_label_in_id(self, hermes_home): 1097 from hermes_cli.backup import create_quick_snapshot 1098 snap_id = create_quick_snapshot(label="before-upgrade", hermes_home=hermes_home) 1099 assert "before-upgrade" in snap_id 1100 1101 def test_state_db_safely_copied(self, hermes_home): 1102 from hermes_cli.backup import create_quick_snapshot 1103 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1104 db_copy = hermes_home / "state-snapshots" / snap_id / "state.db" 1105 assert db_copy.exists() 1106 1107 conn = sqlite3.connect(str(db_copy)) 1108 rows = conn.execute("SELECT * FROM sessions").fetchall() 1109 conn.close() 1110 assert len(rows) == 1 1111 assert rows[0] == ("s1", "hello world") 1112 1113 def test_copies_nested_files(self, hermes_home): 1114 from hermes_cli.backup import create_quick_snapshot 1115 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1116 assert (hermes_home / "state-snapshots" / snap_id / "cron" / "jobs.json").exists() 1117 1118 def test_missing_files_skipped(self, hermes_home): 1119 from hermes_cli.backup import create_quick_snapshot 1120 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1121 with open(hermes_home / "state-snapshots" / snap_id / "manifest.json") as f: 1122 meta = json.load(f) 1123 # gateway_state.json etc. don't exist in fixture 1124 assert "gateway_state.json" not in meta["files"] 1125 1126 def test_empty_home_returns_none(self, tmp_path): 1127 from hermes_cli.backup import create_quick_snapshot 1128 empty = tmp_path / "empty" 1129 empty.mkdir() 1130 assert create_quick_snapshot(hermes_home=empty) is None 1131 1132 def test_list_snapshots(self, hermes_home): 1133 from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots 1134 id1 = create_quick_snapshot(label="first", hermes_home=hermes_home) 1135 id2 = create_quick_snapshot(label="second", hermes_home=hermes_home) 1136 1137 snaps = list_quick_snapshots(hermes_home=hermes_home) 1138 assert len(snaps) == 2 1139 assert snaps[0]["id"] == id2 # most recent first 1140 assert snaps[1]["id"] == id1 1141 1142 def test_list_limit(self, hermes_home): 1143 from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots 1144 for i in range(5): 1145 create_quick_snapshot(label=f"s{i}", hermes_home=hermes_home) 1146 snaps = list_quick_snapshots(limit=3, hermes_home=hermes_home) 1147 assert len(snaps) == 3 1148 1149 def test_restore_config(self, hermes_home): 1150 from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot 1151 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1152 1153 (hermes_home / "config.yaml").write_text("model:\n provider: anthropic\n") 1154 assert "anthropic" in (hermes_home / "config.yaml").read_text() 1155 1156 result = restore_quick_snapshot(snap_id, hermes_home=hermes_home) 1157 assert result is True 1158 assert "openrouter" in (hermes_home / "config.yaml").read_text() 1159 1160 def test_restore_state_db(self, hermes_home): 1161 from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot 1162 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1163 1164 conn = sqlite3.connect(str(hermes_home / "state.db")) 1165 conn.execute("INSERT INTO sessions VALUES ('s2', 'new')") 1166 conn.commit() 1167 conn.close() 1168 1169 restore_quick_snapshot(snap_id, hermes_home=hermes_home) 1170 1171 conn = sqlite3.connect(str(hermes_home / "state.db")) 1172 rows = conn.execute("SELECT * FROM sessions").fetchall() 1173 conn.close() 1174 assert len(rows) == 1 1175 1176 def test_restore_nonexistent(self, hermes_home): 1177 from hermes_cli.backup import restore_quick_snapshot 1178 assert restore_quick_snapshot("nonexistent", hermes_home=hermes_home) is False 1179 1180 def test_auto_prune(self, hermes_home): 1181 from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots, _QUICK_DEFAULT_KEEP 1182 for i in range(_QUICK_DEFAULT_KEEP + 5): 1183 create_quick_snapshot(label=f"snap-{i:03d}", hermes_home=hermes_home) 1184 snaps = list_quick_snapshots(limit=100, hermes_home=hermes_home) 1185 assert len(snaps) <= _QUICK_DEFAULT_KEEP 1186 1187 def test_manual_prune(self, hermes_home): 1188 from hermes_cli.backup import create_quick_snapshot, prune_quick_snapshots, list_quick_snapshots 1189 for i in range(10): 1190 create_quick_snapshot(label=f"s{i}", hermes_home=hermes_home) 1191 deleted = prune_quick_snapshots(keep=3, hermes_home=hermes_home) 1192 assert deleted == 7 1193 assert len(list_quick_snapshots(hermes_home=hermes_home)) == 3 1194 1195 def test_snapshot_includes_pairing_directories(self, hermes_home): 1196 """Pairing JSONs live outside state.db — snapshot must capture them 1197 recursively (generic + per-platform) so approved-user lists survive 1198 disasters like #15733.""" 1199 from hermes_cli.backup import create_quick_snapshot 1200 1201 # Generic pairing store (new location) 1202 (hermes_home / "platforms" / "pairing").mkdir(parents=True) 1203 (hermes_home / "platforms" / "pairing" / "telegram-approved.json").write_text( 1204 '{"12345": {"user_name": "alice"}}' 1205 ) 1206 (hermes_home / "platforms" / "pairing" / "discord-approved.json").write_text( 1207 '{"67890": {"user_name": "bob"}}' 1208 ) 1209 # Legacy pairing store (old location) 1210 (hermes_home / "pairing").mkdir() 1211 (hermes_home / "pairing" / "matrix-approved.json").write_text( 1212 '{"@charlie:server": {"user_name": "charlie"}}' 1213 ) 1214 # Feishu's separate JSON 1215 (hermes_home / "feishu_comment_pairing.json").write_text( 1216 '{"doc_abc": {"allow_from": ["user_xyz"]}}' 1217 ) 1218 1219 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1220 assert snap_id is not None 1221 1222 snap_dir = hermes_home / "state-snapshots" / snap_id 1223 assert (snap_dir / "platforms" / "pairing" / "telegram-approved.json").exists() 1224 assert (snap_dir / "platforms" / "pairing" / "discord-approved.json").exists() 1225 assert (snap_dir / "pairing" / "matrix-approved.json").exists() 1226 assert (snap_dir / "feishu_comment_pairing.json").exists() 1227 1228 with open(snap_dir / "manifest.json") as f: 1229 meta = json.load(f) 1230 files = meta["files"] 1231 assert "platforms/pairing/telegram-approved.json" in files 1232 assert "platforms/pairing/discord-approved.json" in files 1233 assert "pairing/matrix-approved.json" in files 1234 assert "feishu_comment_pairing.json" in files 1235 1236 def test_restore_recovers_pairing_data(self, hermes_home): 1237 """After restore, deleted pairing files reappear with original content.""" 1238 from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot 1239 1240 pairing_dir = hermes_home / "platforms" / "pairing" 1241 pairing_dir.mkdir(parents=True) 1242 approved = pairing_dir / "telegram-approved.json" 1243 approved.write_text('{"12345": {"user_name": "alice"}}') 1244 feishu = hermes_home / "feishu_comment_pairing.json" 1245 feishu.write_text('{"doc_abc": {"allow_from": ["user_xyz"]}}') 1246 1247 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1248 assert snap_id is not None 1249 1250 # Simulate the disaster — user loses both pairing files. 1251 approved.unlink() 1252 feishu.unlink() 1253 assert not approved.exists() 1254 assert not feishu.exists() 1255 1256 assert restore_quick_snapshot(snap_id, hermes_home=hermes_home) is True 1257 assert approved.exists() 1258 assert '"alice"' in approved.read_text() 1259 assert feishu.exists() 1260 assert '"user_xyz"' in feishu.read_text() 1261 1262 def test_empty_pairing_dir_does_not_fail(self, hermes_home): 1263 """An empty pairing directory should be silently skipped.""" 1264 from hermes_cli.backup import create_quick_snapshot 1265 1266 (hermes_home / "platforms" / "pairing").mkdir(parents=True) 1267 # Directory exists but contains no files. 1268 snap_id = create_quick_snapshot(hermes_home=hermes_home) 1269 # Other state still present → snapshot succeeds. 1270 assert snap_id is not None 1271 1272 # --------------------------------------------------------------------------- 1273 # Pre-update backup (hermes update safety net) 1274 # --------------------------------------------------------------------------- 1275 1276 class TestPreUpdateBackup: 1277 """Tests for create_pre_update_backup — the auto-backup ``hermes update`` 1278 runs before touching anything.""" 1279 1280 @pytest.fixture 1281 def hermes_home(self, tmp_path): 1282 root = tmp_path / ".hermes" 1283 root.mkdir() 1284 _make_hermes_tree(root) 1285 return root 1286 1287 def test_creates_backup_under_backups_dir(self, hermes_home): 1288 from hermes_cli.backup import create_pre_update_backup 1289 out = create_pre_update_backup(hermes_home=hermes_home) 1290 assert out is not None 1291 assert out.exists() 1292 assert out.parent == hermes_home / "backups" 1293 assert out.name.startswith("pre-update-") 1294 assert out.suffix == ".zip" 1295 1296 def test_backup_contents_match_full_backup(self, hermes_home): 1297 """Pre-update backup should include the same user data that 1298 ``hermes backup`` would, and should exclude the same directories.""" 1299 from hermes_cli.backup import create_pre_update_backup 1300 out = create_pre_update_backup(hermes_home=hermes_home) 1301 assert out is not None 1302 with zipfile.ZipFile(out) as zf: 1303 names = set(zf.namelist()) 1304 # User data present 1305 assert "config.yaml" in names 1306 assert ".env" in names 1307 assert "sessions/abc123.json" in names 1308 assert "skills/my-skill/SKILL.md" in names 1309 assert "profiles/coder/config.yaml" in names 1310 # hermes-agent repo excluded 1311 assert not any(n.startswith("hermes-agent/") for n in names) 1312 # __pycache__ excluded 1313 assert not any("__pycache__" in n for n in names) 1314 # pid files excluded 1315 assert "gateway.pid" not in names 1316 1317 def test_does_not_recurse_into_prior_backups(self, hermes_home): 1318 """The ``backups/`` directory must be excluded so that each backup 1319 doesn't grow exponentially by including all prior backups.""" 1320 from hermes_cli.backup import create_pre_update_backup 1321 # First backup 1322 out1 = create_pre_update_backup(hermes_home=hermes_home) 1323 assert out1 is not None 1324 # Second backup — must not include the first 1325 out2 = create_pre_update_backup(hermes_home=hermes_home) 1326 assert out2 is not None 1327 with zipfile.ZipFile(out2) as zf: 1328 names = zf.namelist() 1329 assert not any(n.startswith("backups/") for n in names), ( 1330 f"Pre-update backup recursed into backups/ — leaked: " 1331 f"{[n for n in names if n.startswith('backups/')]}" 1332 ) 1333 1334 def test_rotation_keeps_only_n(self, hermes_home): 1335 """After more than ``keep`` backups are created, older ones are 1336 pruned automatically.""" 1337 import time as _t 1338 from hermes_cli.backup import create_pre_update_backup 1339 1340 created = [] 1341 for _ in range(5): 1342 out = create_pre_update_backup(hermes_home=hermes_home, keep=3) 1343 created.append(out) 1344 _t.sleep(1.05) # ensure distinct seconds in timestamp 1345 1346 remaining = sorted( 1347 p.name for p in (hermes_home / "backups").iterdir() 1348 if p.name.startswith("pre-update-") 1349 ) 1350 assert len(remaining) == 3 1351 # Oldest two should have been pruned 1352 assert created[0].name not in remaining 1353 assert created[1].name not in remaining 1354 # Newest three should remain 1355 assert created[4].name in remaining 1356 1357 def test_rotation_preserves_manual_files(self, hermes_home): 1358 """Hand-dropped zips in ``backups/`` must not be touched by 1359 rotation — it only prunes files matching ``pre-update-*.zip``.""" 1360 import time as _t 1361 from hermes_cli.backup import create_pre_update_backup 1362 1363 (hermes_home / "backups").mkdir(exist_ok=True) 1364 manual = hermes_home / "backups" / "my-manual.zip" 1365 manual.write_bytes(b"manual backup") 1366 1367 for _ in range(5): 1368 create_pre_update_backup(hermes_home=hermes_home, keep=2) 1369 _t.sleep(1.05) 1370 1371 assert manual.exists(), "Manual backup zip was incorrectly pruned" 1372 1373 def test_returns_none_if_root_missing(self, tmp_path): 1374 from hermes_cli.backup import create_pre_update_backup 1375 assert create_pre_update_backup(hermes_home=tmp_path / "does-not-exist") is None 1376 1377 def test_keep_zero_does_not_delete_freshly_created_backup(self, hermes_home): 1378 """Regression: ``backup_keep: 0`` previously triggered ``backups[0:]`` 1379 in the pruner — wiping the just-created zip and leaving the user 1380 with no recovery point. The floor (keep>=1) preserves the new file 1381 regardless of misconfiguration; users who don't want backups should 1382 set ``pre_update_backup: false`` instead. 1383 """ 1384 from hermes_cli.backup import create_pre_update_backup 1385 out = create_pre_update_backup(hermes_home=hermes_home, keep=0) 1386 assert out is not None 1387 assert out.exists(), ( 1388 "keep=0 silently deleted the freshly-created backup; floor " 1389 "should preserve the just-written file." 1390 ) 1391 1392 def test_keep_negative_does_not_delete_freshly_created_backup(self, hermes_home): 1393 """Mirror coverage: any value <1 should be floored, not literally 1394 applied as a slice index.""" 1395 from hermes_cli.backup import create_pre_update_backup 1396 out = create_pre_update_backup(hermes_home=hermes_home, keep=-3) 1397 assert out is not None 1398 assert out.exists() 1399 1400 def test_keep_zero_still_prunes_older_backups(self, hermes_home): 1401 """The floor preserves the new backup but should NOT regress the 1402 rotation behaviour for older zips: a third call with keep=0 must 1403 still remove pre-existing backups beyond the (floored) limit of 1. 1404 """ 1405 import time as _t 1406 from hermes_cli.backup import create_pre_update_backup 1407 1408 first = create_pre_update_backup(hermes_home=hermes_home, keep=5) 1409 _t.sleep(1.05) 1410 second = create_pre_update_backup(hermes_home=hermes_home, keep=5) 1411 _t.sleep(1.05) 1412 third = create_pre_update_backup(hermes_home=hermes_home, keep=0) 1413 1414 remaining = { 1415 p.name for p in (hermes_home / "backups").iterdir() 1416 if p.name.startswith("pre-update-") 1417 } 1418 assert third.name in remaining, "Floor must preserve the new backup" 1419 assert first.name not in remaining and second.name not in remaining, ( 1420 f"keep=0 floor of 1 should still prune older backups; " 1421 f"remaining={remaining}" 1422 ) 1423 1424 1425 class TestRunPreUpdateBackup: 1426 """Tests for the ``_run_pre_update_backup`` wrapper in main.py — 1427 covers config gate, ``--no-backup`` flag, and user-facing output.""" 1428 1429 @pytest.fixture 1430 def hermes_home(self, tmp_path, monkeypatch): 1431 root = tmp_path / ".hermes" 1432 root.mkdir() 1433 _make_hermes_tree(root) 1434 # Point HERMES_HOME at the temp dir so config + backup paths resolve here 1435 monkeypatch.setenv("HERMES_HOME", str(root)) 1436 # Make Path.home() point at tmp_path for anything that uses it 1437 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1438 # Bust caches for hermes_cli.config + hermes_constants so they pick up HERMES_HOME 1439 for mod in list(__import__("sys").modules.keys()): 1440 if mod.startswith("hermes_cli.config") or mod == "hermes_constants": 1441 del __import__("sys").modules[mod] 1442 return root 1443 1444 def test_backup_flag_creates_backup(self, hermes_home, capsys): 1445 """--backup forces the pre-update backup for one run even when config is off.""" 1446 from hermes_cli.main import _run_pre_update_backup 1447 _run_pre_update_backup(Namespace(no_backup=False, backup=True)) 1448 out = capsys.readouterr().out 1449 assert "Creating pre-update backup" in out 1450 assert "Saved:" in out 1451 assert "Restore:" in out 1452 assert "hermes import" in out 1453 assert "Disable:" in out 1454 # Actual backup was created 1455 backups = list((hermes_home / "backups").glob("pre-update-*.zip")) 1456 assert len(backups) == 1 1457 1458 def test_default_disabled_is_silent(self, hermes_home, capsys): 1459 """With the default-off config and no --backup flag, the hook is silent 1460 and creates no backup. This is the common case for every update.""" 1461 from hermes_cli.main import _run_pre_update_backup 1462 _run_pre_update_backup(Namespace(no_backup=False, backup=False)) 1463 out = capsys.readouterr().out 1464 assert out == "" 1465 assert not (hermes_home / "backups").exists() or not list( 1466 (hermes_home / "backups").glob("pre-update-*.zip") 1467 ) 1468 1469 def test_no_backup_flag_skips(self, hermes_home, capsys): 1470 from hermes_cli.main import _run_pre_update_backup 1471 _run_pre_update_backup(Namespace(no_backup=True, backup=False)) 1472 out = capsys.readouterr().out 1473 assert "skipped (--no-backup)" in out 1474 assert "Creating pre-update backup" not in out 1475 # No backup written 1476 assert not (hermes_home / "backups").exists() or not list( 1477 (hermes_home / "backups").glob("pre-update-*.zip") 1478 ) 1479 1480 def test_config_enabled_creates_backup(self, hermes_home, capsys): 1481 """Users who explicitly set updates.pre_update_backup: true still get 1482 a backup on every update — this is the opt-in legacy behavior.""" 1483 import yaml 1484 (hermes_home / "config.yaml").write_text(yaml.safe_dump({ 1485 "_config_version": 22, 1486 "updates": {"pre_update_backup": True}, 1487 })) 1488 import sys as _sys 1489 for mod in list(_sys.modules.keys()): 1490 if mod.startswith("hermes_cli.config"): 1491 del _sys.modules[mod] 1492 1493 from hermes_cli.main import _run_pre_update_backup 1494 _run_pre_update_backup(Namespace(no_backup=False, backup=False)) 1495 out = capsys.readouterr().out 1496 assert "Creating pre-update backup" in out 1497 assert "Saved:" in out 1498 backups = list((hermes_home / "backups").glob("pre-update-*.zip")) 1499 assert len(backups) == 1 1500 1501 def test_config_disabled_is_silent(self, hermes_home, capsys): 1502 """Explicit pre_update_backup: false behaves the same as the default — 1503 silent no-op, no message spam.""" 1504 import yaml 1505 (hermes_home / "config.yaml").write_text(yaml.safe_dump({ 1506 "_config_version": 22, 1507 "updates": {"pre_update_backup": False}, 1508 })) 1509 # Ensure config module re-reads 1510 import sys as _sys 1511 for mod in list(_sys.modules.keys()): 1512 if mod.startswith("hermes_cli.config"): 1513 del _sys.modules[mod] 1514 1515 from hermes_cli.main import _run_pre_update_backup 1516 _run_pre_update_backup(Namespace(no_backup=False, backup=False)) 1517 out = capsys.readouterr().out 1518 assert out == "" 1519 assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \ 1520 if (hermes_home / "backups").exists() else True 1521 1522 def test_cli_flag_overrides_enabled_config(self, hermes_home, capsys): 1523 """--no-backup wins even when config says pre_update_backup: true.""" 1524 import yaml 1525 (hermes_home / "config.yaml").write_text(yaml.safe_dump({ 1526 "_config_version": 22, 1527 "updates": {"pre_update_backup": True}, 1528 })) 1529 import sys as _sys 1530 for mod in list(_sys.modules.keys()): 1531 if mod.startswith("hermes_cli.config"): 1532 del _sys.modules[mod] 1533 1534 from hermes_cli.main import _run_pre_update_backup 1535 _run_pre_update_backup(Namespace(no_backup=True, backup=False)) 1536 out = capsys.readouterr().out 1537 assert "skipped (--no-backup)" in out 1538 1539 1540 # --------------------------------------------------------------------------- 1541 # Pre-migration backup (hermes claw migrate safety net) 1542 # --------------------------------------------------------------------------- 1543 1544 class TestPreMigrationBackup: 1545 """Tests for create_pre_migration_backup — the auto-backup 1546 ``hermes claw migrate`` runs before mutating ~/.hermes/.""" 1547 1548 @pytest.fixture 1549 def hermes_home(self, tmp_path): 1550 root = tmp_path / ".hermes" 1551 root.mkdir() 1552 _make_hermes_tree(root) 1553 return root 1554 1555 def test_creates_backup_under_backups_dir(self, hermes_home): 1556 from hermes_cli.backup import create_pre_migration_backup 1557 out = create_pre_migration_backup(hermes_home=hermes_home) 1558 assert out is not None 1559 assert out.exists() 1560 # Shares the backups/ directory with pre-update backups so `hermes 1561 # import` and the update-backup listing both pick them up. 1562 assert out.parent == hermes_home / "backups" 1563 assert out.name.startswith("pre-migration-") 1564 assert out.suffix == ".zip" 1565 1566 def test_backup_uses_shared_exclusion_rules(self, hermes_home): 1567 """Pre-migration backup reuses the same exclusion rules as 1568 ``hermes backup`` / ``create_pre_update_backup`` — no drift.""" 1569 from hermes_cli.backup import create_pre_migration_backup 1570 out = create_pre_migration_backup(hermes_home=hermes_home) 1571 assert out is not None 1572 with zipfile.ZipFile(out) as zf: 1573 names = set(zf.namelist()) 1574 # User data present 1575 assert "config.yaml" in names 1576 assert ".env" in names 1577 assert "skills/my-skill/SKILL.md" in names 1578 # Same exclusions as the shared helper 1579 assert not any(n.startswith("hermes-agent/") for n in names) 1580 assert not any("__pycache__" in n for n in names) 1581 assert "gateway.pid" not in names 1582 1583 def test_restorable_with_hermes_import(self, hermes_home, tmp_path): 1584 """The zip produced by pre-migration backup must be a valid Hermes 1585 backup — `hermes import` should accept it.""" 1586 from hermes_cli.backup import create_pre_migration_backup, _validate_backup_zip 1587 out = create_pre_migration_backup(hermes_home=hermes_home) 1588 assert out is not None 1589 with zipfile.ZipFile(out) as zf: 1590 valid, _reason = _validate_backup_zip(zf) 1591 assert valid, "pre-migration zip failed _validate_backup_zip" 1592 1593 def test_does_not_recurse_into_prior_backups(self, hermes_home): 1594 from hermes_cli.backup import create_pre_migration_backup 1595 out1 = create_pre_migration_backup(hermes_home=hermes_home) 1596 assert out1 is not None 1597 out2 = create_pre_migration_backup(hermes_home=hermes_home) 1598 assert out2 is not None 1599 with zipfile.ZipFile(out2) as zf: 1600 names = zf.namelist() 1601 assert not any(n.startswith("backups/") for n in names) 1602 1603 def test_rotation_keeps_only_n(self, hermes_home): 1604 import time as _t 1605 from hermes_cli.backup import create_pre_migration_backup 1606 1607 created = [] 1608 for _ in range(7): 1609 out = create_pre_migration_backup(hermes_home=hermes_home, keep=3) 1610 if out is not None: 1611 created.append(out) 1612 _t.sleep(1.05) # timestamp resolution 1613 1614 remaining = sorted((hermes_home / "backups").glob("pre-migration-*.zip")) 1615 assert len(remaining) <= 3, f"expected <=3 backups retained, got {len(remaining)}" 1616 1617 def test_missing_hermes_home_returns_none(self, tmp_path): 1618 """Fresh install with no ~/.hermes yet — nothing to back up.""" 1619 from hermes_cli.backup import create_pre_migration_backup 1620 missing = tmp_path / "does-not-exist" 1621 out = create_pre_migration_backup(hermes_home=missing) 1622 assert out is None 1623 1624 def test_does_not_touch_pre_update_backups(self, hermes_home): 1625 """Pre-migration rotation must only prune pre-migration-*.zip files, 1626 leaving pre-update-*.zip backups untouched.""" 1627 from hermes_cli.backup import create_pre_update_backup, create_pre_migration_backup 1628 update_backup = create_pre_update_backup(hermes_home=hermes_home, keep=5) 1629 assert update_backup is not None and update_backup.exists() 1630 # Spin up a lot of migration backups with keep=1 1631 import time as _t 1632 for _ in range(3): 1633 out = create_pre_migration_backup(hermes_home=hermes_home, keep=1) 1634 assert out is not None 1635 _t.sleep(1.05) 1636 # Update backup must still be there 1637 assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"