test_github_restore_api.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. """Integration tests for the Git backup restore API endpoints (#2656)."""
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from httpx import AsyncClient
  5. from sqlalchemy import select
  6. from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
  7. @pytest.fixture(autouse=True)
  8. def _mock_private_repo_check():
  9. """POST /config refuses to save unless the repo is confirmed private."""
  10. with patch(
  11. "backend.app.services.github_backup.github_backup_service.test_connection",
  12. new=AsyncMock(
  13. return_value={
  14. "success": True,
  15. "message": "Connection successful",
  16. "repo_name": "test/repo",
  17. "permissions": {"push": True},
  18. "is_private": True,
  19. }
  20. ),
  21. ) as m:
  22. yield m
  23. async def _create_config(async_client: AsyncClient, token: str | None = None) -> dict:
  24. response = await async_client.post(
  25. "/api/v1/github-backup/config",
  26. headers={"Authorization": f"Bearer {token}"} if token else {},
  27. json={
  28. "repository_url": "https://github.com/test/repo",
  29. "access_token": "ghp_testtoken123",
  30. "branch": "main",
  31. "backup_kprofiles": True,
  32. "backup_spools": True,
  33. "backup_archives": True,
  34. "backup_settings": True,
  35. "enabled": True,
  36. },
  37. )
  38. assert response.status_code == 200
  39. return response.json()
  40. class TestCommitsEndpoint:
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_404_when_not_configured(self, async_client: AsyncClient):
  44. response = await async_client.get("/api/v1/github-backup/commits")
  45. assert response.status_code == 404
  46. assert "Configure backup first" in response.json()["detail"]
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
  50. await _create_config(async_client)
  51. commits = [
  52. {"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
  53. ]
  54. with patch(
  55. "backend.app.services.git_providers.github.GitHubBackend.list_commits",
  56. new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
  57. ):
  58. response = await async_client.get("/api/v1/github-backup/commits")
  59. assert response.status_code == 200
  60. body = response.json()
  61. assert body["success"] is True
  62. assert body["branch"] == "main"
  63. assert body["commits"][0]["sha"] == "aaa1111"
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
  67. await _create_config(async_client)
  68. with patch(
  69. "backend.app.services.git_providers.github.GitHubBackend.list_commits",
  70. new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
  71. ):
  72. response = await async_client.get("/api/v1/github-backup/commits")
  73. assert response.status_code == 200
  74. assert response.json()["success"] is False
  75. assert response.json()["commits"] == []
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_limit_is_bounded(self, async_client: AsyncClient):
  79. await _create_config(async_client)
  80. assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
  81. assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
  82. class TestPreviewEndpoint:
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_404_when_not_configured(self, async_client: AsyncClient):
  86. response = await async_client.get("/api/v1/github-backup/restore/preview")
  87. assert response.status_code == 404
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
  91. await _create_config(async_client)
  92. preview = {
  93. "success": True,
  94. "message": "OK",
  95. "ref": "aaa1111",
  96. "commit": None,
  97. "metadata_version": "1.0",
  98. "categories": [
  99. {"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
  100. {"category": "settings", "available": True, "item_count": 12, "detail": None},
  101. {"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
  102. {"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
  103. ],
  104. }
  105. with patch(
  106. "backend.app.services.github_restore.github_restore_service.preview",
  107. new=AsyncMock(return_value=preview),
  108. ):
  109. response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
  110. assert response.status_code == 200
  111. body = response.json()
  112. assert body["metadata_version"] == "1.0"
  113. by_name = {c["category"]: c for c in body["categories"]}
  114. assert by_name["kprofiles"]["available"] is False
  115. assert by_name["spools"]["item_count"] == 4
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. @pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
  119. async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
  120. await _create_config(async_client)
  121. response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
  122. assert response.status_code == 422
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_defaults_to_head(self, async_client: AsyncClient):
  126. await _create_config(async_client)
  127. mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
  128. with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
  129. response = await async_client.get("/api/v1/github-backup/restore/preview")
  130. assert response.status_code == 200
  131. assert mock.await_args.kwargs["ref"] == "HEAD"
  132. class TestRestoreEndpoint:
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_404_when_not_configured(self, async_client: AsyncClient):
  136. response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
  137. assert response.status_code == 404
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_applies_selected_categories(self, async_client: AsyncClient):
  141. await _create_config(async_client)
  142. outcome = {
  143. "success": True,
  144. "message": "Restored 5 item(s) from aaa1111",
  145. "log_id": 3,
  146. "ref": "aaa1111",
  147. "results": {
  148. "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
  149. "settings": {
  150. "restored": 1,
  151. "skipped": 2,
  152. "failed": 0,
  153. "notes": [
  154. {
  155. "code": "settingsCredentialsSkipped",
  156. "params": {"count": 1},
  157. "message": "1 credential-like key(s) skipped",
  158. }
  159. ],
  160. },
  161. },
  162. }
  163. with patch(
  164. "backend.app.services.github_restore.github_restore_service.run_restore",
  165. new=AsyncMock(return_value=outcome),
  166. ) as mock:
  167. response = await async_client.post(
  168. "/api/v1/github-backup/restore",
  169. json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
  170. )
  171. assert response.status_code == 200
  172. body = response.json()
  173. assert body["results"]["spools"]["restored"] == 4
  174. # Notes cross the wire as code + params + English fallback, so a
  175. # non-English client can translate them (#2656).
  176. assert body["results"]["settings"]["notes"] == [
  177. {
  178. "code": "settingsCredentialsSkipped",
  179. "params": {"count": 1},
  180. "message": "1 credential-like key(s) skipped",
  181. }
  182. ]
  183. assert mock.await_args.kwargs["overwrite_existing"] is True
  184. assert mock.await_args.kwargs["ref"] == "aaa1111"
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_rejects_empty_category_list(self, async_client: AsyncClient):
  188. await _create_config(async_client)
  189. response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
  190. assert response.status_code == 422
  191. @pytest.mark.asyncio
  192. @pytest.mark.integration
  193. async def test_rejects_unknown_category(self, async_client: AsyncClient):
  194. await _create_config(async_client)
  195. response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
  196. assert response.status_code == 422
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_rejects_malformed_ref(self, async_client: AsyncClient):
  200. await _create_config(async_client)
  201. response = await async_client.post(
  202. "/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
  203. )
  204. assert response.status_code == 422
  205. @pytest.mark.asyncio
  206. @pytest.mark.integration
  207. async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
  208. """The safe default: a restore only inserts what's missing."""
  209. await _create_config(async_client)
  210. mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
  211. with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
  212. response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
  213. assert response.status_code == 200
  214. assert mock.await_args.kwargs["overwrite_existing"] is False
  215. @pytest.mark.asyncio
  216. @pytest.mark.integration
  217. async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
  218. await _create_config(async_client)
  219. with patch(
  220. "backend.app.services.github_restore.github_restore_service.run_restore",
  221. new=AsyncMock(
  222. return_value={
  223. "success": False,
  224. "message": "A backup is currently running. Wait for it to finish before restoring.",
  225. "results": {},
  226. }
  227. ),
  228. ):
  229. response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
  230. assert response.status_code == 200
  231. assert response.json()["success"] is False
  232. assert "backup is currently running" in response.json()["message"]
  233. class TestStatusExposesRestoreState:
  234. @pytest.mark.asyncio
  235. @pytest.mark.integration
  236. async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
  237. await _create_config(async_client)
  238. response = await async_client.get("/api/v1/github-backup/status")
  239. assert response.status_code == 200
  240. assert response.json()["restore_running"] is False
  241. @pytest.mark.asyncio
  242. @pytest.mark.integration
  243. async def test_restore_running_is_reported(self, async_client: AsyncClient):
  244. """The UI disables both action buttons off this flag."""
  245. await _create_config(async_client)
  246. from backend.app.services.github_restore import github_restore_service
  247. github_restore_service._running_restore = True
  248. github_restore_service._progress = "Restoring spool inventory..."
  249. try:
  250. response = await async_client.get("/api/v1/github-backup/status")
  251. finally:
  252. github_restore_service._running_restore = False
  253. github_restore_service._progress = None
  254. assert response.json()["restore_running"] is True
  255. assert response.json()["progress"] == "Restoring spool inventory..."
  256. @pytest.mark.asyncio
  257. @pytest.mark.integration
  258. async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
  259. response = await async_client.get("/api/v1/github-backup/status")
  260. assert response.status_code == 200
  261. assert response.json()["restore_running"] is False
  262. class TestRestoredArchivesAreVisibleToTheirOwner(TestOwnershipPermissionsSetup):
  263. """The archive-ownership blocker, proved through the route that enforces it.
  264. ``_ensure_archive_visible`` fails closed on a NULL ``created_by_id`` — 404 for
  265. any caller without ``archives:read_all`` — so before the collector and the
  266. restore carried the column across, a multi-user instance got archives the
  267. tally called restored and their owner could not open.
  268. """
  269. @pytest.mark.asyncio
  270. @pytest.mark.integration
  271. async def test_the_owning_non_admin_can_open_a_restored_archive(
  272. self, async_client: AsyncClient, auth_setup, db_session
  273. ):
  274. from backend.app.models.archive import PrintArchive
  275. from backend.app.services.github_restore import _CategoryTally, github_restore_service
  276. owner_id = auth_setup["operator_user"]["id"]
  277. payload = {
  278. "archives": [
  279. {
  280. "id": 77,
  281. "filename": "benchy.3mf",
  282. "file_size": 2048,
  283. "content_hash": "abc123",
  284. "print_name": "Benchy",
  285. "started_at": "2026-03-01 10:00:00",
  286. "created_at": "2026-03-01 10:00:00",
  287. "created_by_id": owner_id,
  288. }
  289. ]
  290. }
  291. await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
  292. await db_session.commit()
  293. restored = (await db_session.execute(select(PrintArchive))).scalar_one()
  294. assert restored.id != 77, "the backup's primary key must not be reused"
  295. response = await async_client.get(
  296. f"/api/v1/archives/{restored.id}",
  297. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  298. )
  299. assert response.status_code == 200, "the owner cannot see their own restored archive"
  300. assert response.json()["print_name"] == "Benchy"
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_a_different_operator_still_cannot(self, async_client: AsyncClient, auth_setup, db_session):
  304. """Control: carrying the owner across must not widen who can read it."""
  305. from backend.app.models.archive import PrintArchive
  306. from backend.app.services.github_restore import _CategoryTally, github_restore_service
  307. payload = {
  308. "archives": [
  309. {
  310. "id": 77,
  311. "filename": "benchy.3mf",
  312. "file_size": 2048,
  313. "content_hash": "abc123",
  314. "started_at": "2026-03-01 10:00:00",
  315. "created_by_id": auth_setup["operator_user"]["id"],
  316. }
  317. ]
  318. }
  319. await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
  320. await db_session.commit()
  321. restored = (await db_session.execute(select(PrintArchive))).scalar_one()
  322. response = await async_client.get(
  323. f"/api/v1/archives/{restored.id}",
  324. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  325. )
  326. assert response.status_code == 404
  327. class TestRestoreDoesNotOpenTheMetricsEndpoint:
  328. """The companion-credential rule, proved against the endpoint it protects.
  329. ``/api/v1/metrics`` is on ``PUBLIC_API_ROUTES`` and its only gate is
  330. ``if token:``, so writing ``prometheus_enabled`` onto an instance with no
  331. ``prometheus_token`` row hands the entire metrics body to anyone who can
  332. reach the port. The restore refuses that token as credential-shaped, so
  333. before this change the pair came apart and the endpoint opened — with
  334. overwrite *off*, since the local row is missing rather than present.
  335. Driven through the real service and the real endpoint against one database:
  336. the unit tests can show the toggle is not written, only this can show what
  337. that means.
  338. """
  339. @pytest.mark.asyncio
  340. @pytest.mark.integration
  341. async def test_restoring_prometheus_enabled_leaves_the_endpoint_shut(self, async_client: AsyncClient, db_session):
  342. from backend.app.services.github_restore import _CategoryTally, github_restore_service
  343. # An instance that never enabled Prometheus: no toggle row, no token row.
  344. assert (await async_client.get("/api/v1/metrics")).status_code == 404
  345. tally = _CategoryTally()
  346. await github_restore_service._restore_settings(
  347. db_session,
  348. {"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret", "currency": "EUR"}},
  349. overwrite=False,
  350. tally=tally,
  351. )
  352. await db_session.commit()
  353. response = await async_client.get("/api/v1/metrics")
  354. assert response.status_code == 404, "a settings restore opened the metrics endpoint"
  355. assert "bambuddy_build_info" not in response.text
  356. assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
  357. @pytest.mark.asyncio
  358. @pytest.mark.integration
  359. async def test_an_instance_with_its_own_token_still_gets_the_toggle_back(
  360. self, async_client: AsyncClient, db_session
  361. ):
  362. """Control. The rule must not break a legitimate Prometheus restore."""
  363. from backend.app.services.github_restore import _CategoryTally, github_restore_service
  364. await async_client.put(
  365. "/api/v1/settings/", json={"prometheus_enabled": False, "prometheus_token": "local-token"}
  366. )
  367. await github_restore_service._restore_settings(
  368. db_session,
  369. {"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret"}},
  370. overwrite=True,
  371. tally=_CategoryTally(),
  372. )
  373. await db_session.commit()
  374. assert (await async_client.get("/api/v1/metrics")).status_code == 401
  375. authorised = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer local-token"})
  376. assert authorised.status_code == 200
  377. assert "bambuddy_build_info" in authorised.text
  378. class TestSettingsRestoreNeedsSettingsUpdate(TestOwnershipPermissionsSetup):
  379. """A Backup-only role must not reach around the gate that owns settings (#2656).
  380. The settings category rewrites arbitrary non-auth ``Settings`` rows, which is
  381. exactly what ``PUT /api/v1/settings/`` gates on ``settings:update``. Backup
  382. and Settings are separate permission groups, so gating the restore endpoint
  383. on ``github:restore`` alone let a role holding only Backup change settings it
  384. could not change through the endpoint that owns them. This module already
  385. makes that argument — it is why the four protected auth keys are refused
  386. outright — so the gap was an inconsistency in ours.
  387. """
  388. async def _token_for(self, async_client: AsyncClient, admin_token: str, name: str, permissions: list[str]) -> str:
  389. headers = {"Authorization": f"Bearer {admin_token}"}
  390. group = await async_client.post(
  391. "/api/v1/groups/",
  392. headers=headers,
  393. json={"name": name, "permissions": permissions},
  394. )
  395. assert group.status_code == 201, group.text
  396. created = await async_client.post(
  397. "/api/v1/users/",
  398. headers=headers,
  399. json={"username": name, "password": "Restorepass1!", "group_ids": [group.json()["id"]]},
  400. )
  401. assert created.status_code in (200, 201), created.text
  402. login = await async_client.post(
  403. "/api/v1/auth/login",
  404. json={"username": name, "password": "Restorepass1!"},
  405. )
  406. assert login.status_code == 200, login.text
  407. return login.json()["access_token"]
  408. @pytest.mark.asyncio
  409. @pytest.mark.integration
  410. async def test_backup_only_role_cannot_restore_settings(self, async_client: AsyncClient, auth_setup):
  411. token = await self._token_for(
  412. async_client, auth_setup["admin_token"], "backuponly", ["github:backup", "github:restore"]
  413. )
  414. await _create_config(async_client, auth_setup["admin_token"])
  415. with patch(
  416. "backend.app.services.github_restore.github_restore_service.run_restore",
  417. new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
  418. ) as mock:
  419. response = await async_client.post(
  420. "/api/v1/github-backup/restore",
  421. headers={"Authorization": f"Bearer {token}"},
  422. json={"categories": ["settings"]},
  423. )
  424. assert response.status_code == 403
  425. assert "settings:update" in response.json()["detail"]
  426. mock.assert_not_awaited(), "the refusal has to happen before anything is written"
  427. @pytest.mark.asyncio
  428. @pytest.mark.integration
  429. async def test_the_same_role_can_still_restore_the_other_categories(self, async_client: AsyncClient, auth_setup):
  430. """Control: the gate is per-category, not a blanket demotion of github:restore."""
  431. token = await self._token_for(
  432. async_client, auth_setup["admin_token"], "backuponly2", ["github:backup", "github:restore"]
  433. )
  434. await _create_config(async_client, auth_setup["admin_token"])
  435. with patch(
  436. "backend.app.services.github_restore.github_restore_service.run_restore",
  437. new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
  438. ):
  439. response = await async_client.post(
  440. "/api/v1/github-backup/restore",
  441. headers={"Authorization": f"Bearer {token}"},
  442. json={"categories": ["spools", "archives", "kprofiles"]},
  443. )
  444. assert response.status_code == 200
  445. @pytest.mark.asyncio
  446. @pytest.mark.integration
  447. async def test_a_role_holding_both_can_restore_settings(self, async_client: AsyncClient, auth_setup):
  448. """Control: the gate must not lock out a role that legitimately holds both."""
  449. token = await self._token_for(
  450. async_client,
  451. auth_setup["admin_token"],
  452. "backupandsettings",
  453. ["github:backup", "github:restore", "settings:read", "settings:update"],
  454. )
  455. await _create_config(async_client, auth_setup["admin_token"])
  456. with patch(
  457. "backend.app.services.github_restore.github_restore_service.run_restore",
  458. new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
  459. ):
  460. response = await async_client.post(
  461. "/api/v1/github-backup/restore",
  462. headers={"Authorization": f"Bearer {token}"},
  463. json={"categories": ["settings"]},
  464. )
  465. assert response.status_code == 200
  466. @pytest.mark.asyncio
  467. @pytest.mark.integration
  468. async def test_auth_disabled_is_unaffected(self, async_client: AsyncClient):
  469. """Control: with auth off there is no user to check, and the dep returns None."""
  470. await _create_config(async_client)
  471. with patch(
  472. "backend.app.services.github_restore.github_restore_service.run_restore",
  473. new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
  474. ):
  475. response = await async_client.post(
  476. "/api/v1/github-backup/restore",
  477. json={"categories": ["settings"]},
  478. )
  479. assert response.status_code == 200