test_local_backup.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. The HH:MM picker is interpreted in the container's local timezone (TZ env
  12. var, UTC fallback). Each test pins TZ so the assertions don't depend on
  13. the test runner's environment.
  14. """
  15. def test_hourly_returns_next_full_hour(self, monkeypatch):
  16. monkeypatch.setenv("TZ", "UTC")
  17. service = LocalBackupService()
  18. now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
  19. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  20. mock_dt.now.return_value = now
  21. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  22. result = service._calculate_next_run("hourly", "03:00")
  23. assert result.hour == 15
  24. assert result.minute == 0
  25. def test_daily_before_target_time_schedules_today_utc(self, monkeypatch):
  26. monkeypatch.setenv("TZ", "UTC")
  27. service = LocalBackupService()
  28. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  29. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  30. mock_dt.now.return_value = now
  31. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  32. result = service._calculate_next_run("daily", "03:00")
  33. assert result == datetime(2026, 4, 12, 3, 0, 0, tzinfo=timezone.utc)
  34. def test_daily_after_target_time_schedules_tomorrow_utc(self, monkeypatch):
  35. monkeypatch.setenv("TZ", "UTC")
  36. service = LocalBackupService()
  37. now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
  38. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  39. mock_dt.now.return_value = now
  40. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  41. result = service._calculate_next_run("daily", "03:00")
  42. assert result == datetime(2026, 4, 13, 3, 0, 0, tzinfo=timezone.utc)
  43. def test_weekly_adds_full_week_utc(self, monkeypatch):
  44. monkeypatch.setenv("TZ", "UTC")
  45. service = LocalBackupService()
  46. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  47. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  48. mock_dt.now.return_value = now
  49. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  50. result = service._calculate_next_run("weekly", "03:00")
  51. assert result == datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
  52. def test_weekly_after_target_time_adds_full_week_from_tomorrow_utc(self, monkeypatch):
  53. monkeypatch.setenv("TZ", "UTC")
  54. service = LocalBackupService()
  55. now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
  56. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  57. mock_dt.now.return_value = now
  58. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  59. result = service._calculate_next_run("weekly", "03:00")
  60. assert result == datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
  61. def test_invalid_time_defaults_to_0300(self, monkeypatch):
  62. monkeypatch.setenv("TZ", "UTC")
  63. service = LocalBackupService()
  64. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  65. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  66. mock_dt.now.return_value = now
  67. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  68. result = service._calculate_next_run("daily", "invalid")
  69. assert result.hour == 3
  70. assert result.minute == 0
  71. def test_unknown_schedule_type_defaults_to_daily(self, monkeypatch):
  72. monkeypatch.setenv("TZ", "UTC")
  73. service = LocalBackupService()
  74. now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
  75. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  76. mock_dt.now.return_value = now
  77. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  78. result = service._calculate_next_run("every_5_min", "03:00")
  79. # Should fall through to daily behavior (time-based)
  80. assert result.hour == 3
  81. def test_daily_berlin_local_time_converts_to_utc(self, monkeypatch):
  82. """User in Europe/Berlin entering 21:00 should run at 19:00 UTC (CEST/UTC+2)."""
  83. monkeypatch.setenv("TZ", "Europe/Berlin")
  84. service = LocalBackupService()
  85. # Mid-June: Europe/Berlin is CEST (+02:00)
  86. now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc) # 12:00 Berlin
  87. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  88. mock_dt.now.return_value = now
  89. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  90. result = service._calculate_next_run("daily", "21:00")
  91. # 21:00 Berlin (CEST, +02:00) on 2026-06-15 == 19:00 UTC same day
  92. assert result == datetime(2026, 6, 15, 19, 0, 0, tzinfo=timezone.utc)
  93. def test_daily_istanbul_local_time_converts_to_utc(self, monkeypatch):
  94. """The #1602 reporter: UTC+3 user entering 21:00 should run at 18:00 UTC."""
  95. monkeypatch.setenv("TZ", "Europe/Istanbul")
  96. service = LocalBackupService()
  97. now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc) # 13:00 Istanbul
  98. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  99. mock_dt.now.return_value = now
  100. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  101. result = service._calculate_next_run("daily", "21:00")
  102. assert result == datetime(2026, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
  103. def test_no_tz_env_falls_back_to_utc(self, monkeypatch):
  104. monkeypatch.delenv("TZ", raising=False)
  105. service = LocalBackupService()
  106. now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
  107. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  108. mock_dt.now.return_value = now
  109. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  110. result = service._calculate_next_run("daily", "21:00")
  111. # No TZ → behaves as UTC: 21:00 today is in the future of 10:00, so today
  112. assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
  113. def test_unrecognised_tz_falls_back_to_utc(self, monkeypatch):
  114. monkeypatch.setenv("TZ", "Not/A_Real_Zone")
  115. service = LocalBackupService()
  116. now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
  117. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  118. mock_dt.now.return_value = now
  119. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  120. result = service._calculate_next_run("daily", "21:00")
  121. assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
  122. def test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc(self, monkeypatch):
  123. """Windows installer ships an embedded Python without the IANA tz DB
  124. (no system tzdata, no ``tzdata`` PyPI package). Even ``ZoneInfo("UTC")``
  125. raises ``ZoneInfoNotFoundError`` then, and /api/local-backup/status
  126. 500s. The fallback must catch that and return ``datetime.timezone.utc``
  127. so scheduling still works without the DB.
  128. """
  129. from zoneinfo import ZoneInfoNotFoundError
  130. from backend.app.services import local_backup as lb_module
  131. monkeypatch.delenv("TZ", raising=False)
  132. def _always_missing(_key):
  133. raise ZoneInfoNotFoundError("no tz database on this platform")
  134. monkeypatch.setattr(lb_module, "ZoneInfo", _always_missing)
  135. assert lb_module._local_zone() is timezone.utc
  136. def test_dst_spring_forward_gap_does_not_crash(self, monkeypatch):
  137. """Europe/Berlin spring-forward 2026-03-29 jumps 02:00 → 03:00 local;
  138. 02:30 wall-clock does not exist. ``replace(hour=2, minute=30)`` should
  139. still normalise to a valid UTC instant via astimezone, not raise.
  140. """
  141. monkeypatch.setenv("TZ", "Europe/Berlin")
  142. service = LocalBackupService()
  143. # 2026-03-29 00:30 UTC == 01:30 Berlin (CET, just before the gap)
  144. now = datetime(2026, 3, 29, 0, 30, 0, tzinfo=timezone.utc)
  145. with patch("backend.app.services.local_backup.datetime") as mock_dt:
  146. mock_dt.now.return_value = now
  147. mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
  148. # 02:30 local is in the non-existent gap on that day.
  149. result = service._calculate_next_run("daily", "02:30")
  150. # Result must be a UTC-aware datetime — exact value depends on
  151. # zoneinfo's gap normalisation; we just guarantee no crash and that
  152. # the run is in the future of ``now``.
  153. assert result.tzinfo == timezone.utc
  154. assert result > now
  155. class TestPruneBackups:
  156. """Tests for backup retention pruning."""
  157. def test_prune_keeps_retention_count(self, tmp_path):
  158. service = LocalBackupService()
  159. # Create 5 backup files
  160. for i in range(5):
  161. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  162. f.write_text(f"backup{i}")
  163. service._prune_backups(tmp_path, retention=3)
  164. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  165. assert len(remaining) == 3
  166. def test_prune_noop_when_under_retention(self, tmp_path):
  167. service = LocalBackupService()
  168. for i in range(2):
  169. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  170. f.write_text(f"backup{i}")
  171. service._prune_backups(tmp_path, retention=5)
  172. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  173. assert len(remaining) == 2
  174. def test_prune_only_touches_matching_files(self, tmp_path):
  175. service = LocalBackupService()
  176. # Create backup files and a non-backup file
  177. for i in range(3):
  178. f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
  179. f.write_text(f"backup{i}")
  180. other = tmp_path / "other_file.txt"
  181. other.write_text("keep me")
  182. service._prune_backups(tmp_path, retention=1)
  183. assert other.exists()
  184. remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
  185. assert len(remaining) == 1
  186. class TestResolveBackupFile:
  187. """Tests for backup file resolution with path traversal protection."""
  188. def test_valid_filename(self, tmp_path):
  189. service = LocalBackupService()
  190. f = tmp_path / "bambuddy-backup-20260412-120000.zip"
  191. f.write_text("data")
  192. result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  193. assert result == f
  194. def test_path_traversal_blocked(self, tmp_path):
  195. service = LocalBackupService()
  196. result = service.resolve_backup_file(str(tmp_path), "../etc/passwd")
  197. assert result is None
  198. def test_backslash_blocked(self, tmp_path):
  199. service = LocalBackupService()
  200. result = service.resolve_backup_file(str(tmp_path), "..\\etc\\passwd")
  201. assert result is None
  202. def test_dotdot_blocked(self, tmp_path):
  203. service = LocalBackupService()
  204. result = service.resolve_backup_file(str(tmp_path), "..bambuddy-backup.zip")
  205. assert result is None
  206. def test_wrong_prefix_blocked(self, tmp_path):
  207. service = LocalBackupService()
  208. f = tmp_path / "evil-file.zip"
  209. f.write_text("data")
  210. result = service.resolve_backup_file(str(tmp_path), "evil-file.zip")
  211. assert result is None
  212. def test_nonexistent_file(self, tmp_path):
  213. service = LocalBackupService()
  214. result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  215. assert result is None
  216. class TestDeleteBackup:
  217. """Tests for backup deletion."""
  218. def test_delete_valid_backup(self, tmp_path):
  219. service = LocalBackupService()
  220. f = tmp_path / "bambuddy-backup-20260412-120000.zip"
  221. f.write_text("data")
  222. result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  223. assert result["success"] is True
  224. assert not f.exists()
  225. def test_delete_nonexistent_backup(self, tmp_path):
  226. service = LocalBackupService()
  227. result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
  228. assert result["success"] is False
  229. def test_delete_path_traversal_blocked(self, tmp_path):
  230. service = LocalBackupService()
  231. result = service.delete_backup(str(tmp_path), "../important.zip")
  232. assert result["success"] is False
  233. class TestListBackups:
  234. """Tests for backup listing."""
  235. def test_list_empty_dir(self, tmp_path):
  236. service = LocalBackupService()
  237. result = service.list_backups(str(tmp_path))
  238. assert result == []
  239. def test_list_nonexistent_dir(self):
  240. service = LocalBackupService()
  241. result = service.list_backups("/nonexistent/path/12345")
  242. assert result == []
  243. def test_list_only_matching_files(self, tmp_path):
  244. service = LocalBackupService()
  245. (tmp_path / "bambuddy-backup-20260412-120000.zip").write_text("a")
  246. (tmp_path / "bambuddy-backup-20260412-130000.zip").write_text("bb")
  247. (tmp_path / "other-file.txt").write_text("ccc")
  248. result = service.list_backups(str(tmp_path))
  249. assert len(result) == 2
  250. assert all(r["filename"].startswith("bambuddy-backup-") for r in result)
  251. def test_list_sorted_newest_first(self, tmp_path):
  252. import time
  253. service = LocalBackupService()
  254. f1 = tmp_path / "bambuddy-backup-20260412-120000.zip"
  255. f1.write_text("a")
  256. time.sleep(0.05)
  257. f2 = tmp_path / "bambuddy-backup-20260412-130000.zip"
  258. f2.write_text("b")
  259. result = service.list_backups(str(tmp_path))
  260. assert result[0]["filename"] == "bambuddy-backup-20260412-130000.zip"
  261. def test_list_includes_size(self, tmp_path):
  262. service = LocalBackupService()
  263. (tmp_path / "bambuddy-backup-20260412-120000.zip").write_bytes(b"x" * 1024)
  264. result = service.list_backups(str(tmp_path))
  265. assert result[0]["size"] == 1024
  266. class TestGetStatus:
  267. """Tests for status reporting."""
  268. def test_initial_status(self):
  269. service = LocalBackupService()
  270. status = service.get_status()
  271. assert status["is_running"] is False
  272. assert status["last_backup_at"] is None
  273. assert status["last_status"] is None
  274. assert status["next_run"] is None