test_github_backup_api.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. """Integration tests for GitHub Backup API endpoints."""
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from httpx import AsyncClient
  5. @pytest.fixture(autouse=True)
  6. def _mock_private_repo_check():
  7. """Default mock: test_connection returns success + confirmed private.
  8. POST /config and PATCH /config now refuse to save when the target repo
  9. isn't confirmed private (Bambuddy backups carry credentials — see
  10. `_enforce_private_repo` in github_backup.py routes). The default mock
  11. here keeps the existing test suite green; tests that need to exercise
  12. the public / unknown-visibility branches override this fixture inline.
  13. """
  14. with patch(
  15. "backend.app.services.github_backup.github_backup_service.test_connection",
  16. new=AsyncMock(
  17. return_value={
  18. "success": True,
  19. "message": "Connection successful",
  20. "repo_name": "test/repo",
  21. "permissions": {"push": True},
  22. "is_private": True,
  23. }
  24. ),
  25. ) as m:
  26. yield m
  27. class TestGitHubBackupConfigAPI:
  28. """Integration tests for /api/v1/github-backup endpoints."""
  29. @pytest.mark.asyncio
  30. @pytest.mark.integration
  31. async def test_get_config_no_config(self, async_client: AsyncClient):
  32. """Verify getting config when none exists returns null."""
  33. response = await async_client.get("/api/v1/github-backup/config")
  34. assert response.status_code == 200
  35. assert response.json() is None
  36. @pytest.mark.asyncio
  37. @pytest.mark.integration
  38. async def test_create_config(self, async_client: AsyncClient):
  39. """Verify GitHub backup config can be created."""
  40. data = {
  41. "repository_url": "https://github.com/test/repo",
  42. "access_token": "ghp_testtoken123",
  43. "branch": "main",
  44. "schedule_enabled": False,
  45. "schedule_type": "daily",
  46. "backup_kprofiles": True,
  47. "backup_cloud_profiles": True,
  48. "backup_settings": False,
  49. "backup_spools": False,
  50. "backup_archives": False,
  51. "enabled": True,
  52. }
  53. response = await async_client.post("/api/v1/github-backup/config", json=data)
  54. assert response.status_code == 200
  55. result = response.json()
  56. assert result["repository_url"] == "https://github.com/test/repo"
  57. assert result["branch"] == "main"
  58. assert result["has_token"] is True
  59. assert result["enabled"] is True
  60. assert result["backup_spools"] is False
  61. assert result["backup_archives"] is False
  62. # Token should not be exposed in response
  63. assert "access_token" not in result
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_get_config_after_create(self, async_client: AsyncClient):
  67. """Verify getting config after creation returns the config."""
  68. # Create config first
  69. data = {
  70. "repository_url": "https://github.com/test/getrepo",
  71. "access_token": "ghp_testtoken456",
  72. "branch": "develop",
  73. "schedule_enabled": True,
  74. "schedule_type": "weekly",
  75. "backup_kprofiles": True,
  76. "backup_cloud_profiles": False,
  77. "backup_settings": True,
  78. "enabled": True,
  79. }
  80. await async_client.post("/api/v1/github-backup/config", json=data)
  81. # Get config
  82. response = await async_client.get("/api/v1/github-backup/config")
  83. assert response.status_code == 200
  84. result = response.json()
  85. assert result is not None
  86. assert result["repository_url"] == "https://github.com/test/getrepo"
  87. assert result["branch"] == "develop"
  88. assert result["schedule_type"] == "weekly"
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_create_config_with_spools_and_archives(self, async_client: AsyncClient):
  92. """Verify config with spool and archive backup enabled."""
  93. data = {
  94. "repository_url": "https://github.com/test/spoolarchive",
  95. "access_token": "ghp_spooltoken",
  96. "branch": "main",
  97. "schedule_enabled": False,
  98. "schedule_type": "daily",
  99. "backup_kprofiles": True,
  100. "backup_cloud_profiles": False,
  101. "backup_settings": False,
  102. "backup_spools": True,
  103. "backup_archives": True,
  104. "enabled": True,
  105. }
  106. response = await async_client.post("/api/v1/github-backup/config", json=data)
  107. assert response.status_code == 200
  108. result = response.json()
  109. assert result["backup_spools"] is True
  110. assert result["backup_archives"] is True
  111. assert result["backup_cloud_profiles"] is False
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_update_config_partial(self, async_client: AsyncClient):
  115. """Verify partial update of GitHub backup config."""
  116. # Create config first
  117. create_data = {
  118. "repository_url": "https://github.com/test/update",
  119. "access_token": "ghp_token",
  120. "branch": "main",
  121. "schedule_enabled": False,
  122. "schedule_type": "daily",
  123. "backup_kprofiles": True,
  124. "backup_cloud_profiles": True,
  125. "backup_settings": False,
  126. "backup_spools": False,
  127. "backup_archives": False,
  128. "enabled": True,
  129. }
  130. await async_client.post("/api/v1/github-backup/config", json=create_data)
  131. # Partial update
  132. update_data = {
  133. "branch": "develop",
  134. "schedule_enabled": True,
  135. }
  136. response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
  137. assert response.status_code == 200
  138. result = response.json()
  139. assert result["branch"] == "develop"
  140. assert result["schedule_enabled"] is True
  141. # Original values should be preserved
  142. assert result["repository_url"] == "https://github.com/test/update"
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_update_config_enable_spools_and_archives(self, async_client: AsyncClient):
  146. """Verify partial update can enable spool and archive backup."""
  147. # Create config first
  148. create_data = {
  149. "repository_url": "https://github.com/test/updatetoggle",
  150. "access_token": "ghp_toggletoken",
  151. "branch": "main",
  152. "schedule_enabled": False,
  153. "schedule_type": "daily",
  154. "backup_kprofiles": True,
  155. "backup_cloud_profiles": True,
  156. "backup_settings": False,
  157. "backup_spools": False,
  158. "backup_archives": False,
  159. "enabled": True,
  160. }
  161. await async_client.post("/api/v1/github-backup/config", json=create_data)
  162. # Enable spools and archives via partial update
  163. update_data = {
  164. "backup_spools": True,
  165. "backup_archives": True,
  166. }
  167. response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
  168. assert response.status_code == 200
  169. result = response.json()
  170. assert result["backup_spools"] is True
  171. assert result["backup_archives"] is True
  172. # Other values preserved
  173. assert result["backup_kprofiles"] is True
  174. assert result["backup_settings"] is False
  175. @pytest.mark.asyncio
  176. @pytest.mark.integration
  177. async def test_update_config_rejects_disabling_insecure_http_for_stored_http_url(self, async_client: AsyncClient):
  178. """Verify PATCH rejects leaving a stored HTTP URL without explicit insecure-HTTP allowance."""
  179. create_data = {
  180. "repository_url": "http://git.example.com/test/httprepo",
  181. "access_token": "gitea_token",
  182. "branch": "main",
  183. "provider": "gitea",
  184. "allow_insecure_http": True,
  185. "schedule_enabled": False,
  186. "schedule_type": "daily",
  187. "backup_kprofiles": True,
  188. "backup_cloud_profiles": True,
  189. "backup_settings": False,
  190. "backup_spools": False,
  191. "backup_archives": False,
  192. "enabled": True,
  193. }
  194. create_response = await async_client.post("/api/v1/github-backup/config", json=create_data)
  195. assert create_response.status_code == 200
  196. response = await async_client.patch("/api/v1/github-backup/config", json={"allow_insecure_http": False})
  197. assert response.status_code == 422
  198. assert "Allow insecure HTTP" in response.json()["detail"]
  199. stored_response = await async_client.get("/api/v1/github-backup/config")
  200. assert stored_response.status_code == 200
  201. stored = stored_response.json()
  202. assert stored["repository_url"] == "http://git.example.com/test/httprepo"
  203. assert stored["allow_insecure_http"] is True
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_delete_config(self, async_client: AsyncClient):
  207. """Verify GitHub backup config can be deleted."""
  208. # Create config first
  209. create_data = {
  210. "repository_url": "https://github.com/test/delete",
  211. "access_token": "ghp_deletetoken",
  212. "branch": "main",
  213. "schedule_enabled": False,
  214. "schedule_type": "daily",
  215. "backup_kprofiles": True,
  216. "backup_cloud_profiles": True,
  217. "backup_settings": False,
  218. "enabled": True,
  219. }
  220. await async_client.post("/api/v1/github-backup/config", json=create_data)
  221. # Delete
  222. response = await async_client.delete("/api/v1/github-backup/config")
  223. assert response.status_code == 200
  224. # Verify it's deleted
  225. get_response = await async_client.get("/api/v1/github-backup/config")
  226. assert get_response.status_code == 200
  227. assert get_response.json() is None
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_delete_config_not_found(self, async_client: AsyncClient):
  231. """Verify deleting non-existent config returns 404."""
  232. # Make sure no config exists
  233. await async_client.delete("/api/v1/github-backup/config")
  234. # Try to delete again
  235. response = await async_client.delete("/api/v1/github-backup/config")
  236. assert response.status_code == 404
  237. class TestGitHubBackupPrivateRepoGuard:
  238. """Refuse to save a config when the target repository is not private.
  239. Bambuddy backups contain MQTT credentials, HA/Prometheus tokens, the
  240. Bambu Cloud email, and printer access codes via K-profiles — they must
  241. never be pushed to a public or internal-visibility repository.
  242. """
  243. @pytest.mark.asyncio
  244. @pytest.mark.integration
  245. async def test_create_config_rejects_public_repo(self, async_client: AsyncClient):
  246. """POST /config returns 400 when the connection test reports is_private=False."""
  247. with patch(
  248. "backend.app.services.github_backup.github_backup_service.test_connection",
  249. new=AsyncMock(
  250. return_value={
  251. "success": True,
  252. "message": "Connection successful",
  253. "repo_name": "test/public-repo",
  254. "permissions": {"push": True},
  255. "is_private": False,
  256. }
  257. ),
  258. ):
  259. response = await async_client.post(
  260. "/api/v1/github-backup/config",
  261. json={
  262. "repository_url": "https://github.com/test/public-repo",
  263. "access_token": "ghp_token",
  264. "branch": "main",
  265. "schedule_enabled": False,
  266. "schedule_type": "daily",
  267. "backup_kprofiles": True,
  268. "backup_cloud_profiles": True,
  269. "backup_settings": True,
  270. "enabled": True,
  271. },
  272. )
  273. assert response.status_code == 400
  274. assert "not private" in response.json()["detail"].lower()
  275. @pytest.mark.asyncio
  276. @pytest.mark.integration
  277. async def test_create_config_rejects_unknown_visibility(self, async_client: AsyncClient):
  278. """POST /config returns 400 when is_private cannot be determined (None)."""
  279. with patch(
  280. "backend.app.services.github_backup.github_backup_service.test_connection",
  281. new=AsyncMock(
  282. return_value={
  283. "success": True,
  284. "message": "Connection successful",
  285. "repo_name": "test/repo",
  286. "permissions": {"push": True},
  287. "is_private": None,
  288. }
  289. ),
  290. ):
  291. response = await async_client.post(
  292. "/api/v1/github-backup/config",
  293. json={
  294. "repository_url": "https://github.com/test/repo",
  295. "access_token": "ghp_token",
  296. "branch": "main",
  297. "schedule_enabled": False,
  298. "schedule_type": "daily",
  299. "backup_kprofiles": True,
  300. "backup_cloud_profiles": True,
  301. "backup_settings": True,
  302. "enabled": True,
  303. },
  304. )
  305. assert response.status_code == 400
  306. assert "could not confirm" in response.json()["detail"].lower()
  307. @pytest.mark.asyncio
  308. @pytest.mark.integration
  309. async def test_create_config_rejects_failed_connection(self, async_client: AsyncClient):
  310. """POST /config returns 400 when the connection test itself fails."""
  311. with patch(
  312. "backend.app.services.github_backup.github_backup_service.test_connection",
  313. new=AsyncMock(
  314. return_value={
  315. "success": False,
  316. "message": "Invalid access token",
  317. "repo_name": None,
  318. "permissions": None,
  319. "is_private": None,
  320. }
  321. ),
  322. ):
  323. response = await async_client.post(
  324. "/api/v1/github-backup/config",
  325. json={
  326. "repository_url": "https://github.com/test/repo",
  327. "access_token": "bad-token",
  328. "branch": "main",
  329. "schedule_enabled": False,
  330. "schedule_type": "daily",
  331. "backup_kprofiles": True,
  332. "backup_cloud_profiles": True,
  333. "backup_settings": True,
  334. "enabled": True,
  335. },
  336. )
  337. assert response.status_code == 400
  338. assert "invalid access token" in response.json()["detail"].lower()
  339. @pytest.mark.asyncio
  340. @pytest.mark.integration
  341. async def test_patch_rejects_url_change_to_public_repo(self, async_client: AsyncClient):
  342. """Changing the repository_url on an existing config re-checks privacy."""
  343. # Initial create succeeds via the default autouse mock (private).
  344. await async_client.post(
  345. "/api/v1/github-backup/config",
  346. json={
  347. "repository_url": "https://github.com/test/private-repo",
  348. "access_token": "ghp_token",
  349. "branch": "main",
  350. "schedule_enabled": False,
  351. "schedule_type": "daily",
  352. "backup_kprofiles": True,
  353. "backup_cloud_profiles": True,
  354. "backup_settings": True,
  355. "enabled": True,
  356. },
  357. )
  358. # Now try to switch to a public repo — must be rejected.
  359. with patch(
  360. "backend.app.services.github_backup.github_backup_service.test_connection",
  361. new=AsyncMock(
  362. return_value={
  363. "success": True,
  364. "message": "Connection successful",
  365. "repo_name": "test/public-repo",
  366. "permissions": {"push": True},
  367. "is_private": False,
  368. }
  369. ),
  370. ):
  371. response = await async_client.patch(
  372. "/api/v1/github-backup/config",
  373. json={"repository_url": "https://github.com/test/public-repo"},
  374. )
  375. assert response.status_code == 400
  376. assert "not private" in response.json()["detail"].lower()
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_patch_skips_check_for_unrelated_fields(self, async_client: AsyncClient):
  380. """PATCHing a non-target field (e.g. schedule) does NOT re-run the test.
  381. Without this, every benign toggle would trigger a live API call.
  382. """
  383. await async_client.post(
  384. "/api/v1/github-backup/config",
  385. json={
  386. "repository_url": "https://github.com/test/private-repo",
  387. "access_token": "ghp_token",
  388. "branch": "main",
  389. "schedule_enabled": False,
  390. "schedule_type": "daily",
  391. "backup_kprofiles": True,
  392. "backup_cloud_profiles": True,
  393. "backup_settings": True,
  394. "enabled": True,
  395. },
  396. )
  397. # Replace the mock with one that would fail if called — proves the
  398. # PATCH didn't hit test_connection for a schedule-only change.
  399. mock = AsyncMock(side_effect=AssertionError("test_connection should not be called"))
  400. with patch(
  401. "backend.app.services.github_backup.github_backup_service.test_connection",
  402. new=mock,
  403. ):
  404. response = await async_client.patch(
  405. "/api/v1/github-backup/config",
  406. json={"schedule_enabled": True},
  407. )
  408. assert response.status_code == 200
  409. mock.assert_not_called()
  410. class TestGitHubBackupStatusAPI:
  411. """Integration tests for /api/v1/github-backup/status endpoint."""
  412. @pytest.mark.asyncio
  413. @pytest.mark.integration
  414. async def test_status_no_config(self, async_client: AsyncClient):
  415. """Verify status when no config exists."""
  416. # Ensure no config
  417. await async_client.delete("/api/v1/github-backup/config")
  418. response = await async_client.get("/api/v1/github-backup/status")
  419. assert response.status_code == 200
  420. result = response.json()
  421. assert result["configured"] is False
  422. assert result["enabled"] is False
  423. assert result["is_running"] is False
  424. @pytest.mark.asyncio
  425. @pytest.mark.integration
  426. async def test_status_with_config(self, async_client: AsyncClient):
  427. """Verify status when config exists."""
  428. # Create config
  429. create_data = {
  430. "repository_url": "https://github.com/test/status",
  431. "access_token": "ghp_statustoken",
  432. "branch": "main",
  433. "schedule_enabled": True,
  434. "schedule_type": "hourly",
  435. "backup_kprofiles": True,
  436. "backup_cloud_profiles": True,
  437. "backup_settings": False,
  438. "enabled": True,
  439. }
  440. await async_client.post("/api/v1/github-backup/config", json=create_data)
  441. response = await async_client.get("/api/v1/github-backup/status")
  442. assert response.status_code == 200
  443. result = response.json()
  444. assert result["configured"] is True
  445. assert result["enabled"] is True
  446. assert result["is_running"] is False
  447. assert result["next_scheduled_run"] is not None
  448. class TestGitHubBackupLogsAPI:
  449. """Integration tests for /api/v1/github-backup/logs endpoints."""
  450. @pytest.mark.asyncio
  451. @pytest.mark.integration
  452. async def test_logs_no_config(self, async_client: AsyncClient):
  453. """Verify getting logs when no config exists returns empty list."""
  454. # Ensure no config
  455. await async_client.delete("/api/v1/github-backup/config")
  456. response = await async_client.get("/api/v1/github-backup/logs")
  457. assert response.status_code == 200
  458. assert response.json() == []
  459. @pytest.mark.asyncio
  460. @pytest.mark.integration
  461. async def test_logs_with_config(self, async_client: AsyncClient):
  462. """Verify getting logs with config."""
  463. # Create config
  464. create_data = {
  465. "repository_url": "https://github.com/test/logs",
  466. "access_token": "ghp_logstoken",
  467. "branch": "main",
  468. "schedule_enabled": False,
  469. "schedule_type": "daily",
  470. "backup_kprofiles": True,
  471. "backup_cloud_profiles": True,
  472. "backup_settings": False,
  473. "enabled": True,
  474. }
  475. await async_client.post("/api/v1/github-backup/config", json=create_data)
  476. response = await async_client.get("/api/v1/github-backup/logs")
  477. assert response.status_code == 200
  478. # No backups run yet, so empty list
  479. assert response.json() == []
  480. class TestGitHubBackupTriggerAPI:
  481. """Integration tests for /api/v1/github-backup/run endpoint."""
  482. @pytest.mark.asyncio
  483. @pytest.mark.integration
  484. async def test_trigger_no_config(self, async_client: AsyncClient):
  485. """Verify triggering backup without config returns 404."""
  486. # Ensure no config
  487. await async_client.delete("/api/v1/github-backup/config")
  488. response = await async_client.post("/api/v1/github-backup/run")
  489. assert response.status_code == 404
  490. @pytest.mark.asyncio
  491. @pytest.mark.integration
  492. async def test_trigger_disabled_config(self, async_client: AsyncClient):
  493. """Verify triggering backup with disabled config returns 400."""
  494. # Create disabled config
  495. create_data = {
  496. "repository_url": "https://github.com/test/trigger",
  497. "access_token": "ghp_triggertoken",
  498. "branch": "main",
  499. "schedule_enabled": False,
  500. "schedule_type": "daily",
  501. "backup_kprofiles": True,
  502. "backup_cloud_profiles": True,
  503. "backup_settings": False,
  504. "enabled": False, # Disabled
  505. }
  506. await async_client.post("/api/v1/github-backup/config", json=create_data)
  507. response = await async_client.post("/api/v1/github-backup/run")
  508. assert response.status_code == 400
  509. assert "disabled" in response.json()["detail"].lower()