test_firmware_versions.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """
  2. Unit tests for firmware version listing.
  3. Covers:
  4. - Wiki-page version extraction is restricted to section-heading anchors
  5. (incidental version-like strings in release-note prose must be ignored).
  6. - Merging wiki + download-page versions produces a single list where
  7. wiki-only versions are flagged as unavailable (no download URL).
  8. - buildId disk-persistence + 403 fallback for #1350.
  9. """
  10. import json
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.services.firmware_check import FirmwareCheckService, FirmwareVersion
  14. WIKI_SAMPLE = """
  15. <h2 id="h-01030000-20260303" class="toc-header">01.03.00.00 (20260303)</h2>
  16. <p>Released 20260303</p>
  17. <ul><li>Optimized AMS 2 Pro (requires AMS firmware OTA v02.00.19.47 or newer).</li></ul>
  18. <h2 id="h-01021000-20260209" class="toc-header">01.02.10.00 (20260209)</h2>
  19. <p>Bug fixes.</p>
  20. <h2 id="h-01020200-20251105" class="toc-header">01.02.02.00 (20251105)</h2>
  21. <p>Some more text referencing 00.00.00.00 incidentally.</p>
  22. """
  23. @pytest.mark.asyncio
  24. async def test_wiki_extraction_ignores_prose_version_mentions():
  25. """02.00.19.47 appears only in release notes prose — it must not be listed."""
  26. svc = FirmwareCheckService()
  27. mock_resp = AsyncMock()
  28. mock_resp.status_code = 200
  29. mock_resp.text = WIKI_SAMPLE
  30. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  31. versions = await svc._fetch_all_versions_from_wiki("h2d")
  32. version_strs = [v for v, _ in versions]
  33. assert version_strs == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  34. # The AMS firmware mentioned in prose must not leak in:
  35. assert "02.00.19.47" not in version_strs
  36. assert "00.00.00.00" not in version_strs
  37. # Release dates are captured from the anchor id:
  38. assert versions[0][1] == "20260303"
  39. @pytest.mark.asyncio
  40. async def test_wiki_extraction_returns_empty_for_unknown_api_key():
  41. svc = FirmwareCheckService()
  42. assert await svc._fetch_all_versions_from_wiki("no-such-key") == []
  43. # P2S and X2D wiki pages publish anchor ids without a dash between the
  44. # version bytes and the date (e.g. h-0102000020260409). Regression for #1030
  45. # where the anchor regex required a dash and silently returned no versions,
  46. # causing the UI to fall back to the stale download-page "Latest" value.
  47. P2S_NODASH_ANCHOR_SAMPLE = """
  48. <h2 id="h-0102000020260409" class="toc-header">01.02.00.00(20260409)</h2>
  49. <h2 id="h-0101030020260209" class="toc-header">01.01.03.00(20260209)</h2>
  50. <h2 id="h-0101010020251208" class="toc-header">01.01.01.00(20251208)</h2>
  51. """
  52. @pytest.mark.asyncio
  53. async def test_wiki_extraction_accepts_nodash_anchors():
  54. """P2S/X2D anchors concatenate version+date with no dash — must still parse."""
  55. svc = FirmwareCheckService()
  56. mock_resp = AsyncMock()
  57. mock_resp.status_code = 200
  58. mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE
  59. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  60. versions = await svc._fetch_all_versions_from_wiki("p2s")
  61. assert [v for v, _ in versions] == ["01.02.00.00", "01.01.03.00", "01.01.01.00"]
  62. assert versions[0][1] == "20260409"
  63. # A1, A1-mini and P2S pages render dates in full-width parens (YYYYMMDD)
  64. # rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall
  65. # through to the text-based regex, so it must accept both paren styles.
  66. FULLWIDTH_PAREN_FALLBACK_SAMPLE = """
  67. <h2>01.04.00.01 (20260401)</h2>
  68. <h2>01.03.00.00 (20260101)</h2>
  69. """
  70. @pytest.mark.asyncio
  71. async def test_wiki_extraction_fallback_accepts_fullwidth_parens():
  72. svc = FirmwareCheckService()
  73. mock_resp = AsyncMock()
  74. mock_resp.status_code = 200
  75. mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE
  76. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  77. versions = await svc._fetch_all_versions_from_wiki("a1")
  78. assert [v for v, _ in versions] == ["01.04.00.01", "01.03.00.00"]
  79. assert versions[0][1] == "20260401"
  80. @pytest.mark.asyncio
  81. async def test_get_available_versions_merges_sources():
  82. """
  83. Merged list must include all wiki versions (newest first), populating
  84. download URL + notes from the download-page JSON when present, and
  85. leaving download_url empty when the file is not published.
  86. """
  87. svc = FirmwareCheckService()
  88. wiki = [
  89. ("01.03.00.00", "20260303"),
  90. ("01.02.10.00", "20260209"), # wiki-only — should be "unavailable"
  91. ("01.02.02.00", "20251105"),
  92. ]
  93. download = [
  94. FirmwareVersion(
  95. version="01.03.00.00",
  96. download_url="https://cdn.example/1.bin",
  97. release_notes="notes 1.3",
  98. release_time="2026-03-03",
  99. ),
  100. FirmwareVersion(
  101. version="01.02.02.00",
  102. download_url="https://cdn.example/2.bin",
  103. release_notes="notes 1.2.2",
  104. release_time="2025-11-05",
  105. ),
  106. ]
  107. with (
  108. patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
  109. patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
  110. ):
  111. result = await svc.get_available_versions("H2D")
  112. assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  113. assert result[0].download_url == "https://cdn.example/1.bin"
  114. assert result[0].release_notes == "notes 1.3"
  115. # Wiki-only version has no download URL → treated as unavailable by callers.
  116. assert result[1].download_url == ""
  117. assert result[1].release_notes is None
  118. assert result[1].release_time == "20260209"
  119. assert result[2].download_url == "https://cdn.example/2.bin"
  120. @pytest.mark.asyncio
  121. async def test_get_available_versions_sorts_newest_first():
  122. """Merged list must be sorted descending by version tuple regardless of input order."""
  123. svc = FirmwareCheckService()
  124. wiki = [("01.02.02.00", None)]
  125. download = [
  126. FirmwareVersion(version="01.03.00.00", download_url="a"),
  127. FirmwareVersion(version="01.02.10.00", download_url="b"),
  128. ]
  129. with (
  130. patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
  131. patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
  132. ):
  133. result = await svc.get_available_versions("H2D")
  134. assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  135. @pytest.mark.asyncio
  136. async def test_client_headers_identify_honestly_and_send_browser_accept():
  137. """
  138. The httpx client must identify as Bambuddy (no Chrome impersonation) and
  139. must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't
  140. 403 us for looking like a bare scraper (#1350).
  141. """
  142. svc = FirmwareCheckService()
  143. headers = svc._client.headers
  144. assert headers["User-Agent"].startswith("Bambuddy/")
  145. assert "Chrome" not in headers["User-Agent"]
  146. assert "Accept" in headers
  147. assert "Accept-Language" in headers
  148. @pytest.mark.asyncio
  149. async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
  150. """Successful buildId fetch writes to disk so it survives restart (#1350)."""
  151. monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
  152. svc = FirmwareCheckService()
  153. mock_resp = MagicMock()
  154. mock_resp.status_code = 200
  155. mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
  156. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  157. build_id = await svc._get_build_id()
  158. assert build_id == "abc123xyz"
  159. cache_file = tmp_path / "firmware" / "build_id.json"
  160. assert cache_file.exists()
  161. data = json.loads(cache_file.read_text())
  162. assert data["build_id"] == "abc123xyz"
  163. assert data["fetched_at"] > 0
  164. @pytest.mark.asyncio
  165. async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
  166. """
  167. When bambulab.com 403s (Cloudflare block reported in #1350) we must
  168. fall back to the disk-cached buildId from the previous successful fetch.
  169. Without this the user's screenshots happen: wiki version is detected
  170. but the download URL stays empty forever.
  171. """
  172. monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
  173. # Pre-seed a previously-saved buildId
  174. cache_dir = tmp_path / "firmware"
  175. cache_dir.mkdir(parents=True)
  176. (cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
  177. svc = FirmwareCheckService()
  178. mock_resp = MagicMock()
  179. mock_resp.status_code = 403
  180. mock_resp.text = "<html>Access denied</html>"
  181. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  182. build_id = await svc._get_build_id()
  183. assert build_id == "cached_id_42"
  184. assert svc.download_page_unreachable is True
  185. @pytest.mark.asyncio
  186. async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypatch):
  187. """A 403 on the per-model JSON endpoint also marks the page unreachable."""
  188. monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
  189. svc = FirmwareCheckService()
  190. svc._build_id = "stale_id"
  191. svc._build_id_time = 9999999999.0 # never expires for this test
  192. mock_resp = MagicMock()
  193. mock_resp.status_code = 403
  194. mock_resp.text = "Forbidden"
  195. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  196. result = await svc._fetch_all_versions_from_download_page("x1")
  197. assert result == []
  198. assert svc.download_page_unreachable is True
  199. @pytest.mark.asyncio
  200. async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypatch):
  201. """
  202. If the cached buildId returns 404 (Bambu rebuilt the page), refresh the
  203. buildId once and retry — but don't churn on repeated failures.
  204. """
  205. monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
  206. svc = FirmwareCheckService()
  207. svc._build_id = "stale_id"
  208. svc._build_id_time = 9999999999.0
  209. # First call (with stale buildId) → 404
  210. # Second call (with fresh buildId after refresh) → 200 with versions
  211. stale_resp = MagicMock(status_code=404, text="not found")
  212. fresh_resp = MagicMock(status_code=200)
  213. fresh_resp.json = MagicMock(
  214. return_value={
  215. "pageProps": {
  216. "printerMap": {
  217. "x1": {
  218. "versions": [
  219. {
  220. "version": "01.11.02.00",
  221. "url": "https://cdn/fw.bin",
  222. "release_notes_en": "n",
  223. "release_time": "2025-12-10",
  224. }
  225. ]
  226. }
  227. }
  228. }
  229. }
  230. )
  231. # Page fetch for the buildId refresh
  232. page_resp = MagicMock(status_code=200)
  233. page_resp.text = 'foo "buildId":"fresh_id" bar'
  234. # Sequence: stale 404 → page refresh 200 → fresh 200
  235. get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp])
  236. with patch.object(svc._client, "get", get):
  237. result = await svc._fetch_all_versions_from_download_page("x1")
  238. assert len(result) == 1
  239. assert result[0].version == "01.11.02.00"
  240. assert svc._build_id == "fresh_id"
  241. @pytest.mark.asyncio
  242. async def test_check_for_update_includes_available_versions():
  243. svc = FirmwareCheckService()
  244. available = [
  245. FirmwareVersion(version="01.03.00.00", download_url="https://cdn/1.bin", release_notes="x"),
  246. FirmwareVersion(version="01.02.10.00", download_url=""), # unavailable
  247. ]
  248. with patch.object(svc, "get_available_versions", AsyncMock(return_value=available)):
  249. result = await svc.check_for_update("H2D", "01.02.02.00")
  250. assert result["update_available"] is True
  251. assert result["latest_version"] == "01.03.00.00"
  252. assert len(result["available_versions"]) == 2
  253. assert result["available_versions"][0]["file_available"] is True
  254. assert result["available_versions"][1]["file_available"] is False
  255. assert result["available_versions"][1]["download_url"] is None