/ tests / hermes_cli / test_backup.py
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"