test_bambu_cloud.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. """Tests for Bambu Cloud service - TOTP and email verification flows."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from backend.app.services.bambu_cloud import BambuCloudService
  5. @pytest.fixture(autouse=True)
  6. def _stub_csrf_handshake():
  7. """Keep the CSRF pre-flight off the network for every test in this module.
  8. ``verify_totp`` fetches a CSRF token from the ``bambulab.com`` web origin
  9. before posting the code (#2696), and returns early without posting when it
  10. cannot get one. The tests below patch only ``post``, so that GET went out
  11. over the real network: it succeeded on any machine that could reach
  12. bambulab.com — which is why this file passed locally — and returned a
  13. tokenless 403 on a CI runner, where six tests then failed asserting on a
  14. ``post`` that never happened.
  15. The handshake itself is covered end to end in
  16. ``tests/unit/test_cloud_totp_csrf.py``, including the no-token path, so
  17. stubbing it here removes a network dependency rather than any coverage.
  18. """
  19. with patch.object(BambuCloudService, "_fetch_csrf_token", new_callable=AsyncMock) as fetch:
  20. fetch.return_value = "csrf-token-for-tests"
  21. yield fetch
  22. class TestBambuCloudLogin:
  23. """Test login flow detection (email vs TOTP)."""
  24. @pytest.fixture
  25. def cloud_service(self):
  26. """Create a BambuCloudService instance."""
  27. return BambuCloudService()
  28. @pytest.mark.asyncio
  29. async def test_login_detects_email_verification(self, cloud_service):
  30. """When loginType is verifyCode, should return email verification type."""
  31. mock_response = MagicMock()
  32. mock_response.status_code = 200
  33. mock_response.json.return_value = {
  34. "loginType": "verifyCode",
  35. }
  36. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  37. mock_post.return_value = mock_response
  38. result = await cloud_service.login_request("test@example.com", "password")
  39. assert result["success"] is False
  40. assert result["needs_verification"] is True
  41. assert result["verification_type"] == "email"
  42. assert result["tfa_key"] is None
  43. assert "email" in result["message"].lower()
  44. @pytest.mark.asyncio
  45. async def test_login_detects_totp(self, cloud_service):
  46. """When loginType is tfa, should return TOTP verification type with tfaKey."""
  47. mock_response = MagicMock()
  48. mock_response.status_code = 200
  49. mock_response.json.return_value = {
  50. "loginType": "tfa",
  51. "tfaKey": "test-tfa-key-123",
  52. }
  53. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  54. mock_post.return_value = mock_response
  55. result = await cloud_service.login_request("test@example.com", "password")
  56. assert result["success"] is False
  57. assert result["needs_verification"] is True
  58. assert result["verification_type"] == "totp"
  59. assert result["tfa_key"] == "test-tfa-key-123"
  60. assert "authenticator" in result["message"].lower()
  61. @pytest.mark.asyncio
  62. async def test_login_direct_success(self, cloud_service):
  63. """When accessToken is returned directly, should succeed without verification."""
  64. mock_response = MagicMock()
  65. mock_response.status_code = 200
  66. mock_response.json.return_value = {
  67. "accessToken": "test-access-token",
  68. "refreshToken": "test-refresh-token",
  69. }
  70. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  71. mock_post.return_value = mock_response
  72. result = await cloud_service.login_request("test@example.com", "password")
  73. assert result["success"] is True
  74. assert result["needs_verification"] is False
  75. assert cloud_service.access_token == "test-access-token"
  76. @pytest.mark.asyncio
  77. async def test_login_failure(self, cloud_service):
  78. """When login fails, should return error message."""
  79. mock_response = MagicMock()
  80. mock_response.status_code = 401
  81. mock_response.json.return_value = {
  82. "message": "Invalid credentials",
  83. }
  84. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  85. mock_post.return_value = mock_response
  86. result = await cloud_service.login_request("test@example.com", "wrong-password")
  87. assert result["success"] is False
  88. assert result["needs_verification"] is False
  89. assert "Invalid credentials" in result["message"]
  90. class TestBambuCloudEmailVerification:
  91. """Test email verification flow."""
  92. @pytest.fixture
  93. def cloud_service(self):
  94. """Create a BambuCloudService instance."""
  95. return BambuCloudService()
  96. @pytest.mark.asyncio
  97. async def test_verify_code_success(self, cloud_service):
  98. """When email code is correct, should return success with token."""
  99. mock_response = MagicMock()
  100. mock_response.status_code = 200
  101. mock_response.json.return_value = {
  102. "accessToken": "test-access-token",
  103. "refreshToken": "test-refresh-token",
  104. }
  105. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  106. mock_post.return_value = mock_response
  107. result = await cloud_service.verify_code("test@example.com", "123456")
  108. assert result["success"] is True
  109. assert cloud_service.access_token == "test-access-token"
  110. @pytest.mark.asyncio
  111. async def test_verify_code_failure(self, cloud_service):
  112. """When email code is incorrect, should return failure."""
  113. mock_response = MagicMock()
  114. mock_response.status_code = 400
  115. mock_response.json.return_value = {
  116. "message": "Invalid verification code",
  117. }
  118. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  119. mock_post.return_value = mock_response
  120. result = await cloud_service.verify_code("test@example.com", "000000")
  121. assert result["success"] is False
  122. assert "Invalid" in result["message"] or "Verification failed" in result["message"]
  123. class TestBambuCloudTOTPVerification:
  124. """Test TOTP verification flow."""
  125. @pytest.fixture
  126. def cloud_service(self):
  127. """Create a BambuCloudService instance."""
  128. return BambuCloudService()
  129. @pytest.mark.asyncio
  130. async def test_verify_totp_success(self, cloud_service):
  131. """When TOTP code is correct, should return success with token."""
  132. mock_response = MagicMock()
  133. mock_response.status_code = 200
  134. mock_response.text = '{"token": "test-access-token"}'
  135. mock_response.json.return_value = {
  136. "token": "test-access-token",
  137. }
  138. mock_response.cookies = {}
  139. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  140. mock_post.return_value = mock_response
  141. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  142. assert result["success"] is True
  143. assert cloud_service.access_token == "test-access-token"
  144. @pytest.mark.asyncio
  145. async def test_verify_totp_uses_correct_endpoint(self, cloud_service):
  146. """TOTP verification should use bambulab.com, not api.bambulab.com."""
  147. mock_response = MagicMock()
  148. mock_response.status_code = 200
  149. mock_response.text = '{"token": "test-token"}'
  150. mock_response.json.return_value = {"token": "test-token"}
  151. mock_response.cookies = {}
  152. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  153. mock_post.return_value = mock_response
  154. await cloud_service.verify_totp("test-tfa-key", "123456")
  155. # Check the URL used
  156. call_args = mock_post.call_args
  157. url = call_args[0][0]
  158. assert "bambulab.com/api/sign-in/tfa" in url
  159. assert "api.bambulab.com" not in url
  160. @pytest.mark.asyncio
  161. async def test_verify_totp_empty_response(self, cloud_service):
  162. """When TOTP returns empty response, should handle gracefully."""
  163. mock_response = MagicMock()
  164. mock_response.status_code = 400
  165. mock_response.text = ""
  166. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  167. mock_post.return_value = mock_response
  168. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  169. assert result["success"] is False
  170. assert "empty response" in result["message"].lower()
  171. @pytest.mark.asyncio
  172. async def test_verify_totp_cloudflare_blocked(self, cloud_service):
  173. """When Cloudflare returns a 'Just a moment...' interstitial instead of
  174. JSON, surface the actionable CF-specific message (issue #1575) rather
  175. than the opaque "Invalid response from Bambu Cloud" parse error."""
  176. mock_response = MagicMock()
  177. mock_response.status_code = 403
  178. mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
  179. mock_response.headers = {}
  180. # json() raises an error when response is HTML
  181. mock_response.json.side_effect = ValueError("No JSON")
  182. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  183. mock_post.return_value = mock_response
  184. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  185. assert result["success"] is False
  186. assert "Cloudflare" in result["message"]
  187. assert "bambulab.com" in result["message"]
  188. @pytest.mark.asyncio
  189. async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
  190. """TOTP verification identifies as Bambuddy, not as a browser.
  191. The TOTP endpoint previously sent a Chrome User-Agent + Origin/Referer
  192. headers under the assumption Cloudflare would block non-browser
  193. identification. Verified 2026-05-12 that ``https://bambulab.com/api/sign-in/tfa``
  194. accepts ``Bambuddy/X.Y.Z`` cleanly — the expected application-level
  195. response comes back, no Cloudflare interstitial. Browser impersonation
  196. was removed to stay clearly on the right side of Bambu Lab's
  197. "no falsified client identity" line from the 2026-05-12 cloud-access
  198. blog post.
  199. """
  200. mock_response = MagicMock()
  201. mock_response.status_code = 200
  202. mock_response.text = '{"token": "test-token"}'
  203. mock_response.json.return_value = {"token": "test-token"}
  204. mock_response.cookies = {}
  205. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  206. mock_post.return_value = mock_response
  207. await cloud_service.verify_totp("test-tfa-key", "123456")
  208. call_args = mock_post.call_args
  209. headers = call_args[1]["headers"]
  210. assert headers["User-Agent"].startswith("Bambuddy/")
  211. # Browser-impersonation strings must not creep back in
  212. assert "Mozilla" not in headers["User-Agent"]
  213. assert "Chrome" not in headers["User-Agent"]
  214. # Origin / Referer headers were spoofing bambulab.com origin — gone
  215. assert "Origin" not in headers
  216. assert "Referer" not in headers
  217. class TestBambuCloudRegion:
  218. """Region routing — China-region instances must hit api.bambulab.cn."""
  219. def test_global_region_uses_com_base(self):
  220. """Default / 'global' region should use api.bambulab.com."""
  221. cloud = BambuCloudService() # default region
  222. assert cloud.base_url == "https://api.bambulab.com"
  223. cloud_explicit = BambuCloudService(region="global")
  224. assert cloud_explicit.base_url == "https://api.bambulab.com"
  225. def test_china_region_uses_cn_base(self):
  226. """'china' region should use api.bambulab.cn."""
  227. cloud = BambuCloudService(region="china")
  228. assert cloud.base_url == "https://api.bambulab.cn"
  229. @pytest.mark.asyncio
  230. async def test_china_region_login_hits_cn_endpoint(self):
  231. """A login_request from a China-region instance must POST to api.bambulab.cn."""
  232. cloud = BambuCloudService(region="china")
  233. mock_response = MagicMock()
  234. mock_response.status_code = 200
  235. mock_response.json.return_value = {"loginType": "verifyCode"}
  236. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  237. mock_post.return_value = mock_response
  238. await cloud.login_request("test@example.com", "password")
  239. url = mock_post.call_args[0][0]
  240. assert "api.bambulab.cn" in url
  241. assert "api.bambulab.com" not in url
  242. @pytest.mark.asyncio
  243. async def test_china_region_totp_hits_cn_tfa_endpoint(self):
  244. """TOTP verification from a China-region instance uses the CN TFA endpoint."""
  245. cloud = BambuCloudService(region="china")
  246. mock_response = MagicMock()
  247. mock_response.status_code = 200
  248. mock_response.text = '{"token": "t"}'
  249. mock_response.json.return_value = {"token": "t"}
  250. mock_response.cookies = {}
  251. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  252. mock_post.return_value = mock_response
  253. await cloud.verify_totp("tfa-key", "123456")
  254. url = mock_post.call_args[0][0]
  255. assert "bambulab.cn/api/sign-in/tfa" in url
  256. assert "bambulab.com" not in url
  257. # ===========================================================================
  258. # Issue #1575: Cloudflare interstitial → actionable error message
  259. # ===========================================================================
  260. class TestCloudflareChallengeDetection:
  261. """The _detect_cloudflare_challenge helper inspects a response and returns
  262. the user-actionable message when CF returned a challenge / mitigation page
  263. instead of JSON. None otherwise."""
  264. # The actual interstitial fragment captured from issue #1575's log — keeping
  265. # this verbatim so future regressions in detection are checked against the
  266. # exact body shape the user hit, not a stylised copy.
  267. _REPORTER_INTERSTITIAL = (
  268. '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...'
  269. '</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
  270. '<meta http-equiv="X-UA-Compatible" content="IE=Edge">'
  271. '<meta name="robots" content="noindex,nofollow">'
  272. '<meta name="viewport" content="width=device-width,initial-scale=1">'
  273. )
  274. def test_just_a_moment_title_in_body(self):
  275. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  276. response = MagicMock()
  277. response.text = self._REPORTER_INTERSTITIAL
  278. response.status_code = 200
  279. response.headers = {}
  280. assert _detect_cloudflare_challenge(response) is not None
  281. def test_challenges_cloudflare_com_in_body(self):
  282. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  283. response = MagicMock()
  284. response.text = (
  285. '<html><body><script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script></body></html>'
  286. )
  287. response.status_code = 200
  288. response.headers = {}
  289. assert _detect_cloudflare_challenge(response) is not None
  290. def test_cf_mitigated_403(self):
  291. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  292. response = MagicMock()
  293. response.text = ""
  294. response.status_code = 403
  295. response.headers = {"cf-mitigated": "challenge"}
  296. assert _detect_cloudflare_challenge(response) is not None
  297. def test_cf_ray_503(self):
  298. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  299. response = MagicMock()
  300. response.text = "<html>Under attack</html>"
  301. response.status_code = 503
  302. response.headers = {"cf-ray": "abc-DEF"}
  303. assert _detect_cloudflare_challenge(response) is not None
  304. def test_real_json_400_is_not_a_challenge(self):
  305. """Application-level 400 with the real "Login failed" JSON the API
  306. normally returns must NOT be misclassified as a CF challenge — that
  307. would suppress the actionable upstream error."""
  308. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  309. response = MagicMock()
  310. response.text = '{"code":5,"error":"Login failed"}'
  311. response.status_code = 400
  312. response.headers = {"cf-ray": "abc-DEF", "server": "cloudflare"}
  313. assert _detect_cloudflare_challenge(response) is None
  314. def test_message_mentions_bambu_lab_and_cloudflare(self):
  315. """The message must clearly attribute the block to Bambu Lab's
  316. Cloudflare protection — not to Bambuddy — so users know what to do."""
  317. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  318. response = MagicMock()
  319. response.text = "<title>Just a moment...</title>"
  320. response.status_code = 200
  321. response.headers = {}
  322. msg = _detect_cloudflare_challenge(response)
  323. assert msg is not None
  324. assert "Cloudflare" in msg
  325. assert "bambulab.com" in msg
  326. @pytest.mark.asyncio
  327. async def test_verify_code_surfaces_cf_message_on_interstitial(self):
  328. """verify_code (email-code path) must surface the CF message when the
  329. endpoint returns an HTML interstitial — same shape as verify_totp."""
  330. cloud = BambuCloudService()
  331. mock_response = MagicMock()
  332. mock_response.status_code = 403
  333. mock_response.text = self._REPORTER_INTERSTITIAL
  334. mock_response.headers = {}
  335. mock_response.json.side_effect = ValueError("No JSON")
  336. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  337. mock_post.return_value = mock_response
  338. result = await cloud.verify_code("test@example.com", "123456")
  339. assert result["success"] is False
  340. assert "Cloudflare" in result["message"]
  341. @pytest.mark.asyncio
  342. async def test_login_request_surfaces_cf_message_on_interstitial(self):
  343. """login_request must surface the CF message when the endpoint returns
  344. an HTML interstitial. Previously the parse error bubbled to
  345. BambuCloudAuthError with an opaque "Expecting value..." detail."""
  346. cloud = BambuCloudService()
  347. mock_response = MagicMock()
  348. mock_response.status_code = 403
  349. mock_response.text = self._REPORTER_INTERSTITIAL
  350. mock_response.headers = {}
  351. mock_response.json.side_effect = ValueError("No JSON")
  352. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  353. mock_post.return_value = mock_response
  354. result = await cloud.login_request("test@example.com", "password")
  355. assert result["success"] is False
  356. assert result["needs_verification"] is False
  357. assert "Cloudflare" in result["message"]
  358. # ===========================================================================
  359. # Issue #1815: PFUS cloud user preset lookup silently 400s in resolver
  360. # ===========================================================================
  361. class TestSlicerSettingVersionParam:
  362. """`/v1/iot-service/api/slicer/setting` endpoints require ?version=XX.YY.ZZ.WW.
  363. The plural GET (`get_slicer_settings`) has always sent it. The singular
  364. GET (`get_setting_detail`) and DELETE (`delete_setting`) hit the same
  365. subtree and were silently omitting it since #1013's compliance rework
  366. (2026-05-12), which surfaced as #1815: every PFUS-prefix cloud user preset
  367. lookup in the slicer_filament_resolver 400'd, so BambuStudio saw the
  368. generic-material fallback instead of the user's actual custom profile
  369. (rescued in most cases by slot-tray_info_idx reuse or K-profile realign,
  370. Bgabor997's spool 54 had neither).
  371. """
  372. def _auth(self) -> BambuCloudService:
  373. cloud = BambuCloudService()
  374. cloud.access_token = "test-token"
  375. return cloud
  376. @pytest.mark.asyncio
  377. async def test_get_setting_detail_sends_version_param(self):
  378. """`get_setting_detail` must include the version query param — without
  379. it Bambu Cloud returns HTTP 400 'field version is not set'."""
  380. cloud = self._auth()
  381. mock_response = MagicMock()
  382. mock_response.status_code = 200
  383. mock_response.json.return_value = {"filament_id": "P4d64437", "name": "Overture Matte PLA"}
  384. with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
  385. mock_get.return_value = mock_response
  386. result = await cloud.get_setting_detail("PFUS992454068158eb")
  387. assert result["filament_id"] == "P4d64437"
  388. url = mock_get.call_args[0][0]
  389. assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
  390. params = mock_get.call_args.kwargs.get("params") or {}
  391. assert params.get("version"), "get_setting_detail must send ?version=… to avoid 400"
  392. @pytest.mark.asyncio
  393. async def test_get_setting_detail_error_includes_response_body(self):
  394. """The 400 body identifies the exact contract violation. Callers include
  395. it in log warnings so a next contract change is self-diagnostic instead
  396. of surfacing an opaque status code (which cost 50 days on #1815)."""
  397. cloud = self._auth()
  398. from backend.app.services.bambu_cloud import BambuCloudError
  399. mock_response = MagicMock()
  400. mock_response.status_code = 400
  401. mock_response.text = "field 'version' is not set"
  402. with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
  403. mock_get.return_value = mock_response
  404. with pytest.raises(BambuCloudError) as exc:
  405. await cloud.get_setting_detail("PFUS992454068158eb")
  406. assert "400" in str(exc.value)
  407. assert "field 'version'" in str(exc.value)
  408. @pytest.mark.asyncio
  409. async def test_delete_setting_sends_version_param(self):
  410. """`delete_setting` hits the same subtree; same requirement applies."""
  411. cloud = self._auth()
  412. mock_response = MagicMock()
  413. mock_response.status_code = 200
  414. mock_response.content = b"{}"
  415. mock_response.json.return_value = {}
  416. with patch.object(cloud._client, "delete", new_callable=AsyncMock) as mock_delete:
  417. mock_delete.return_value = mock_response
  418. result = await cloud.delete_setting("PFUS992454068158eb")
  419. assert result["success"] is True
  420. url = mock_delete.call_args[0][0]
  421. assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
  422. params = mock_delete.call_args.kwargs.get("params") or {}
  423. assert params.get("version"), "delete_setting must send ?version=… to avoid 400"