test_git_providers.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  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 TestGiteaBackendEmptyRepoInitialCommit:
  272. """#1224 regression: Git Data API refuses writes against empty Gitea repos.
  273. GitHub accepts ``POST /git/blobs`` against an empty repo and creates the
  274. initial commit + branch. Gitea returns 404 on every blob/tree/commit POST
  275. until the repo has at least one commit. The fix is to use the Contents
  276. API (``POST /repos/.../contents``) which seeds the branch + initial
  277. commit in a single transaction.
  278. """
  279. def setup_method(self):
  280. self.backend = GiteaBackend()
  281. self.repo_url = "https://git.example.com/owner/repo"
  282. self.token = "gitea-token"
  283. self.branch = "main"
  284. @pytest.mark.asyncio
  285. async def test_empty_repo_uses_contents_api_not_git_data_api(self):
  286. files = {"config/printers.json": {"name": "p1"}, "config/spools.json": {"id": 1}}
  287. client = AsyncMock()
  288. client.get = AsyncMock(
  289. side_effect=[
  290. _make_mock_response(404, {}), # backup branch missing
  291. _make_mock_response(200, {"default_branch": "main"}), # repo info
  292. _make_mock_response(404, {}), # default branch missing too -> empty repo
  293. ]
  294. )
  295. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "initial-sha"}}))
  296. result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  297. assert result["status"] == "success"
  298. assert result["files_changed"] == 2
  299. assert result["commit_sha"] == "initial-sha"
  300. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  301. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  302. tree_calls = [c for c in client.post.call_args_list if "/git/trees" in c.args[0]]
  303. commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
  304. ref_calls = [c for c in client.post.call_args_list if "/git/refs" in c.args[0]]
  305. # Exactly one Contents API call, no Git Data API writes
  306. assert len(contents_calls) == 1
  307. assert len(blob_calls) == 0
  308. assert len(tree_calls) == 0
  309. assert len(commit_calls) == 0
  310. assert len(ref_calls) == 0
  311. @pytest.mark.asyncio
  312. async def test_contents_api_payload_shape(self):
  313. """The Contents API call must carry branch+new_branch+files in the documented shape."""
  314. files = {"a.json": {"k": "v"}, "nested/b.json": {"x": 1}}
  315. client = AsyncMock()
  316. client.get = AsyncMock(
  317. side_effect=[
  318. _make_mock_response(404, {}),
  319. _make_mock_response(200, {"default_branch": "main"}),
  320. _make_mock_response(404, {}),
  321. ]
  322. )
  323. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "abc"}}))
  324. await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  325. body = client.post.call_args.kwargs["json"]
  326. assert body["branch"] == "main"
  327. assert body["new_branch"] == "main"
  328. assert body["message"].startswith("Initial Bambuddy backup")
  329. assert len(body["files"]) == 2
  330. paths = {f["path"] for f in body["files"]}
  331. assert paths == {"a.json", "nested/b.json"}
  332. for f in body["files"]:
  333. assert f["operation"] == "create"
  334. # Content is base64-encoded JSON of the original dict
  335. decoded = base64.b64decode(f["content"]).decode("utf-8")
  336. assert json.loads(decoded) == files[f["path"]]
  337. @pytest.mark.asyncio
  338. async def test_contents_api_failure_truncates_error_body(self):
  339. client = AsyncMock()
  340. client.get = AsyncMock(
  341. side_effect=[
  342. _make_mock_response(404, {}),
  343. _make_mock_response(200, {"default_branch": "main"}),
  344. _make_mock_response(404, {}),
  345. ]
  346. )
  347. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  348. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  349. assert result["status"] == "failed"
  350. assert result["message"] == f"Failed to create initial commit: {'x' * 197}..."
  351. @pytest.mark.asyncio
  352. async def test_empty_files_skips_contents_api_call(self):
  353. # Edge: nothing to commit -> don't make a useless Contents API call.
  354. client = AsyncMock()
  355. client.post = AsyncMock()
  356. result = await self.backend._create_initial_commit(
  357. client, {}, "https://git.example.com/api/v1", "owner", "repo", "main", {}
  358. )
  359. assert result["status"] == "skipped"
  360. assert result["files_changed"] == 0
  361. client.post.assert_not_called()
  362. class TestForgejoInheritsGiteaFixes:
  363. """ForgejoBackend extends GiteaBackend with no overrides — must inherit both fixes."""
  364. @pytest.mark.asyncio
  365. async def test_forgejo_handles_list_shape_ref_response(self):
  366. backend = ForgejoBackend()
  367. client = AsyncMock()
  368. client.get = AsyncMock(
  369. side_effect=[
  370. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  371. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  372. _make_mock_response(200, {"tree": []}),
  373. ]
  374. )
  375. client.post = AsyncMock(
  376. side_effect=[
  377. _make_mock_response(201, {"sha": "blob1"}),
  378. _make_mock_response(201, {"sha": "new-tree"}),
  379. _make_mock_response(201, {"sha": "new-commit"}),
  380. ]
  381. )
  382. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  383. result = await backend.push_files(
  384. "https://forgejo.example.com/owner/repo",
  385. "token",
  386. "bambuddy-backup",
  387. {"a.json": {"k": "v"}},
  388. client,
  389. )
  390. assert result["status"] == "success"
  391. @pytest.mark.asyncio
  392. async def test_forgejo_empty_repo_uses_contents_api(self):
  393. backend = ForgejoBackend()
  394. client = AsyncMock()
  395. client.get = AsyncMock(
  396. side_effect=[
  397. _make_mock_response(404, {}),
  398. _make_mock_response(200, {"default_branch": "main"}),
  399. _make_mock_response(404, {}),
  400. ]
  401. )
  402. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "fj-sha"}}))
  403. result = await backend.push_files(
  404. "https://forgejo.example.com/owner/repo",
  405. "token",
  406. "main",
  407. {"a.json": {"k": "v"}},
  408. client,
  409. )
  410. assert result["status"] == "success"
  411. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  412. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  413. assert len(contents_calls) == 1
  414. assert len(blob_calls) == 0
  415. class TestForgejoBackendApiBase:
  416. def setup_method(self):
  417. self.backend = ForgejoBackend()
  418. def test_derives_api_base_from_repo_url(self):
  419. result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
  420. assert result == "https://forgejo.example.com/api/v1"
  421. def test_derives_api_base_with_port(self):
  422. result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
  423. assert result == "https://forgejo.example.com:3000/api/v1"
  424. def test_invalid_url_raises_value_error(self):
  425. with pytest.raises(ValueError, match="Cannot derive API base"):
  426. self.backend.get_api_base("not-a-url")
  427. def test_parse_url_uses_instance_host(self):
  428. owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
  429. assert owner == "owner"
  430. assert repo == "repo"
  431. class TestGitLabBackend:
  432. def setup_method(self):
  433. self.backend = GitLabBackend()
  434. def test_parse_url_https(self):
  435. owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
  436. assert owner == "owner"
  437. assert repo == "repo"
  438. def test_parse_url_ssh(self):
  439. owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
  440. assert owner == "owner"
  441. assert repo == "repo"
  442. def test_parse_url_invalid_raises(self):
  443. with pytest.raises(ValueError):
  444. self.backend.parse_repo_url("not-a-url")
  445. def test_get_api_base_derives_from_repo_url(self):
  446. result = self.backend.get_api_base("https://gitlab.com/owner/repo")
  447. assert result == "https://gitlab.com/api/v4"
  448. def test_get_api_base_derives_from_self_hosted_url(self):
  449. result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
  450. assert result == "https://my-gitlab.example.com/api/v4"
  451. def test_get_api_base_invalid_url_raises(self):
  452. with pytest.raises(ValueError, match="Cannot derive API base"):
  453. self.backend.get_api_base("not-a-url")
  454. def test_get_headers_uses_bearer_token(self):
  455. headers = self.backend.get_headers("mytoken")
  456. assert headers["Authorization"] == "Bearer mytoken"
  457. assert "Content-Type" in headers
  458. def test_parse_url_subgroup_https(self):
  459. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
  460. assert namespace == "group/subgroup"
  461. assert repo == "project"
  462. def test_parse_url_deep_namespace_https(self):
  463. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
  464. assert namespace == "myorg/team/api"
  465. assert repo == "backend"
  466. def test_parse_url_subgroup_ssh(self):
  467. namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
  468. assert namespace == "group/subgroup"
  469. assert repo == "project"
  470. @pytest.mark.asyncio
  471. async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
  472. backend = GitLabBackend()
  473. repo_url = "https://gitlab.com/group/subgroup/project"
  474. client = AsyncMock()
  475. client.get = AsyncMock(
  476. side_effect=[
  477. _make_mock_response(200, {"name": "bambuddy-backup"}),
  478. _make_mock_response(200, []),
  479. ]
  480. )
  481. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  482. await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
  483. called_url = client.get.call_args_list[0].args[0]
  484. assert "group%2Fsubgroup%2Fproject" in called_url
  485. def _blob_sha(content: dict) -> str:
  486. content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
  487. return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
  488. def _make_mock_response(status_code: int, body=None, text: str = ""):
  489. resp = MagicMock()
  490. resp.status_code = status_code
  491. resp.text = text
  492. resp.json = MagicMock(return_value=body or {})
  493. return resp
  494. class TestGitLabBackendPushFiles:
  495. def setup_method(self):
  496. self.backend = GitLabBackend()
  497. self.repo_url = "https://gitlab.com/owner/repo"
  498. self.token = "glpat-test"
  499. self.branch = "bambuddy-backup"
  500. self.files = {"config/printers.json": {"name": "my-printer"}}
  501. @pytest.mark.asyncio
  502. async def test_skips_commit_when_content_unchanged(self):
  503. sha = _blob_sha(self.files["config/printers.json"])
  504. client = AsyncMock()
  505. client.get = AsyncMock(
  506. side_effect=[
  507. # branch check → branch exists
  508. _make_mock_response(200, {"name": self.branch}),
  509. # tree page 1 → one blob whose sha matches current content
  510. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
  511. # tree page 2 → empty, stop pagination
  512. _make_mock_response(200, []),
  513. ]
  514. )
  515. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  516. assert result["status"] == "skipped"
  517. assert result["files_changed"] == 0
  518. client.post.assert_not_called()
  519. @pytest.mark.asyncio
  520. async def test_commits_when_content_changed(self):
  521. stale_sha = "0000000000000000000000000000000000000000"
  522. client = AsyncMock()
  523. client.get = AsyncMock(
  524. side_effect=[
  525. _make_mock_response(200, {"name": self.branch}),
  526. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
  527. _make_mock_response(200, []), # page 2 empty, stop pagination
  528. ]
  529. )
  530. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  531. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  532. assert result["status"] == "success"
  533. assert result["files_changed"] == 1
  534. client.post.assert_called_once()
  535. @pytest.mark.asyncio
  536. async def test_truncates_upstream_error_body_in_failure_message(self):
  537. client = AsyncMock()
  538. client.get = AsyncMock(
  539. side_effect=[
  540. _make_mock_response(200, {"name": self.branch}),
  541. _make_mock_response(200, []),
  542. ]
  543. )
  544. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  545. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  546. assert result["status"] == "failed"
  547. assert result["message"] == f"Failed to create commit: {'x' * 197}..."
  548. @pytest.mark.asyncio
  549. async def test_creates_new_file_not_in_existing_tree(self):
  550. client = AsyncMock()
  551. client.get = AsyncMock(
  552. side_effect=[
  553. _make_mock_response(200, {"name": self.branch}),
  554. # tree is empty
  555. _make_mock_response(200, []),
  556. ]
  557. )
  558. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
  559. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  560. assert result["status"] == "success"
  561. call_kwargs = client.post.call_args.kwargs["json"]
  562. assert call_kwargs["actions"][0]["action"] == "create"
  563. @pytest.mark.asyncio
  564. async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
  565. """Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
  566. sha = _blob_sha(self.files["config/printers.json"])
  567. page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
  568. page2_items = [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] + [
  569. {"type": "blob", "path": "config/printers.json", "id": sha}
  570. ] # 120 total blobs across two pages
  571. client = AsyncMock()
  572. client.get = AsyncMock(
  573. side_effect=[
  574. _make_mock_response(200, {"name": self.branch}), # branch check
  575. _make_mock_response(200, page1_items), # tree page 1
  576. _make_mock_response(200, page2_items), # tree page 2
  577. _make_mock_response(200, []), # tree page 3 empty, stop
  578. ]
  579. )
  580. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  581. assert result["status"] == "skipped"
  582. client.post.assert_not_called()