test_git_providers_restore.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  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 TestGetCommit:
  87. """A ref older than the list window still needs a subject line and a date."""
  88. @pytest.mark.asyncio
  89. async def test_github_reads_one_commit_by_sha(self):
  90. client = AsyncMock()
  91. client.get = AsyncMock(return_value=_make_mock_response(200, _github_commit("abc1234567")))
  92. result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "abc1234567", client)
  93. assert result["success"] is True
  94. assert result["commit"] == {
  95. "sha": "abc1234567",
  96. "message": "Bambuddy backup",
  97. "author": "Bambuddy",
  98. "date": "2026-07-01T10:00:00Z",
  99. }
  100. assert "repos/owner/repo/commits/abc1234567" in client.get.await_args.args[0]
  101. @pytest.mark.asyncio
  102. async def test_github_404_names_the_ref(self):
  103. client = AsyncMock()
  104. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  105. result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "deadbee", client)
  106. assert result["success"] is False
  107. assert result["commit"] is None
  108. assert "deadbee" in result["message"]
  109. @pytest.mark.asyncio
  110. async def test_gitlab_reads_its_flattened_shape(self):
  111. client = AsyncMock()
  112. client.get = AsyncMock(
  113. return_value=_make_mock_response(
  114. 200,
  115. {
  116. "id": "abc1234567",
  117. "message": "Bambuddy backup",
  118. "author_name": "Bambuddy",
  119. "committed_date": "2026-07-02T10:00:00Z",
  120. },
  121. )
  122. )
  123. result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "abc1234567", client)
  124. assert result["commit"]["author"] == "Bambuddy"
  125. assert result["commit"]["date"] == "2026-07-02T10:00:00Z"
  126. @pytest.mark.asyncio
  127. async def test_gitlab_404_names_the_ref(self):
  128. client = AsyncMock()
  129. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  130. result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "deadbee", client)
  131. assert result["success"] is False
  132. assert "deadbee" in result["message"]
  133. class TestGitHubListTree:
  134. def setup_method(self):
  135. self.backend = GitHubBackend()
  136. self.repo_url = "https://github.com/owner/repo"
  137. self.token = "ghp_token"
  138. @pytest.mark.asyncio
  139. async def test_returns_sorted_blob_paths_only(self):
  140. client = AsyncMock()
  141. client.get = AsyncMock(
  142. return_value=_make_mock_response(
  143. 200,
  144. {
  145. "tree": [
  146. {"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
  147. {"type": "tree", "path": "spools", "sha": "d1"},
  148. {"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
  149. ]
  150. },
  151. )
  152. )
  153. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  154. assert result["success"] is True
  155. assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
  156. @pytest.mark.asyncio
  157. async def test_truncated_tree_fails_loudly(self):
  158. """A truncated listing would make restore silently miss categories."""
  159. client = AsyncMock()
  160. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
  161. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  162. assert result["success"] is False
  163. assert "truncated" in result["message"]
  164. @pytest.mark.asyncio
  165. async def test_404_names_the_missing_ref(self):
  166. client = AsyncMock()
  167. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  168. result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
  169. assert result["success"] is False
  170. assert "deadbee" in result["message"]
  171. class TestGitHubFetchFiles:
  172. def setup_method(self):
  173. self.backend = GitHubBackend()
  174. self.repo_url = "https://github.com/owner/repo"
  175. self.token = "ghp_token"
  176. @pytest.mark.asyncio
  177. async def test_reads_requested_paths_via_blob_api(self):
  178. tree = _make_mock_response(
  179. 200,
  180. {
  181. "tree": [
  182. {"type": "blob", "path": "a.json", "sha": "sha-a"},
  183. {"type": "blob", "path": "b.json", "sha": "sha-b"},
  184. ]
  185. },
  186. )
  187. client = AsyncMock()
  188. client.get = AsyncMock(
  189. side_effect=[
  190. tree,
  191. _make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
  192. ]
  193. )
  194. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  195. assert result["success"] is True
  196. assert result["files"] == {"a.json": '{"a": 1}'}
  197. # One tree listing regardless of how many files are read.
  198. assert client.get.await_count == 2
  199. @pytest.mark.asyncio
  200. async def test_lists_the_tree_once_for_many_files(self):
  201. tree = _make_mock_response(
  202. 200,
  203. {
  204. "tree": [
  205. {"type": "blob", "path": "a.json", "sha": "sha-a"},
  206. {"type": "blob", "path": "b.json", "sha": "sha-b"},
  207. ]
  208. },
  209. )
  210. client = AsyncMock()
  211. client.get = AsyncMock(
  212. side_effect=[
  213. tree,
  214. _make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
  215. _make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
  216. ]
  217. )
  218. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
  219. assert result["files"] == {"a.json": "1", "b.json": "2"}
  220. assert client.get.await_count == 3
  221. @pytest.mark.asyncio
  222. async def test_a_supplied_blob_map_skips_the_second_tree_read(self):
  223. """list_tree already fetched this; fetching it again was a wasted GET."""
  224. client = AsyncMock()
  225. client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
  226. result = await self.backend.fetch_files(
  227. self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "sha-a"}
  228. )
  229. assert result["files"] == {"a.json": "1"}
  230. # The blob read and nothing else.
  231. assert client.get.await_count == 1
  232. assert "git/blobs/sha-a" in client.get.await_args.args[0]
  233. @pytest.mark.asyncio
  234. async def test_list_tree_hands_back_the_map_it_built(self):
  235. client = AsyncMock()
  236. client.get = AsyncMock(
  237. return_value=_make_mock_response(
  238. 200,
  239. {
  240. "tree": [
  241. {"type": "blob", "path": "a.json", "sha": "sha-a"},
  242. {"type": "tree", "path": "dir", "sha": "sha-d"},
  243. ]
  244. },
  245. )
  246. )
  247. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  248. assert result["blob_shas"] == {"a.json": "sha-a"}
  249. @pytest.mark.asyncio
  250. async def test_missing_path_is_skipped_not_an_error(self):
  251. """Which categories a backup contains varies by config, so an absent
  252. path is expected rather than a failure."""
  253. client = AsyncMock()
  254. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
  255. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
  256. assert result["success"] is True
  257. assert result["files"] == {}
  258. @pytest.mark.asyncio
  259. async def test_blob_error_fails_the_whole_read(self):
  260. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  261. client = AsyncMock()
  262. client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
  263. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  264. assert result["success"] is False
  265. assert "a.json" in result["message"]
  266. assert result["files"] == {}
  267. @pytest.mark.asyncio
  268. async def test_utf8_content_survives_round_trip(self):
  269. payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
  270. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  271. client = AsyncMock()
  272. client.get = AsyncMock(
  273. side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
  274. )
  275. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  276. assert result["files"]["a.json"] == payload
  277. @pytest.mark.asyncio
  278. async def test_unsupported_encoding_is_reported(self):
  279. tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
  280. client = AsyncMock()
  281. client.get = AsyncMock(
  282. side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
  283. )
  284. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  285. assert result["success"] is False
  286. assert "Unsupported blob encoding" in result["message"]
  287. class TestGiteaAndForgejoInheritReads:
  288. """Gitea overrides the *write* path, plus the one read that genuinely differs."""
  289. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  290. def test_read_methods_are_not_overridden(self, backend_cls):
  291. for method in ("list_commits", "list_tree", "fetch_files", "get_commit"):
  292. assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
  293. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  294. def test_the_tree_read_is_paged_rather_than_inherited(self, backend_cls):
  295. """GitHub's trees endpoint is not paginated; Gitea's is (#2656)."""
  296. assert backend_cls._blob_shas_at is not GitHubBackend._blob_shas_at
  297. @pytest.mark.asyncio
  298. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  299. async def test_a_paged_tree_is_read_to_the_end(self, backend_cls):
  300. """Inheriting GitHub's single GET read only the first page.
  301. The rest of the backup then looked absent from the commit, and the
  302. preview reported those categories as "not present" — a restore silently
  303. skipping data, which is exactly what GitHub's truncated=true check
  304. exists to prevent.
  305. """
  306. page1 = {
  307. "tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)],
  308. "total_count": 1002,
  309. }
  310. page2 = {
  311. "tree": [
  312. {"type": "blob", "path": "settings/app_settings.json", "sha": "sx"},
  313. {"type": "tree", "path": "settings", "sha": "dx"},
  314. ],
  315. "total_count": 1002,
  316. }
  317. client = AsyncMock()
  318. client.get = AsyncMock(side_effect=[_make_mock_response(200, page1), _make_mock_response(200, page2)])
  319. result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  320. assert result["success"] is True
  321. assert client.get.await_count == 2
  322. assert "settings/app_settings.json" in result["paths"]
  323. assert len(result["paths"]) == 1001
  324. @pytest.mark.asyncio
  325. async def test_a_single_page_tree_costs_one_request(self):
  326. client = AsyncMock()
  327. client.get = AsyncMock(
  328. return_value=_make_mock_response(
  329. 200, {"tree": [{"type": "blob", "path": "a.json", "sha": "s1"}], "total_count": 1}
  330. )
  331. )
  332. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  333. assert result["paths"] == ["a.json"]
  334. assert client.get.await_count == 1
  335. @pytest.mark.asyncio
  336. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  337. async def test_a_clamped_page_size_is_still_read_to_the_end(self, backend_cls):
  338. """Gitea clamps per_page to MAX_RESPONSE_ITEMS — 50 by default (#2656).
  339. Paging off the *requested* 1000 made page 2 believe it had seen 1050
  340. entries, which clears any total_count below that. The loop then returned
  341. the first 100 entries of a 120-entry tree as a success, and the restore
  342. reported the categories it could not see as absent from the commit.
  343. """
  344. clamped = 50
  345. total = 120
  346. pages = []
  347. for start in range(0, total, clamped):
  348. count = min(clamped, total - start)
  349. pages.append(
  350. _make_mock_response(
  351. 200,
  352. {
  353. "tree": [
  354. {"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)
  355. ],
  356. "total_count": total,
  357. },
  358. )
  359. )
  360. client = AsyncMock()
  361. client.get = AsyncMock(side_effect=pages)
  362. result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  363. assert result["success"] is True
  364. assert client.get.await_count == 3
  365. assert len(result["paths"]) == total
  366. assert "f119.json" in result["paths"], "the tail of the tree is what a clamped pager loses"
  367. # --- a response with no usable total_count must not fail open -----------
  368. #
  369. # The pager used to short-circuit into a *success* holding page 1 whenever
  370. # total_count was missing or not an int — 50 entries of an arbitrarily large
  371. # tree under Gitea's default clamp. The restore then reported the categories
  372. # it could not see as "not present in this backup commit", the same silent
  373. # skip this whole override exists to prevent. GitHub and GitLab both
  374. # hard-fail in the equivalent spot; only Gitea guessed.
  375. @staticmethod
  376. def _page(start, count, **extra):
  377. return _make_mock_response(
  378. 200,
  379. {
  380. "tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)],
  381. **extra,
  382. },
  383. )
  384. @pytest.mark.asyncio
  385. @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
  386. async def test_a_countless_response_is_paged_to_the_end(self, backend_cls):
  387. client = AsyncMock()
  388. client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 50), self._page(100, 0)])
  389. result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  390. assert result["success"] is True
  391. assert client.get.await_count == 3
  392. assert len(result["paths"]) == 100
  393. assert "f99.json" in result["paths"], "the tail is what a fail-open pager loses"
  394. @pytest.mark.asyncio
  395. async def test_a_countless_short_page_ends_the_paging(self):
  396. client = AsyncMock()
  397. client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 7)])
  398. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  399. assert result["success"] is True
  400. assert client.get.await_count == 2
  401. assert len(result["paths"]) == 57
  402. @pytest.mark.asyncio
  403. async def test_a_countless_single_page_tree_still_costs_one_request(self):
  404. """Control: a small tree must not pay for the fix."""
  405. client = AsyncMock()
  406. client.get = AsyncMock(return_value=self._page(0, 3))
  407. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  408. assert result["paths"] == ["f0.json", "f1.json", "f2.json"]
  409. assert client.get.await_count == 1
  410. @pytest.mark.asyncio
  411. async def test_a_non_int_total_count_is_treated_as_no_count(self):
  412. """The arm the code was written to defend against, and then trusted."""
  413. client = AsyncMock()
  414. client.get = AsyncMock(side_effect=[self._page(0, 50, total_count="120"), self._page(50, 4)])
  415. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  416. assert result["success"] is True
  417. assert client.get.await_count == 2
  418. assert len(result["paths"]) == 54
  419. @pytest.mark.asyncio
  420. async def test_a_countless_tree_beyond_the_page_cap_still_fails(self):
  421. """The page ceiling is what keeps "page until short" from truncating."""
  422. client = AsyncMock()
  423. client.get = AsyncMock(return_value=self._page(0, 1000))
  424. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  425. assert result["success"] is False
  426. assert "listing limit" in result["message"]
  427. @pytest.mark.asyncio
  428. async def test_a_tree_beyond_the_page_cap_fails_rather_than_truncating(self):
  429. page = {"tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)]}
  430. page["total_count"] = 10_000_000
  431. client = AsyncMock()
  432. client.get = AsyncMock(return_value=_make_mock_response(200, page))
  433. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
  434. assert result["success"] is False
  435. assert "listing limit" in result["message"]
  436. @pytest.mark.asyncio
  437. async def test_a_missing_ref_is_still_named(self):
  438. client = AsyncMock()
  439. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  440. result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "deadbee", client)
  441. assert result["success"] is False
  442. assert "deadbee" in result["message"]
  443. @pytest.mark.asyncio
  444. async def test_gitea_list_commits_uses_its_own_api_base(self):
  445. backend = GiteaBackend()
  446. client = AsyncMock()
  447. client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
  448. result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
  449. assert result["success"] is True
  450. url = client.get.await_args.args[0]
  451. assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
  452. @pytest.mark.asyncio
  453. async def test_gitea_subpath_install_is_respected(self):
  454. """Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
  455. backend = GiteaBackend()
  456. client = AsyncMock()
  457. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
  458. client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "total_count": 0}))
  459. await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
  460. url = client.get.await_args.args[0]
  461. assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
  462. class TestGitLabReads:
  463. def setup_method(self):
  464. self.backend = GitLabBackend()
  465. self.repo_url = "https://gitlab.com/owner/repo"
  466. self.token = "glpat-test"
  467. @pytest.mark.asyncio
  468. async def test_list_commits_reads_flattened_author_fields(self):
  469. """GitLab puts message/author/date on the entry, not under 'commit'."""
  470. client = AsyncMock()
  471. client.get = AsyncMock(
  472. return_value=_make_mock_response(
  473. 200,
  474. [
  475. {
  476. "id": "abc123",
  477. "message": "Bambuddy backup",
  478. "author_name": "Bambuddy",
  479. "committed_date": "2026-07-02T10:00:00Z",
  480. }
  481. ],
  482. )
  483. )
  484. result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
  485. assert result["success"] is True
  486. assert result["commits"] == [
  487. {
  488. "sha": "abc123",
  489. "message": "Bambuddy backup",
  490. "author": "Bambuddy",
  491. "date": "2026-07-02T10:00:00Z",
  492. }
  493. ]
  494. @pytest.mark.asyncio
  495. async def test_list_commits_uses_ref_name(self):
  496. client = AsyncMock()
  497. client.get = AsyncMock(return_value=_make_mock_response(200, []))
  498. await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
  499. params = client.get.await_args.kwargs["params"]
  500. assert params["ref_name"] == "bambuddy-backup"
  501. assert params["per_page"] == 5
  502. @pytest.mark.asyncio
  503. async def test_subgroup_path_is_url_encoded(self):
  504. client = AsyncMock()
  505. client.get = AsyncMock(return_value=_make_mock_response(200, []))
  506. await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
  507. url = client.get.await_args.args[0]
  508. assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
  509. @pytest.mark.asyncio
  510. async def test_list_tree_returns_blob_paths(self):
  511. client = AsyncMock()
  512. client.get = AsyncMock(
  513. return_value=_make_mock_response(
  514. 200,
  515. [
  516. {"type": "blob", "path": "spools/inventory.json"},
  517. {"type": "tree", "path": "spools"},
  518. ],
  519. )
  520. )
  521. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  522. assert result["success"] is True
  523. assert result["paths"] == ["spools/inventory.json"]
  524. @pytest.mark.asyncio
  525. async def test_list_tree_follows_pagination(self):
  526. """GitLab paginates instead of exposing a truncated flag."""
  527. full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
  528. client = AsyncMock()
  529. client.get = AsyncMock(
  530. side_effect=[
  531. _make_mock_response(200, full_page),
  532. _make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
  533. ]
  534. )
  535. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  536. assert client.get.await_count == 2
  537. assert len(result["paths"]) == 101
  538. assert "last.json" in result["paths"]
  539. @pytest.mark.asyncio
  540. async def test_hitting_the_page_cap_is_a_failure_not_a_partial_list(self):
  541. """The mirror image of GitHub's truncated=true check.
  542. Falling out of the `while page <= 50` condition used to return
  543. success: True with a silently partial path list, which the restore then
  544. reported as "those categories are not present in this commit" — data
  545. skipped without anyone being told.
  546. """
  547. full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
  548. client = AsyncMock()
  549. client.get = AsyncMock(return_value=_make_mock_response(200, full_page))
  550. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  551. assert result["success"] is False
  552. assert result["paths"] == []
  553. assert "cannot be enumerated reliably" in result["message"]
  554. @pytest.mark.asyncio
  555. async def test_list_tree_returns_no_blob_map(self):
  556. """GitLab reads files by path, so there is nothing to share."""
  557. client = AsyncMock()
  558. client.get = AsyncMock(return_value=_make_mock_response(200, [{"type": "blob", "path": "a.json"}]))
  559. result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
  560. assert result["blob_shas"] == {}
  561. @pytest.mark.asyncio
  562. async def test_fetch_files_ignores_a_blob_map(self):
  563. client = AsyncMock()
  564. client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
  565. result = await self.backend.fetch_files(
  566. self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "irrelevant"}
  567. )
  568. assert result["files"] == {"a.json": "1"}
  569. assert "repository/files/a.json" in client.get.await_args.args[0]
  570. @pytest.mark.asyncio
  571. async def test_fetch_files_decodes_base64(self):
  572. client = AsyncMock()
  573. client.get = AsyncMock(
  574. return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
  575. )
  576. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
  577. assert result["success"] is True
  578. assert result["files"] == {"a.json": '{"k": 1}'}
  579. @pytest.mark.asyncio
  580. async def test_fetch_files_encodes_nested_path(self):
  581. client = AsyncMock()
  582. client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
  583. await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
  584. url = client.get.await_args.args[0]
  585. assert "repository/files/spools%2Finventory.json" in url
  586. @pytest.mark.asyncio
  587. async def test_fetch_files_skips_404(self):
  588. client = AsyncMock()
  589. client.get = AsyncMock(return_value=_make_mock_response(404, {}))
  590. result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
  591. assert result["success"] is True
  592. assert result["files"] == {}