test_git_providers_restore.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. """Unit tests for the git_providers read side used by restore (#2656).
  2. Covers list_commits / list_tree / fetch_files across all four providers,
  3. including that Gitea and Forgejo inherit GitHub's Git Data API implementation
  4. rather than needing their own.
  5. """
  6. import base64
  7. from unittest.mock import AsyncMock, MagicMock
  8. import pytest
  9. from backend.app.services.git_providers.forgejo import ForgejoBackend
  10. from backend.app.services.git_providers.gitea import GiteaBackend
  11. from backend.app.services.git_providers.github import GitHubBackend
  12. from backend.app.services.git_providers.gitlab import GitLabBackend
  13. def _make_mock_response(status_code: int, body=None, text: str = ""):
  14. resp = MagicMock()
  15. resp.status_code = status_code
  16. resp.text = text
  17. resp.json = MagicMock(return_value=body if body is not None else {})
  18. return resp
  19. def _b64(text: str) -> str:
  20. return base64.b64encode(text.encode("utf-8")).decode()
  21. def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"):
  22. return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}}
  23. class TestGitHubListCommits:
  24. def setup_method(self):
  25. self.backend = GitHubBackend()
  26. self.repo_url = "https://github.com/owner/repo"
  27. self.token = "ghp_token"
  28. @pytest.mark.asyncio
  29. async def test_returns_normalised_commits_newest_first(self):
  30. client = AsyncMock()
  31. client.get = AsyncMock(
  32. return_value=_make_mock_response(
  33. 200,
  34. [
  35. _github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"),
  36. _github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"),
  37. ],
  38. )
  39. )
  40. result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
  41. assert result["success"] is True
  42. assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"]
  43. assert result["commits"][0]["message"] == "Bambuddy backup - newest"
  44. assert result["commits"][0]["author"] == "Bambuddy"
  45. assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z"
  46. @pytest.mark.asyncio
  47. async def test_sends_both_per_page_and_limit(self):
  48. """GitHub honours per_page, Gitea honours limit — one call must carry both
  49. so GiteaBackend can inherit this method unchanged."""
  50. client = AsyncMock()
  51. client.get = AsyncMock(return_value=_make_mock_response(200, []))
  52. await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7)
  53. params = client.get.await_args.kwargs["params"]
  54. assert params["per_page"] == 7
  55. assert params["limit"] == 7
  56. assert params["sha"] == "main"
  57. @pytest.mark.asyncio
  58. async def test_respects_limit_even_if_provider_overshoots(self):
  59. client = AsyncMock()
  60. client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)]))
  61. result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3)
  62. assert len(result["commits"]) == 3
  63. @pytest.mark.asyncio
  64. async def test_404_explains_empty_repository(self):
  65. client = AsyncMock()
  66. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  67. result = await self.backend.list_commits(self.repo_url, self.token, "nope", client)
  68. assert result["success"] is False
  69. assert "no commits yet" in result["message"]
  70. assert result["commits"] == []
  71. @pytest.mark.asyncio
  72. async def test_skips_entries_without_a_sha(self):
  73. client = AsyncMock()
  74. client.get = AsyncMock(
  75. return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")])
  76. )
  77. result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
  78. assert [c["sha"] for c in result["commits"]] == ["good"]
  79. @pytest.mark.asyncio
  80. async def test_non_list_body_is_an_error_not_a_crash(self):
  81. client = AsyncMock()
  82. client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
  83. result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
  84. assert result["success"] is False
  85. assert "Unexpected shape" in result["message"]
  86. class TestGitHubListTree:
  87. def setup_method(self):
  88. self.backend = GitHubBackend()
  89. self.repo_url = "https://github.com/owner/repo"
  90. self.token = "ghp_token"
  91. @pytest.mark.asyncio
  92. async def test_returns_sorted_blob_paths_only(self):
  93. client = AsyncMock()
  94. client.get = AsyncMock(
  95. return_value=_make_mock_response(
  96. 200,
  97. {
  98. "tree": [
  99. {"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
  100. {"type": "tree", "path": "spools", "sha": "d1"},
  101. {"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
  102. ]
  103. },
  104. )
  105. )
  106. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  107. assert result["success"] is True
  108. assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
  109. @pytest.mark.asyncio
  110. async def test_truncated_tree_fails_loudly(self):
  111. """A truncated listing would make restore silently miss categories."""
  112. client = AsyncMock()
  113. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
  114. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  115. assert result["success"] is False
  116. assert "truncated" in result["message"]
  117. @pytest.mark.asyncio
  118. async def test_404_names_the_missing_ref(self):
  119. client = AsyncMock()
  120. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  121. result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
  122. assert result["success"] is False
  123. assert "deadbee" in result["message"]
  124. class TestGitHubFetchFiles:
  125. def setup_method(self):
  126. self.backend = GitHubBackend()
  127. self.repo_url = "https://github.com/owner/repo"
  128. self.token = "ghp_token"
  129. @pytest.mark.asyncio
  130. async def test_reads_requested_paths_via_blob_api(self):
  131. tree = _make_mock_response(
  132. 200,
  133. {
  134. "tree": [
  135. {"type": "blob", "path": "a.json", "sha": "sha-a"},
  136. {"type": "blob", "path": "b.json", "sha": "sha-b"},
  137. ]
  138. },
  139. )
  140. client = AsyncMock()
  141. client.get = AsyncMock(
  142. side_effect=[
  143. tree,
  144. _make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
  145. ]
  146. )
  147. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  148. assert result["success"] is True
  149. assert result["files"] == {"a.json": '{"a": 1}'}
  150. # One tree listing regardless of how many files are read.
  151. assert client.get.await_count == 2
  152. @pytest.mark.asyncio
  153. async def test_lists_the_tree_once_for_many_files(self):
  154. tree = _make_mock_response(
  155. 200,
  156. {
  157. "tree": [
  158. {"type": "blob", "path": "a.json", "sha": "sha-a"},
  159. {"type": "blob", "path": "b.json", "sha": "sha-b"},
  160. ]
  161. },
  162. )
  163. client = AsyncMock()
  164. client.get = AsyncMock(
  165. side_effect=[
  166. tree,
  167. _make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
  168. _make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
  169. ]
  170. )
  171. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
  172. assert result["files"] == {"a.json": "1", "b.json": "2"}
  173. assert client.get.await_count == 3
  174. @pytest.mark.asyncio
  175. async def test_missing_path_is_skipped_not_an_error(self):
  176. """Which categories a backup contains varies by config, so an absent
  177. path is expected rather than a failure."""
  178. client = AsyncMock()
  179. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
  180. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
  181. assert result["success"] is True
  182. assert result["files"] == {}
  183. @pytest.mark.asyncio
  184. async def test_blob_error_fails_the_whole_read(self):
  185. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  186. client = AsyncMock()
  187. client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
  188. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  189. assert result["success"] is False
  190. assert "a.json" in result["message"]
  191. assert result["files"] == {}
  192. @pytest.mark.asyncio
  193. async def test_utf8_content_survives_round_trip(self):
  194. payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
  195. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  196. client = AsyncMock()
  197. client.get = AsyncMock(
  198. side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
  199. )
  200. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  201. assert result["files"]["a.json"] == payload
  202. @pytest.mark.asyncio
  203. async def test_unsupported_encoding_is_reported(self):
  204. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  205. client = AsyncMock()
  206. client.get = AsyncMock(
  207. side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
  208. )
  209. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  210. assert result["success"] is False
  211. assert "Unsupported blob encoding" in result["message"]
  212. class TestGiteaAndForgejoInheritReads:
  213. """Gitea overrides the *write* path only; reads come from GitHubBackend."""
  214. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  215. def test_read_methods_are_not_overridden(self, backend_cls):
  216. for method in ("list_commits", "list_tree", "fetch_files"):
  217. assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
  218. @pytest.mark.asyncio
  219. async def test_gitea_list_commits_uses_its_own_api_base(self):
  220. backend = GiteaBackend()
  221. client = AsyncMock()
  222. client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
  223. result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
  224. assert result["success"] is True
  225. url = client.get.await_args.args[0]
  226. assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
  227. @pytest.mark.asyncio
  228. async def test_gitea_subpath_install_is_respected(self):
  229. """Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
  230. backend = GiteaBackend()
  231. client = AsyncMock()
  232. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
  233. await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
  234. url = client.get.await_args.args[0]
  235. assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
  236. class TestGitLabReads:
  237. def setup_method(self):
  238. self.backend = GitLabBackend()
  239. self.repo_url = "https://gitlab.com/owner/repo"
  240. self.token = "glpat-test"
  241. @pytest.mark.asyncio
  242. async def test_list_commits_reads_flattened_author_fields(self):
  243. """GitLab puts message/author/date on the entry, not under 'commit'."""
  244. client = AsyncMock()
  245. client.get = AsyncMock(
  246. return_value=_make_mock_response(
  247. 200,
  248. [
  249. {
  250. "id": "abc123",
  251. "message": "Bambuddy backup",
  252. "author_name": "Bambuddy",
  253. "committed_date": "2026-07-02T10:00:00Z",
  254. }
  255. ],
  256. )
  257. )
  258. result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
  259. assert result["success"] is True
  260. assert result["commits"] == [
  261. {
  262. "sha": "abc123",
  263. "message": "Bambuddy backup",
  264. "author": "Bambuddy",
  265. "date": "2026-07-02T10:00:00Z",
  266. }
  267. ]
  268. @pytest.mark.asyncio
  269. async def test_list_commits_uses_ref_name(self):
  270. client = AsyncMock()
  271. client.get = AsyncMock(return_value=_make_mock_response(200, []))
  272. await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
  273. params = client.get.await_args.kwargs["params"]
  274. assert params["ref_name"] == "bambuddy-backup"
  275. assert params["per_page"] == 5
  276. @pytest.mark.asyncio
  277. async def test_subgroup_path_is_url_encoded(self):
  278. client = AsyncMock()
  279. client.get = AsyncMock(return_value=_make_mock_response(200, []))
  280. await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
  281. url = client.get.await_args.args[0]
  282. assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
  283. @pytest.mark.asyncio
  284. async def test_list_tree_returns_blob_paths(self):
  285. client = AsyncMock()
  286. client.get = AsyncMock(
  287. return_value=_make_mock_response(
  288. 200,
  289. [
  290. {"type": "blob", "path": "spools/inventory.json"},
  291. {"type": "tree", "path": "spools"},
  292. ],
  293. )
  294. )
  295. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  296. assert result["success"] is True
  297. assert result["paths"] == ["spools/inventory.json"]
  298. @pytest.mark.asyncio
  299. async def test_list_tree_follows_pagination(self):
  300. """GitLab paginates instead of exposing a truncated flag."""
  301. full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
  302. client = AsyncMock()
  303. client.get = AsyncMock(
  304. side_effect=[
  305. _make_mock_response(200, full_page),
  306. _make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
  307. ]
  308. )
  309. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  310. assert client.get.await_count == 2
  311. assert len(result["paths"]) == 101
  312. assert "last.json" in result["paths"]
  313. @pytest.mark.asyncio
  314. async def test_fetch_files_decodes_base64(self):
  315. client = AsyncMock()
  316. client.get = AsyncMock(
  317. return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
  318. )
  319. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  320. assert result["success"] is True
  321. assert result["files"] == {"a.json": '{"k": 1}'}
  322. @pytest.mark.asyncio
  323. async def test_fetch_files_encodes_nested_path(self):
  324. client = AsyncMock()
  325. client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
  326. await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
  327. url = client.get.await_args.args[0]
  328. assert "repository/files/spools%2Finventory.json" in url
  329. @pytest.mark.asyncio
  330. async def test_fetch_files_skips_404(self):
  331. client = AsyncMock()
  332. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  333. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
  334. assert result["success"] is True
  335. assert result["files"] == {}