test_local_backup.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """Unit tests for scheduled local backup service (#884)."""
  2. import tempfile
  3. import zipfile
  4. from datetime import datetime, timedelta, timezone
  5. from pathlib import Path
  6. from unittest.mock import AsyncMock, patch
  7. import pytest
  8. from backend.app.services.local_backup import LocalBackupService
  9. class TestCalculateNextRun:
  10. """Tests for _calculate_next_run scheduling logic."""
  11. def test_hourly_returns_next_full_hour(self):
  12. service = LocalBackupService()
  13. now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
  14. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  15. mock_dt.now.return_value = now
  16. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  17. result = service._calculate_next_run("hourly", "03:00")
  18. assert result.hour == 15
  19. assert result.minute == 0
  20. def test_daily_before_target_time_schedules_today(self):
  21. service = LocalBackupService()
  22. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  23. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  24. mock_dt.now.return_value = now
  25. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  26. result = service._calculate_next_run("daily", "03:00")
  27. assert result.day == 12
  28. assert result.hour == 3
  29. def test_daily_after_target_time_schedules_tomorrow(self):
  30. service = LocalBackupService()
  31. now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
  32. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  33. mock_dt.now.return_value = now
  34. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  35. result = service._calculate_next_run("daily", "03:00")
  36. assert result.day == 13
  37. assert result.hour == 3
  38. def test_weekly_adds_full_week(self):
  39. service = LocalBackupService()
  40. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  41. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  42. mock_dt.now.return_value = now
  43. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  44. result = service._calculate_next_run("weekly", "03:00")
  45. expected = datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
  46. assert result == expected
  47. def test_weekly_after_target_time_adds_full_week_from_tomorrow(self):
  48. service = LocalBackupService()
  49. now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
  50. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  51. mock_dt.now.return_value = now
  52. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  53. result = service._calculate_next_run("weekly", "03:00")
  54. expected = datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
  55. assert result == expected
  56. def test_invalid_time_defaults_to_0300(self):
  57. service = LocalBackupService()
  58. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  59. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  60. mock_dt.now.return_value = now
  61. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  62. result = service._calculate_next_run("daily", "invalid")
  63. assert result.hour == 3
  64. assert result.minute == 0
  65. def test_unknown_schedule_type_defaults_to_daily(self):
  66. service = LocalBackupService()
  67. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  68. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  69. mock_dt.now.return_value = now
  70. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  71. result = service._calculate_next_run("every_5_min", "03:00")
  72. # Should fall through to daily behavior (time-based)
  73. assert result.hour == 3
  74. class TestPruneBackups:
  75. """Tests for backup retention pruning."""
  76. def test_prune_keeps_retention_count(self, tmp_path):
  77. service = LocalBackupService()
  78. # Create 5 backup files
  79. for i in range(5):
  80. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  81. f.write_text(f"backup{i}")
  82. service._prune_backups(tmp_path, retention=3)
  83. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  84. assert len(remaining) == 3
  85. def test_prune_noop_when_under_retention(self, tmp_path):
  86. service = LocalBackupService()
  87. for i in range(2):
  88. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  89. f.write_text(f"backup{i}")
  90. service._prune_backups(tmp_path, retention=5)
  91. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  92. assert len(remaining) == 2
  93. def test_prune_only_touches_matching_files(self, tmp_path):
  94. service = LocalBackupService()
  95. # Create backup files and a non-backup file
  96. for i in range(3):
  97. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  98. f.write_text(f"backup{i}")
  99. other = tmp_path / "other_file.txt"
  100. other.write_text("keep me")
  101. service._prune_backups(tmp_path, retention=1)
  102. assert other.exists()
  103. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  104. assert len(remaining) == 1
  105. class TestResolveBackupFile:
  106. """Tests for backup file resolution with path traversal protection."""
  107. def test_valid_filename(self, tmp_path):
  108. service = LocalBackupService()
  109. f = tmp_path / "bambuddy-backup-20260412-120000.zip"
  110. f.write_text("data")
  111. result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  112. assert result == f
  113. def test_path_traversal_blocked(self, tmp_path):
  114. service = LocalBackupService()
  115. result = service.resolve_backup_file(str(tmp_path), "../etc/passwd")
  116. assert result is None
  117. def test_backslash_blocked(self, tmp_path):
  118. service = LocalBackupService()
  119. result = service.resolve_backup_file(str(tmp_path), "..\\etc\\passwd")
  120. assert result is None
  121. def test_dotdot_blocked(self, tmp_path):
  122. service = LocalBackupService()
  123. result = service.resolve_backup_file(str(tmp_path), "..bambuddy-backup.zip")
  124. assert result is None
  125. def test_wrong_prefix_blocked(self, tmp_path):
  126. service = LocalBackupService()
  127. f = tmp_path / "evil-file.zip"
  128. f.write_text("data")
  129. result = service.resolve_backup_file(str(tmp_path), "evil-file.zip")
  130. assert result is None
  131. def test_nonexistent_file(self, tmp_path):
  132. service = LocalBackupService()
  133. result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  134. assert result is None
  135. class TestDeleteBackup:
  136. """Tests for backup deletion."""
  137. def test_delete_valid_backup(self, tmp_path):
  138. service = LocalBackupService()
  139. f = tmp_path / "bambuddy-backup-20260412-120000.zip"
  140. f.write_text("data")
  141. result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  142. assert result["success"] is True
  143. assert not f.exists()
  144. def test_delete_nonexistent_backup(self, tmp_path):
  145. service = LocalBackupService()
  146. result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  147. assert result["success"] is False
  148. def test_delete_path_traversal_blocked(self, tmp_path):
  149. service = LocalBackupService()
  150. result = service.delete_backup(str(tmp_path), "../important.zip")
  151. assert result["success"] is False
  152. class TestListBackups:
  153. """Tests for backup listing."""
  154. def test_list_empty_dir(self, tmp_path):
  155. service = LocalBackupService()
  156. result = service.list_backups(str(tmp_path))
  157. assert result == []
  158. def test_list_nonexistent_dir(self):
  159. service = LocalBackupService()
  160. result = service.list_backups("/nonexistent/path/12345")
  161. assert result == []
  162. def test_list_only_matching_files(self, tmp_path):
  163. service = LocalBackupService()
  164. (tmp_path / "bambuddy-backup-20260412-120000.zip").write_text("a")
  165. (tmp_path / "bambuddy-backup-20260412-130000.zip").write_text("bb")
  166. (tmp_path / "other-file.txt").write_text("ccc")
  167. result = service.list_backups(str(tmp_path))
  168. assert len(result) == 2
  169. assert all(r["filename"].startswith("bambuddy-backup-") for r in result)
  170. def test_list_sorted_newest_first(self, tmp_path):
  171. import time
  172. service = LocalBackupService()
  173. f1 = tmp_path / "bambuddy-backup-20260412-120000.zip"
  174. f1.write_text("a")
  175. time.sleep(0.05)
  176. f2 = tmp_path / "bambuddy-backup-20260412-130000.zip"
  177. f2.write_text("b")
  178. result = service.list_backups(str(tmp_path))
  179. assert result[0]["filename"] == "bambuddy-backup-20260412-130000.zip"
  180. def test_list_includes_size(self, tmp_path):
  181. service = LocalBackupService()
  182. (tmp_path / "bambuddy-backup-20260412-120000.zip").write_bytes(b"x" * 1024)
  183. result = service.list_backups(str(tmp_path))
  184. assert result[0]["size"] == 1024
  185. class TestGetStatus:
  186. """Tests for status reporting."""
  187. def test_initial_status(self):
  188. service = LocalBackupService()
  189. status = service.get_status()
  190. assert status["is_running"] is False
  191. assert status["last_backup_at"] is None
  192. assert status["last_status"] is None
  193. assert status["next_run"] is None