test_git_providers.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. """Unit tests for the git_providers abstraction package."""
  2. import base64
  3. import hashlib
  4. import json
  5. from unittest.mock import AsyncMock, MagicMock
  6. import pytest
  7. from backend.app.services.git_providers.factory import get_provider_backend
  8. from backend.app.services.git_providers.forgejo import ForgejoBackend
  9. from backend.app.services.git_providers.gitea import GiteaBackend
  10. from backend.app.services.git_providers.github import GitHubBackend
  11. from backend.app.services.git_providers.gitlab import GitLabBackend
  12. class TestFactory:
  13. def test_known_providers_return_correct_class(self):
  14. assert isinstance(get_provider_backend("github"), GitHubBackend)
  15. assert isinstance(get_provider_backend("gitea"), GiteaBackend)
  16. assert isinstance(get_provider_backend("forgejo"), ForgejoBackend)
  17. assert isinstance(get_provider_backend("gitlab"), GitLabBackend)
  18. def test_unknown_provider_raises_value_error(self):
  19. with pytest.raises(ValueError, match="Unknown Git provider"):
  20. get_provider_backend("bitbucket")
  21. class TestGitHubBackendParseUrl:
  22. def setup_method(self):
  23. self.backend = GitHubBackend()
  24. def test_https_url(self):
  25. owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo")
  26. assert owner == "owner"
  27. assert repo == "repo"
  28. def test_https_url_with_git_suffix(self):
  29. owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo.git")
  30. assert owner == "owner"
  31. assert repo == "repo"
  32. def test_ssh_url(self):
  33. owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo")
  34. assert owner == "owner"
  35. assert repo == "repo"
  36. def test_ssh_url_with_git_suffix(self):
  37. owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo.git")
  38. assert owner == "owner"
  39. assert repo == "repo"
  40. def test_invalid_url_raises_value_error(self):
  41. with pytest.raises(ValueError, match="Cannot parse repository URL"):
  42. self.backend.parse_repo_url("https://example.com/not-a-repo")
  43. def test_empty_url_raises_value_error(self):
  44. with pytest.raises(ValueError):
  45. self.backend.parse_repo_url("")
  46. class TestGitHubBackendApiBase:
  47. def setup_method(self):
  48. self.backend = GitHubBackend()
  49. def test_github_com_returns_api_github_com(self):
  50. assert self.backend.get_api_base("https://github.com/owner/repo") == "https://api.github.com"
  51. def test_ghe_host_returns_v3_endpoint(self):
  52. assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3"
  53. def test_ghe_host_with_port(self):
  54. assert (
  55. self.backend.get_api_base("https://github.example.com:8443/owner/repo")
  56. == "https://github.example.com:8443/api/v3"
  57. )
  58. def test_ssh_github_com_returns_api_github_com(self):
  59. assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com"
  60. def test_ssh_ghe_host_returns_v3_endpoint(self):
  61. assert self.backend.get_api_base("git@github.example.com:owner/repo.git") == "https://github.example.com/api/v3"
  62. class TestGiteaBackendApiBase:
  63. def setup_method(self):
  64. self.backend = GiteaBackend()
  65. def test_derives_api_base_from_repo_url(self):
  66. result = self.backend.get_api_base("https://git.example.com/owner/repo")
  67. assert result == "https://git.example.com/api/v1"
  68. def test_derives_api_base_with_port(self):
  69. result = self.backend.get_api_base("https://git.example.com:3000/owner/repo")
  70. assert result == "https://git.example.com:3000/api/v1"
  71. def test_invalid_url_raises_value_error(self):
  72. with pytest.raises(ValueError, match="Cannot derive API base"):
  73. self.backend.get_api_base("not-a-url")
  74. def test_parse_url_uses_instance_host(self):
  75. owner, repo = self.backend.parse_repo_url("https://git.example.com/owner/repo")
  76. assert owner == "owner"
  77. assert repo == "repo"
  78. class TestGiteaBackendPushFiles:
  79. def setup_method(self):
  80. self.backend = GiteaBackend()
  81. self.repo_url = "https://git.example.com/owner/repo"
  82. self.token = "gitea-token"
  83. self.branch = "bambuddy-backup"
  84. @pytest.mark.asyncio
  85. async def test_n_files_produce_single_commit(self):
  86. """All changed files are bundled into one commit via the Git Data API."""
  87. files = {"a.json": {"k": "v1"}, "b.json": {"k": "v2"}}
  88. client = AsyncMock()
  89. client.get = AsyncMock(
  90. side_effect=[
  91. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  92. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  93. _make_mock_response(200, {"tree": []}),
  94. ]
  95. )
  96. client.post = AsyncMock(
  97. side_effect=[
  98. _make_mock_response(201, {"sha": "blob1"}),
  99. _make_mock_response(201, {"sha": "blob2"}),
  100. _make_mock_response(201, {"sha": "new-tree"}),
  101. _make_mock_response(201, {"sha": "new-commit"}),
  102. ]
  103. )
  104. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  105. result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  106. assert result["status"] == "success"
  107. assert result["files_changed"] == 2
  108. commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
  109. assert len(commit_calls) == 1
  110. @pytest.mark.asyncio
  111. async def test_uses_gitea_api_v1_base_not_github(self):
  112. """Git Data API calls target the instance's /api/v1, not api.github.com."""
  113. client = AsyncMock()
  114. client.get = AsyncMock(
  115. side_effect=[
  116. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  117. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  118. _make_mock_response(200, {"tree": []}),
  119. ]
  120. )
  121. client.post = AsyncMock(
  122. side_effect=[
  123. _make_mock_response(201, {"sha": "blob1"}),
  124. _make_mock_response(201, {"sha": "new-tree"}),
  125. _make_mock_response(201, {"sha": "new-commit"}),
  126. ]
  127. )
  128. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  129. await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  130. first_get_url = client.get.call_args_list[0].args[0]
  131. assert "git.example.com/api/v1" in first_get_url
  132. assert "api.github.com" not in first_get_url
  133. @pytest.mark.asyncio
  134. async def test_skips_unchanged_files(self):
  135. """Files whose blob SHA matches the existing tree entry are excluded from the commit."""
  136. content = {"name": "my-printer"}
  137. sha = _blob_sha(content)
  138. client = AsyncMock()
  139. client.get = AsyncMock(
  140. side_effect=[
  141. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  142. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  143. _make_mock_response(200, {"tree": [{"type": "blob", "path": "config/printers.json", "sha": sha}]}),
  144. ]
  145. )
  146. result = await self.backend.push_files(
  147. self.repo_url, self.token, self.branch, {"config/printers.json": content}, client
  148. )
  149. assert result["status"] == "skipped"
  150. client.post.assert_not_called()
  151. @pytest.mark.asyncio
  152. async def test_creates_missing_branch_via_git_refs_api(self):
  153. """A missing backup branch is created via the Git Data API refs endpoint."""
  154. client = AsyncMock()
  155. client.get = AsyncMock(
  156. side_effect=[
  157. # branch ref missing
  158. _make_mock_response(404, {}),
  159. # repo info for default branch
  160. _make_mock_response(200, {"default_branch": "main"}),
  161. # default branch ref
  162. _make_mock_response(200, {"object": {"sha": "base-sha"}}),
  163. # second push_files call: branch now exists
  164. _make_mock_response(200, {"object": {"sha": "base-sha"}}),
  165. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  166. _make_mock_response(200, {"tree": []}),
  167. ]
  168. )
  169. client.post = AsyncMock(
  170. side_effect=[
  171. _make_mock_response(201, {}), # create ref
  172. _make_mock_response(201, {"sha": "blob1"}),
  173. _make_mock_response(201, {"sha": "new-tree"}),
  174. _make_mock_response(201, {"sha": "new-commit"}),
  175. ]
  176. )
  177. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  178. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  179. assert result["status"] == "success"
  180. ref_create_call = client.post.call_args_list[0]
  181. assert "/git/refs" in ref_create_call.args[0]
  182. assert ref_create_call.kwargs["json"]["ref"] == f"refs/heads/{self.branch}"
  183. @pytest.mark.asyncio
  184. async def test_truncates_upstream_error_body_in_failure_message(self):
  185. client = AsyncMock()
  186. client.get = AsyncMock(
  187. side_effect=[
  188. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  189. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  190. _make_mock_response(200, {"tree": []}),
  191. ]
  192. )
  193. client.post = AsyncMock(
  194. side_effect=[
  195. _make_mock_response(201, {"sha": "blob1"}),
  196. _make_mock_response(500, {}, text="x" * 500),
  197. ]
  198. )
  199. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  200. assert result["status"] == "failed"
  201. assert result["message"] == f"Failed to create tree: {'x' * 197}..."
  202. class TestGiteaBackendListShapeRefResponse:
  203. """#1224, #1225 regression: Gitea/Forgejo return refs as a *list*, not a dict.
  204. GitHub: ``GET /git/refs/heads/{branch}`` -> ``{"ref": ..., "object": {...}}``.
  205. Gitea/Forgejo: same endpoint -> ``[{"ref": ..., "object": {...}}]``.
  206. The pre-fix code did ``response.json()["object"]["sha"]`` on the Gitea path
  207. and crashed with ``list indices must be integers or slices, not str``.
  208. """
  209. def setup_method(self):
  210. self.backend = GiteaBackend()
  211. self.repo_url = "https://git.example.com/owner/repo"
  212. self.token = "gitea-token"
  213. self.branch = "bambuddy-backup"
  214. def test_ref_sha_extracts_from_list(self):
  215. assert self.backend._ref_sha([{"object": {"sha": "abc"}}]) == "abc"
  216. def test_ref_sha_still_accepts_dict_shape(self):
  217. # Defensive — if Gitea ever returns a dict (older versions, future change),
  218. # we don't want to break.
  219. assert self.backend._ref_sha({"object": {"sha": "abc"}}) == "abc"
  220. def test_ref_sha_raises_on_empty_list(self):
  221. with pytest.raises(ValueError):
  222. self.backend._ref_sha([])
  223. @pytest.mark.asyncio
  224. async def test_push_files_handles_list_shape_branch_ref(self):
  225. """The configured backup branch already exists — ref endpoint returns a list."""
  226. client = AsyncMock()
  227. client.get = AsyncMock(
  228. side_effect=[
  229. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]), # list shape
  230. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  231. _make_mock_response(200, {"tree": []}),
  232. ]
  233. )
  234. client.post = AsyncMock(
  235. side_effect=[
  236. _make_mock_response(201, {"sha": "blob1"}),
  237. _make_mock_response(201, {"sha": "new-tree"}),
  238. _make_mock_response(201, {"sha": "new-commit"}),
  239. ]
  240. )
  241. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  242. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  243. assert result["status"] == "success"
  244. assert result["commit_sha"] == "new-commit"
  245. @pytest.mark.asyncio
  246. async def test_create_branch_handles_list_shape_default_branch_ref(self):
  247. """Backup branch missing — must read default branch's ref, also list-shaped."""
  248. client = AsyncMock()
  249. client.get = AsyncMock(
  250. side_effect=[
  251. _make_mock_response(404, {}), # missing backup branch
  252. _make_mock_response(200, {"default_branch": "main"}), # repo info
  253. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref (list)
  254. # second push_files() call — branch now exists
  255. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]),
  256. _make_mock_response(200, {"tree": {"sha": "main-tree"}}),
  257. _make_mock_response(200, {"tree": []}),
  258. ]
  259. )
  260. client.post = AsyncMock(
  261. side_effect=[
  262. _make_mock_response(201, {}), # create branch ref
  263. _make_mock_response(201, {"sha": "blob1"}),
  264. _make_mock_response(201, {"sha": "new-tree"}),
  265. _make_mock_response(201, {"sha": "new-commit"}),
  266. ]
  267. )
  268. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  269. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  270. assert result["status"] == "success"
  271. class TestGiteaBackendWrappedCommitResponse:
  272. """#1224 regression: Gitea wraps the GitCommit fields under ``commit``.
  273. GitHub's ``GET /git/commits/{sha}`` returns the unwrapped GitCommit schema
  274. (``tree`` at top level). Gitea's same-named endpoint returns the wrapped
  275. Commit schema where ``tree`` lives at ``commit.tree`` (Gitea 1.24+).
  276. Pre-fix code did ``commit_response.json()["tree"]["sha"]`` and raised
  277. ``KeyError: 'tree'`` on every backup *after* the initial one — surfaced to
  278. the user as the opaque ``Backup failed: 'tree'`` message.
  279. """
  280. def setup_method(self):
  281. self.backend = GiteaBackend()
  282. self.repo_url = "https://git.example.com/owner/repo"
  283. self.token = "gitea-token"
  284. self.branch = "bambuddy-backup"
  285. def test_commit_tree_sha_reads_flat_shape(self):
  286. """GitHub-compatible / older Gitea: ``tree`` at top level."""
  287. assert self.backend._commit_tree_sha({"tree": {"sha": "abc"}}) == "abc"
  288. def test_commit_tree_sha_reads_wrapped_shape(self):
  289. """Gitea 1.24+ / Forgejo: ``tree`` nested under ``commit``."""
  290. assert self.backend._commit_tree_sha({"sha": "c1", "commit": {"tree": {"sha": "abc"}}}) == "abc"
  291. def test_commit_tree_sha_returns_none_on_missing(self):
  292. assert self.backend._commit_tree_sha({"sha": "c1", "commit": {}}) is None
  293. assert self.backend._commit_tree_sha({}) is None
  294. @pytest.mark.asyncio
  295. async def test_push_files_handles_wrapped_commit_response(self):
  296. """Subsequent backup against Gitea 1.24+ — commit endpoint returns wrapped shape."""
  297. client = AsyncMock()
  298. client.get = AsyncMock(
  299. side_effect=[
  300. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  301. # Wrapped Gitea commit response — tree under "commit", not top level
  302. _make_mock_response(200, {"sha": "base-commit", "commit": {"tree": {"sha": "base-tree"}}}),
  303. _make_mock_response(200, {"tree": []}),
  304. ]
  305. )
  306. client.post = AsyncMock(
  307. side_effect=[
  308. _make_mock_response(201, {"sha": "blob1"}),
  309. _make_mock_response(201, {"sha": "new-tree"}),
  310. _make_mock_response(201, {"sha": "new-commit"}),
  311. ]
  312. )
  313. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  314. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  315. assert result["status"] == "success"
  316. assert result["commit_sha"] == "new-commit"
  317. @pytest.mark.asyncio
  318. async def test_push_files_fails_cleanly_when_tree_sha_missing(self):
  319. """Defensive: malformed/unexpected commit response surfaces a clear error, not KeyError."""
  320. client = AsyncMock()
  321. client.get = AsyncMock(
  322. side_effect=[
  323. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  324. _make_mock_response(200, {"sha": "base-commit"}), # no tree at all
  325. ]
  326. )
  327. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  328. assert result["status"] == "failed"
  329. assert "tree SHA" in result["message"]
  330. class TestGiteaBackendEmptyRepoInitialCommit:
  331. """#1224 regression: Git Data API refuses writes against empty Gitea repos.
  332. GitHub accepts ``POST /git/blobs`` against an empty repo and creates the
  333. initial commit + branch. Gitea returns 404 on every blob/tree/commit POST
  334. until the repo has at least one commit. The fix is to use the Contents
  335. API (``POST /repos/.../contents``) which seeds the branch + initial
  336. commit in a single transaction.
  337. """
  338. def setup_method(self):
  339. self.backend = GiteaBackend()
  340. self.repo_url = "https://git.example.com/owner/repo"
  341. self.token = "gitea-token"
  342. self.branch = "main"
  343. @pytest.mark.asyncio
  344. async def test_empty_repo_uses_contents_api_not_git_data_api(self):
  345. files = {"config/printers.json": {"name": "p1"}, "config/spools.json": {"id": 1}}
  346. client = AsyncMock()
  347. client.get = AsyncMock(
  348. side_effect=[
  349. _make_mock_response(404, {}), # backup branch missing
  350. _make_mock_response(200, {"default_branch": "main"}), # repo info
  351. _make_mock_response(404, {}), # default branch missing too -> empty repo
  352. ]
  353. )
  354. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "initial-sha"}}))
  355. result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  356. assert result["status"] == "success"
  357. assert result["files_changed"] == 2
  358. assert result["commit_sha"] == "initial-sha"
  359. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  360. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  361. tree_calls = [c for c in client.post.call_args_list if "/git/trees" in c.args[0]]
  362. commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
  363. ref_calls = [c for c in client.post.call_args_list if "/git/refs" in c.args[0]]
  364. # Exactly one Contents API call, no Git Data API writes
  365. assert len(contents_calls) == 1
  366. assert len(blob_calls) == 0
  367. assert len(tree_calls) == 0
  368. assert len(commit_calls) == 0
  369. assert len(ref_calls) == 0
  370. @pytest.mark.asyncio
  371. async def test_contents_api_payload_shape(self):
  372. """The Contents API call must carry branch+new_branch+files in the documented shape."""
  373. files = {"a.json": {"k": "v"}, "nested/b.json": {"x": 1}}
  374. client = AsyncMock()
  375. client.get = AsyncMock(
  376. side_effect=[
  377. _make_mock_response(404, {}),
  378. _make_mock_response(200, {"default_branch": "main"}),
  379. _make_mock_response(404, {}),
  380. ]
  381. )
  382. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "abc"}}))
  383. await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  384. body = client.post.call_args.kwargs["json"]
  385. assert body["branch"] == "main"
  386. assert body["new_branch"] == "main"
  387. assert body["message"].startswith("Initial Bambuddy backup")
  388. assert len(body["files"]) == 2
  389. paths = {f["path"] for f in body["files"]}
  390. assert paths == {"a.json", "nested/b.json"}
  391. for f in body["files"]:
  392. assert f["operation"] == "create"
  393. # Content is base64-encoded JSON of the original dict
  394. decoded = base64.b64decode(f["content"]).decode("utf-8")
  395. assert json.loads(decoded) == files[f["path"]]
  396. @pytest.mark.asyncio
  397. async def test_contents_api_failure_truncates_error_body(self):
  398. client = AsyncMock()
  399. client.get = AsyncMock(
  400. side_effect=[
  401. _make_mock_response(404, {}),
  402. _make_mock_response(200, {"default_branch": "main"}),
  403. _make_mock_response(404, {}),
  404. ]
  405. )
  406. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  407. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  408. assert result["status"] == "failed"
  409. assert result["message"] == f"Failed to create initial commit: {'x' * 197}..."
  410. @pytest.mark.asyncio
  411. async def test_empty_files_skips_contents_api_call(self):
  412. # Edge: nothing to commit -> don't make a useless Contents API call.
  413. client = AsyncMock()
  414. client.post = AsyncMock()
  415. result = await self.backend._create_initial_commit(
  416. client, {}, "https://git.example.com/api/v1", "owner", "repo", "main", {}
  417. )
  418. assert result["status"] == "skipped"
  419. assert result["files_changed"] == 0
  420. client.post.assert_not_called()
  421. class TestForgejoInheritsGiteaFixes:
  422. """ForgejoBackend extends GiteaBackend with no overrides — must inherit both fixes."""
  423. @pytest.mark.asyncio
  424. async def test_forgejo_handles_list_shape_ref_response(self):
  425. backend = ForgejoBackend()
  426. client = AsyncMock()
  427. client.get = AsyncMock(
  428. side_effect=[
  429. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  430. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  431. _make_mock_response(200, {"tree": []}),
  432. ]
  433. )
  434. client.post = AsyncMock(
  435. side_effect=[
  436. _make_mock_response(201, {"sha": "blob1"}),
  437. _make_mock_response(201, {"sha": "new-tree"}),
  438. _make_mock_response(201, {"sha": "new-commit"}),
  439. ]
  440. )
  441. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  442. result = await backend.push_files(
  443. "https://forgejo.example.com/owner/repo",
  444. "token",
  445. "bambuddy-backup",
  446. {"a.json": {"k": "v"}},
  447. client,
  448. )
  449. assert result["status"] == "success"
  450. @pytest.mark.asyncio
  451. async def test_forgejo_empty_repo_uses_contents_api(self):
  452. backend = ForgejoBackend()
  453. client = AsyncMock()
  454. client.get = AsyncMock(
  455. side_effect=[
  456. _make_mock_response(404, {}),
  457. _make_mock_response(200, {"default_branch": "main"}),
  458. _make_mock_response(404, {}),
  459. ]
  460. )
  461. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "fj-sha"}}))
  462. result = await backend.push_files(
  463. "https://forgejo.example.com/owner/repo",
  464. "token",
  465. "main",
  466. {"a.json": {"k": "v"}},
  467. client,
  468. )
  469. assert result["status"] == "success"
  470. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  471. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  472. assert len(contents_calls) == 1
  473. assert len(blob_calls) == 0
  474. class TestForgejoBackendApiBase:
  475. def setup_method(self):
  476. self.backend = ForgejoBackend()
  477. def test_derives_api_base_from_repo_url(self):
  478. result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
  479. assert result == "https://forgejo.example.com/api/v1"
  480. def test_derives_api_base_with_port(self):
  481. result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
  482. assert result == "https://forgejo.example.com:3000/api/v1"
  483. def test_invalid_url_raises_value_error(self):
  484. with pytest.raises(ValueError, match="Cannot derive API base"):
  485. self.backend.get_api_base("not-a-url")
  486. def test_parse_url_uses_instance_host(self):
  487. owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
  488. assert owner == "owner"
  489. assert repo == "repo"
  490. class TestGitLabBackend:
  491. def setup_method(self):
  492. self.backend = GitLabBackend()
  493. def test_parse_url_https(self):
  494. owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
  495. assert owner == "owner"
  496. assert repo == "repo"
  497. def test_parse_url_ssh(self):
  498. owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
  499. assert owner == "owner"
  500. assert repo == "repo"
  501. def test_parse_url_invalid_raises(self):
  502. with pytest.raises(ValueError):
  503. self.backend.parse_repo_url("not-a-url")
  504. def test_get_api_base_derives_from_repo_url(self):
  505. result = self.backend.get_api_base("https://gitlab.com/owner/repo")
  506. assert result == "https://gitlab.com/api/v4"
  507. def test_get_api_base_derives_from_self_hosted_url(self):
  508. result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
  509. assert result == "https://my-gitlab.example.com/api/v4"
  510. def test_get_api_base_invalid_url_raises(self):
  511. with pytest.raises(ValueError, match="Cannot derive API base"):
  512. self.backend.get_api_base("not-a-url")
  513. def test_get_headers_uses_bearer_token(self):
  514. headers = self.backend.get_headers("mytoken")
  515. assert headers["Authorization"] == "Bearer mytoken"
  516. assert "Content-Type" in headers
  517. def test_parse_url_subgroup_https(self):
  518. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
  519. assert namespace == "group/subgroup"
  520. assert repo == "project"
  521. def test_parse_url_deep_namespace_https(self):
  522. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
  523. assert namespace == "myorg/team/api"
  524. assert repo == "backend"
  525. def test_parse_url_subgroup_ssh(self):
  526. namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
  527. assert namespace == "group/subgroup"
  528. assert repo == "project"
  529. @pytest.mark.asyncio
  530. async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
  531. backend = GitLabBackend()
  532. repo_url = "https://gitlab.com/group/subgroup/project"
  533. client = AsyncMock()
  534. client.get = AsyncMock(
  535. side_effect=[
  536. _make_mock_response(200, {"name": "bambuddy-backup"}),
  537. _make_mock_response(200, []),
  538. ]
  539. )
  540. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  541. await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
  542. called_url = client.get.call_args_list[0].args[0]
  543. assert "group%2Fsubgroup%2Fproject" in called_url
  544. def _blob_sha(content: dict) -> str:
  545. content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
  546. return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
  547. def _make_mock_response(status_code: int, body=None, text: str = ""):
  548. resp = MagicMock()
  549. resp.status_code = status_code
  550. resp.text = text
  551. resp.json = MagicMock(return_value=body or {})
  552. return resp
  553. class TestGitLabBackendPushFiles:
  554. def setup_method(self):
  555. self.backend = GitLabBackend()
  556. self.repo_url = "https://gitlab.com/owner/repo"
  557. self.token = "glpat-test"
  558. self.branch = "bambuddy-backup"
  559. self.files = {"config/printers.json": {"name": "my-printer"}}
  560. @pytest.mark.asyncio
  561. async def test_skips_commit_when_content_unchanged(self):
  562. sha = _blob_sha(self.files["config/printers.json"])
  563. client = AsyncMock()
  564. client.get = AsyncMock(
  565. side_effect=[
  566. # branch check → branch exists
  567. _make_mock_response(200, {"name": self.branch}),
  568. # tree page 1 → one blob whose sha matches current content
  569. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
  570. # tree page 2 → empty, stop pagination
  571. _make_mock_response(200, []),
  572. ]
  573. )
  574. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  575. assert result["status"] == "skipped"
  576. assert result["files_changed"] == 0
  577. client.post.assert_not_called()
  578. @pytest.mark.asyncio
  579. async def test_commits_when_content_changed(self):
  580. stale_sha = "0000000000000000000000000000000000000000"
  581. client = AsyncMock()
  582. client.get = AsyncMock(
  583. side_effect=[
  584. _make_mock_response(200, {"name": self.branch}),
  585. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
  586. _make_mock_response(200, []), # page 2 empty, stop pagination
  587. ]
  588. )
  589. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  590. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  591. assert result["status"] == "success"
  592. assert result["files_changed"] == 1
  593. client.post.assert_called_once()
  594. @pytest.mark.asyncio
  595. async def test_truncates_upstream_error_body_in_failure_message(self):
  596. client = AsyncMock()
  597. client.get = AsyncMock(
  598. side_effect=[
  599. _make_mock_response(200, {"name": self.branch}),
  600. _make_mock_response(200, []),
  601. ]
  602. )
  603. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  604. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  605. assert result["status"] == "failed"
  606. assert result["message"] == f"Failed to create commit: {'x' * 197}..."
  607. @pytest.mark.asyncio
  608. async def test_creates_new_file_not_in_existing_tree(self):
  609. client = AsyncMock()
  610. client.get = AsyncMock(
  611. side_effect=[
  612. _make_mock_response(200, {"name": self.branch}),
  613. # tree is empty
  614. _make_mock_response(200, []),
  615. ]
  616. )
  617. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
  618. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  619. assert result["status"] == "success"
  620. call_kwargs = client.post.call_args.kwargs["json"]
  621. assert call_kwargs["actions"][0]["action"] == "create"
  622. @pytest.mark.asyncio
  623. async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
  624. """Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
  625. sha = _blob_sha(self.files["config/printers.json"])
  626. page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
  627. page2_items = [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] + [
  628. {"type": "blob", "path": "config/printers.json", "id": sha}
  629. ] # 120 total blobs across two pages
  630. client = AsyncMock()
  631. client.get = AsyncMock(
  632. side_effect=[
  633. _make_mock_response(200, {"name": self.branch}), # branch check
  634. _make_mock_response(200, page1_items), # tree page 1
  635. _make_mock_response(200, page2_items), # tree page 2
  636. _make_mock_response(200, []), # tree page 3 empty, stop
  637. ]
  638. )
  639. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  640. assert result["status"] == "skipped"
  641. client.post.assert_not_called()