test_updates_api.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. """Integration tests for Updates API endpoints."""
  2. from pathlib import Path
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestUpdatesAPI:
  7. @pytest.mark.asyncio
  8. async def test_get_version(self, async_client: AsyncClient):
  9. response = await async_client.get("/api/v1/updates/version")
  10. assert response.status_code == 200
  11. @pytest.mark.asyncio
  12. async def test_apply_update_docker_rejection(self, async_client: AsyncClient):
  13. with (
  14. patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
  15. patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
  16. ):
  17. response = await async_client.post("/api/v1/updates/apply")
  18. result = response.json()
  19. assert result["success"] is False
  20. assert result["is_docker"] is True
  21. assert result.get("is_ha_addon") is not True
  22. # Docker message tells the user to docker compose, not HA.
  23. assert "Docker Compose" in result["message"]
  24. @pytest.mark.asyncio
  25. async def test_apply_update_ha_addon_rejection(self, async_client: AsyncClient):
  26. """HA addons are also Docker, so the route must check HA first and
  27. return the HA-specific message — otherwise users see "run docker
  28. compose" advice they can't follow."""
  29. with (
  30. patch("backend.app.api.routes.updates._is_ha_addon", return_value=True),
  31. patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
  32. ):
  33. response = await async_client.post("/api/v1/updates/apply")
  34. result = response.json()
  35. assert result["success"] is False
  36. assert result["is_ha_addon"] is True
  37. assert result["is_docker"] is True
  38. assert "Home Assistant" in result["message"]
  39. assert "Docker Compose" not in result["message"]
  40. @pytest.mark.asyncio
  41. async def test_apply_update_non_docker(self, async_client: AsyncClient):
  42. """Test non-Docker path - mock _perform_update + _discover_target_release
  43. to prevent side effects (network call to GitHub releases API + actual
  44. git/pip subprocesses)."""
  45. with (
  46. patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
  47. patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
  48. patch(
  49. "backend.app.api.routes.updates._discover_target_release",
  50. new_callable=AsyncMock,
  51. return_value="v9.9.9",
  52. ),
  53. patch("backend.app.api.routes.updates._perform_update", new_callable=AsyncMock),
  54. ):
  55. response = await async_client.post("/api/v1/updates/apply")
  56. assert response.json()["success"] is True
  57. def test_is_docker_with_dockerenv(self):
  58. from backend.app.api.routes.updates import _is_docker_environment
  59. with patch("os.path.exists", return_value=True):
  60. assert _is_docker_environment() is True
  61. def test_is_ha_addon_detects_supervisor_token(self):
  62. """HA Supervisor sets SUPERVISOR_TOKEN on every addon container.
  63. That env-var alone is the canonical HA-addon signal."""
  64. from backend.app.api.routes.updates import _is_ha_addon
  65. with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "abc123"}, clear=False):
  66. assert _is_ha_addon() is True
  67. def test_is_ha_addon_false_outside_supervisor(self):
  68. from backend.app.api.routes.updates import _is_ha_addon
  69. with patch.dict("os.environ", {}, clear=True):
  70. assert _is_ha_addon() is False
  71. def test_is_ha_addon_empty_token_treated_as_unset(self):
  72. """An empty string is not a real token — guard against shells that
  73. export the variable empty."""
  74. from backend.app.api.routes.updates import _is_ha_addon
  75. with patch.dict("os.environ", {"SUPERVISOR_TOKEN": ""}, clear=False):
  76. assert _is_ha_addon() is False
  77. @pytest.mark.asyncio
  78. async def test_check_returns_ha_addon_flag_and_method(self, async_client: AsyncClient):
  79. """`/updates/check` must surface the deployment shape so the frontend
  80. can pick the right CTA. HA must take precedence over Docker because
  81. HA addons run *inside* a Docker container — checking docker first
  82. would mis-classify them."""
  83. import httpx as _httpx
  84. fake_release = {
  85. "tag_name": "v999.9.9",
  86. "name": "Far Future Release",
  87. "body": "",
  88. "html_url": "https://example.invalid/r",
  89. "published_at": "2099-01-01T00:00:00Z",
  90. }
  91. class _Resp:
  92. status_code = 200
  93. def raise_for_status(self):
  94. return None
  95. def json(self):
  96. return [fake_release]
  97. class _FakeClient:
  98. async def __aenter__(self):
  99. return self
  100. async def __aexit__(self, *_):
  101. return None
  102. async def get(self, *_, **__):
  103. return _Resp()
  104. with (
  105. patch.object(_httpx, "AsyncClient", _FakeClient),
  106. patch("backend.app.api.routes.updates._is_ha_addon", return_value=True),
  107. patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
  108. ):
  109. response = await async_client.get("/api/v1/updates/check")
  110. body = response.json()
  111. assert body["is_ha_addon"] is True
  112. assert body["update_method"] == "ha_addon"
  113. # is_docker is preserved alongside so older frontend bundles still
  114. # hit a managed-deployment branch (degrades to Docker UX) instead of
  115. # rendering the in-app Install button.
  116. assert body["is_docker"] is True
  117. @pytest.mark.asyncio
  118. async def test_check_docker_only_returns_docker_method(self, async_client: AsyncClient):
  119. import httpx as _httpx
  120. fake_release = {
  121. "tag_name": "v999.9.9",
  122. "name": "Far Future Release",
  123. "body": "",
  124. "html_url": "https://example.invalid/r",
  125. "published_at": "2099-01-01T00:00:00Z",
  126. }
  127. class _Resp:
  128. status_code = 200
  129. def raise_for_status(self):
  130. return None
  131. def json(self):
  132. return [fake_release]
  133. class _FakeClient:
  134. async def __aenter__(self):
  135. return self
  136. async def __aexit__(self, *_):
  137. return None
  138. async def get(self, *_, **__):
  139. return _Resp()
  140. with (
  141. patch.object(_httpx, "AsyncClient", _FakeClient),
  142. patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
  143. patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
  144. ):
  145. response = await async_client.get("/api/v1/updates/check")
  146. body = response.json()
  147. assert body["is_ha_addon"] is False
  148. assert body["is_docker"] is True
  149. assert body["update_method"] == "docker"
  150. def test_parse_version(self):
  151. from backend.app.api.routes.updates import parse_version
  152. assert parse_version("0.1.5")[:3] == (0, 1, 5)
  153. def test_is_newer_version(self):
  154. from backend.app.api.routes.updates import is_newer_version
  155. assert is_newer_version("0.1.5", "0.1.5b7") is True
  156. def test_parse_github_remote_recognises_ssh_https_and_dotgit(self):
  157. """`_parse_github_remote` must accept the four canonical forms `git
  158. remote -v` prints; anything else returns None so callers can treat
  159. it as 'reset to expected URL'."""
  160. from backend.app.api.routes.updates import _parse_github_remote
  161. assert _parse_github_remote("git@github.com:maziggy/bambuddy.git") == (
  162. "maziggy",
  163. "bambuddy",
  164. )
  165. assert _parse_github_remote("git@github.com:maziggy/bambuddy") == (
  166. "maziggy",
  167. "bambuddy",
  168. )
  169. assert _parse_github_remote("https://github.com/maziggy/bambuddy.git") == (
  170. "maziggy",
  171. "bambuddy",
  172. )
  173. assert _parse_github_remote("https://github.com/maziggy/bambuddy") == (
  174. "maziggy",
  175. "bambuddy",
  176. )
  177. # Non-GitHub host → None (we don't claim ownership over arbitrary
  178. # forge URLs).
  179. assert _parse_github_remote("git@gitlab.com:maziggy/bambuddy.git") is None
  180. # Empty / malformed → None.
  181. assert _parse_github_remote("") is None
  182. assert _parse_github_remote("not-a-url") is None
  183. assert _parse_github_remote("https://github.com/maziggy") is None # no /repo
  184. @pytest.mark.asyncio
  185. async def test_perform_update_preserves_ssh_origin_when_pointing_at_correct_repo(self, tmp_path):
  186. """Regression for the developer-checkout footgun: if origin already
  187. points at github.com/maziggy/bambuddy via SSH, the updater must
  188. leave it alone instead of clobbering it with HTTPS. Pre-fix, every
  189. Apply Update click rewrote `git@github.com:...` to `https://...`,
  190. breaking subsequent `git push` for any developer testing the
  191. upgrade flow against their own checkout."""
  192. from backend.app.api.routes import updates as updates_module
  193. app_dir = tmp_path / "app"
  194. data_dir = tmp_path / "app" / "data"
  195. app_dir.mkdir()
  196. data_dir.mkdir()
  197. (app_dir / "requirements.txt").write_text("fastapi\n")
  198. calls: list[dict] = []
  199. async def fake_create_subprocess_exec(*args, **kwargs):
  200. calls.append({"args": args, "cwd": kwargs.get("cwd")})
  201. proc = MagicMock()
  202. # When the updater asks `git remote get-url origin`, return the
  203. # SSH URL. Every other subprocess returns successfully with no
  204. # output.
  205. if "get-url" in args and "origin" in args:
  206. proc.communicate = AsyncMock(return_value=(b"git@github.com:maziggy/bambuddy.git\n", b""))
  207. else:
  208. proc.communicate = AsyncMock(return_value=(b"", b""))
  209. proc.returncode = 0
  210. return proc
  211. with (
  212. patch.object(updates_module.settings, "base_dir", data_dir),
  213. patch.object(updates_module.settings, "app_dir", app_dir),
  214. patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
  215. patch.object(
  216. updates_module.asyncio,
  217. "create_subprocess_exec",
  218. side_effect=fake_create_subprocess_exec,
  219. ),
  220. ):
  221. await updates_module._perform_update("v0.2.4b1")
  222. # The updater MUST NOT have run `git remote set-url origin <https>`
  223. # because origin already pointed at the right repo over SSH.
  224. set_url_calls = [c for c in calls if "set-url" in c["args"] and "origin" in c["args"]]
  225. assert not set_url_calls, (
  226. "Updater clobbered an SSH origin pointing at the correct repo. "
  227. "Captured set-url calls: " + repr([c["args"] for c in set_url_calls])
  228. )
  229. @pytest.mark.asyncio
  230. async def test_perform_update_resets_origin_when_pointing_elsewhere(self, tmp_path):
  231. """Defensive: if origin points at a fork or unrelated repo (or is
  232. missing), the updater should still rewrite it to the canonical
  233. HTTPS URL so subsequent fetch / reset works against the right
  234. repo. This is the original behaviour that the SSH-preservation
  235. fix above must NOT regress."""
  236. from backend.app.api.routes import updates as updates_module
  237. from backend.app.core.config import GITHUB_REPO
  238. app_dir = tmp_path / "app"
  239. data_dir = tmp_path / "app" / "data"
  240. app_dir.mkdir()
  241. data_dir.mkdir()
  242. (app_dir / "requirements.txt").write_text("fastapi\n")
  243. calls: list[dict] = []
  244. async def fake_create_subprocess_exec(*args, **kwargs):
  245. calls.append({"args": args, "cwd": kwargs.get("cwd")})
  246. proc = MagicMock()
  247. # origin is set to a fork — must be rewritten.
  248. if "get-url" in args and "origin" in args:
  249. proc.communicate = AsyncMock(return_value=(b"git@github.com:somefork/bambuddy.git\n", b""))
  250. else:
  251. proc.communicate = AsyncMock(return_value=(b"", b""))
  252. proc.returncode = 0
  253. return proc
  254. with (
  255. patch.object(updates_module.settings, "base_dir", data_dir),
  256. patch.object(updates_module.settings, "app_dir", app_dir),
  257. patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
  258. patch.object(
  259. updates_module.asyncio,
  260. "create_subprocess_exec",
  261. side_effect=fake_create_subprocess_exec,
  262. ),
  263. ):
  264. await updates_module._perform_update("v0.2.4b1")
  265. set_url_calls = [c for c in calls if "set-url" in c["args"] and "origin" in c["args"]]
  266. assert set_url_calls, "Updater must rewrite origin when it points at a fork."
  267. rewritten_to = set_url_calls[0]["args"][-1]
  268. assert rewritten_to == f"https://github.com/{GITHUB_REPO}.git", (
  269. f"Expected origin to be reset to canonical HTTPS URL; got: {rewritten_to}"
  270. )
  271. @pytest.mark.asyncio
  272. async def test_perform_update_resets_to_target_ref_not_hardcoded_main(self, tmp_path):
  273. """Regression for the hardcoded-`origin/main` limitation: the in-app
  274. updater must reset to the caller-supplied target ref (typically a
  275. release tag like `v0.2.4b1` discovered from the GitHub releases API)
  276. so beta releases that don't live on main can actually be installed.
  277. Pre-fix, `_perform_update` issued `git reset --hard origin/main`
  278. verbatim and silently no-op'd whenever the latest release wasn't on
  279. main — leaving a 0.2.3.x user clicking *Apply Update* stranded on
  280. 0.2.3.x. Also asserts the fetch step uses `--tags` so a tag ref is
  281. actually resolvable post-fetch."""
  282. from backend.app.api.routes import updates as updates_module
  283. app_dir = tmp_path / "app"
  284. data_dir = tmp_path / "app" / "data"
  285. app_dir.mkdir()
  286. data_dir.mkdir()
  287. (app_dir / "requirements.txt").write_text("fastapi\n")
  288. calls: list[dict] = []
  289. async def fake_create_subprocess_exec(*args, **kwargs):
  290. calls.append({"args": args, "cwd": kwargs.get("cwd")})
  291. proc = MagicMock()
  292. if "get-url" in args and "origin" in args:
  293. proc.communicate = AsyncMock(return_value=(b"git@github.com:maziggy/bambuddy.git\n", b""))
  294. else:
  295. proc.communicate = AsyncMock(return_value=(b"", b""))
  296. proc.returncode = 0
  297. return proc
  298. with (
  299. patch.object(updates_module.settings, "base_dir", data_dir),
  300. patch.object(updates_module.settings, "app_dir", app_dir),
  301. patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
  302. patch.object(
  303. updates_module.asyncio,
  304. "create_subprocess_exec",
  305. side_effect=fake_create_subprocess_exec,
  306. ),
  307. ):
  308. await updates_module._perform_update("v0.2.4b1")
  309. # Reset target must be the caller-supplied ref, not "origin/main".
  310. reset_calls = [c for c in calls if "reset" in c["args"] and "--hard" in c["args"]]
  311. assert reset_calls, "git reset must be invoked"
  312. reset_target = reset_calls[0]["args"][-1]
  313. assert reset_target == "v0.2.4b1", (
  314. f"Expected reset target to be the caller-supplied ref 'v0.2.4b1'; "
  315. f"got {reset_target!r}. Regression to a hardcoded 'origin/main' "
  316. "would re-introduce the in-app-updater-can't-install-betas bug."
  317. )
  318. # Fetch must include --tags so v0.2.4b1 (a tag) is locally resolvable.
  319. fetch_calls = [c for c in calls if "fetch" in c["args"]]
  320. assert fetch_calls
  321. assert "--tags" in fetch_calls[0]["args"], (
  322. "Fetch must use --tags so release-tag refs (the production path "
  323. "for tag-based updates) are resolvable for the subsequent reset. "
  324. f"Captured fetch call: {fetch_calls[0]['args']}"
  325. )
  326. @pytest.mark.asyncio
  327. async def test_apply_update_passes_discovered_release_to_perform_update(self, async_client: AsyncClient):
  328. """End-to-end glue: the route handler calls `_discover_target_release`
  329. to pick the tag (respecting include_beta_updates), then schedules
  330. `_perform_update` with that tag — not with no arg, not with main."""
  331. from backend.app.api.routes import updates as updates_module
  332. captured_ref: list[str] = []
  333. async def fake_perform_update(target_ref):
  334. captured_ref.append(target_ref)
  335. async def fake_discover(_db):
  336. return "v0.2.4b1"
  337. with (
  338. patch.object(updates_module, "_is_ha_addon", return_value=False),
  339. patch.object(updates_module, "_is_docker_environment", return_value=False),
  340. patch.object(updates_module, "_perform_update", side_effect=fake_perform_update),
  341. patch.object(updates_module, "_discover_target_release", side_effect=fake_discover),
  342. ):
  343. response = await async_client.post("/api/v1/updates/apply")
  344. assert response.json()["success"] is True
  345. assert captured_ref == ["v0.2.4b1"], (
  346. f"apply_update must pass the discovered tag to _perform_update; captured invocations: {captured_ref}"
  347. )
  348. @pytest.mark.asyncio
  349. async def test_apply_update_returns_clear_error_when_no_release_resolves(self, async_client: AsyncClient):
  350. """If GitHub is unreachable or no release matches the user's channel,
  351. the route returns a useful error instead of silently kicking off an
  352. update that can't possibly land. Avoids the previous failure mode
  353. where in-app update appeared to succeed but did nothing."""
  354. from backend.app.api.routes import updates as updates_module
  355. async def fake_discover(_db):
  356. return None
  357. # The route guards against a concurrent update via the module-global
  358. # `_update_status` — reset it so a previous test that left the status
  359. # mid-flight doesn't short-circuit this one.
  360. updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
  361. with (
  362. patch.object(updates_module, "_is_ha_addon", return_value=False),
  363. patch.object(updates_module, "_is_docker_environment", return_value=False),
  364. patch.object(updates_module, "_discover_target_release", side_effect=fake_discover),
  365. ):
  366. response = await async_client.post("/api/v1/updates/apply")
  367. body = response.json()
  368. assert body["success"] is False
  369. assert "release" in body["message"].lower()
  370. @pytest.mark.asyncio
  371. async def test_perform_update_runs_pip_in_app_dir_not_data_dir(self, tmp_path):
  372. """Native install: `requirements.txt` lives at INSTALL_PATH (the source-
  373. code dir), NOT at DATA_DIR (where systemd sets DATA_DIR=INSTALL_PATH/data).
  374. Pre-fix, the updater ran `pip install -r requirements.txt` with
  375. `cwd=settings.base_dir`, which on a native install resolves to the data
  376. dir — `requirements.txt` isn't there and pip fails with `Could not open
  377. requirements file`. The fix: pip's cwd is `settings.app_dir` (the source
  378. tree) so it can actually find the file.
  379. This test mocks every subprocess so it can capture the cwd of each call
  380. and assert that the pip step runs in app_dir while git steps continue
  381. to run in base_dir (their existing behaviour — git walks up to find
  382. `.git` so that path keeps working)."""
  383. from backend.app.api.routes import updates as updates_module
  384. # Set up fake install layout: app_dir has requirements.txt, data_dir is
  385. # a sibling (mirroring `INSTALL_PATH=/opt/bambuddy`, `DATA_DIR=/opt/bambuddy/data`).
  386. app_dir = tmp_path / "app"
  387. data_dir = tmp_path / "app" / "data"
  388. app_dir.mkdir()
  389. data_dir.mkdir()
  390. (app_dir / "requirements.txt").write_text("fastapi\n")
  391. # Capture every subprocess call's cwd + the executable token.
  392. calls: list[dict] = []
  393. async def fake_create_subprocess_exec(*args, **kwargs):
  394. calls.append({"args": args, "cwd": kwargs.get("cwd")})
  395. proc = MagicMock()
  396. proc.communicate = AsyncMock(return_value=(b"", b""))
  397. proc.returncode = 0
  398. return proc
  399. with (
  400. patch.object(updates_module.settings, "base_dir", data_dir),
  401. patch.object(updates_module.settings, "app_dir", app_dir),
  402. patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
  403. patch.object(
  404. updates_module.asyncio,
  405. "create_subprocess_exec",
  406. side_effect=fake_create_subprocess_exec,
  407. ),
  408. ):
  409. await updates_module._perform_update("v0.2.4b1")
  410. # Find the pip invocation (sys.executable + "-m" + "pip" + "install").
  411. pip_calls = [c for c in calls if "pip" in c["args"] and "install" in c["args"]]
  412. assert pip_calls, "pip install was never invoked. Captured: " + repr([c["args"] for c in calls])
  413. pip_cwd = pip_calls[0]["cwd"]
  414. assert pip_cwd == str(app_dir), (
  415. f"pip install must run in app_dir ({app_dir}) so it finds "
  416. f"requirements.txt; got cwd={pip_cwd}. Regression to base_dir "
  417. f"breaks every native-install upgrade."
  418. )
  419. # Sanity check: the requirements.txt that pip would read actually exists
  420. # at the captured cwd. If this fails the cwd is wrong even if it isn't
  421. # base_dir — useful diagnostic if someone refactors path handling.
  422. assert (Path(pip_cwd) / "requirements.txt").exists()