test_bambu_cloud.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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. class TestBambuCloudLogin:
  6. """Test login flow detection (email vs TOTP)."""
  7. @pytest.fixture
  8. def cloud_service(self):
  9. """Create a BambuCloudService instance."""
  10. return BambuCloudService()
  11. @pytest.mark.asyncio
  12. async def test_login_detects_email_verification(self, cloud_service):
  13. """When loginType is verifyCode, should return email verification type."""
  14. mock_response = MagicMock()
  15. mock_response.status_code = 200
  16. mock_response.json.return_value = {
  17. "loginType": "verifyCode",
  18. }
  19. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  20. mock_post.return_value = mock_response
  21. result = await cloud_service.login_request("test@example.com", "password")
  22. assert result["success"] is False
  23. assert result["needs_verification"] is True
  24. assert result["verification_type"] == "email"
  25. assert result["tfa_key"] is None
  26. assert "email" in result["message"].lower()
  27. @pytest.mark.asyncio
  28. async def test_login_detects_totp(self, cloud_service):
  29. """When loginType is tfa, should return TOTP verification type with tfaKey."""
  30. mock_response = MagicMock()
  31. mock_response.status_code = 200
  32. mock_response.json.return_value = {
  33. "loginType": "tfa",
  34. "tfaKey": "test-tfa-key-123",
  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"] == "totp"
  42. assert result["tfa_key"] == "test-tfa-key-123"
  43. assert "authenticator" in result["message"].lower()
  44. @pytest.mark.asyncio
  45. async def test_login_direct_success(self, cloud_service):
  46. """When accessToken is returned directly, should succeed without verification."""
  47. mock_response = MagicMock()
  48. mock_response.status_code = 200
  49. mock_response.json.return_value = {
  50. "accessToken": "test-access-token",
  51. "refreshToken": "test-refresh-token",
  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 True
  57. assert result["needs_verification"] is False
  58. assert cloud_service.access_token == "test-access-token"
  59. @pytest.mark.asyncio
  60. async def test_login_failure(self, cloud_service):
  61. """When login fails, should return error message."""
  62. mock_response = MagicMock()
  63. mock_response.status_code = 401
  64. mock_response.json.return_value = {
  65. "message": "Invalid credentials",
  66. }
  67. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  68. mock_post.return_value = mock_response
  69. result = await cloud_service.login_request("test@example.com", "wrong-password")
  70. assert result["success"] is False
  71. assert result["needs_verification"] is False
  72. assert "Invalid credentials" in result["message"]
  73. class TestBambuCloudEmailVerification:
  74. """Test email verification flow."""
  75. @pytest.fixture
  76. def cloud_service(self):
  77. """Create a BambuCloudService instance."""
  78. return BambuCloudService()
  79. @pytest.mark.asyncio
  80. async def test_verify_code_success(self, cloud_service):
  81. """When email code is correct, should return success with token."""
  82. mock_response = MagicMock()
  83. mock_response.status_code = 200
  84. mock_response.json.return_value = {
  85. "accessToken": "test-access-token",
  86. "refreshToken": "test-refresh-token",
  87. }
  88. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  89. mock_post.return_value = mock_response
  90. result = await cloud_service.verify_code("test@example.com", "123456")
  91. assert result["success"] is True
  92. assert cloud_service.access_token == "test-access-token"
  93. @pytest.mark.asyncio
  94. async def test_verify_code_failure(self, cloud_service):
  95. """When email code is incorrect, should return failure."""
  96. mock_response = MagicMock()
  97. mock_response.status_code = 400
  98. mock_response.json.return_value = {
  99. "message": "Invalid verification code",
  100. }
  101. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  102. mock_post.return_value = mock_response
  103. result = await cloud_service.verify_code("test@example.com", "000000")
  104. assert result["success"] is False
  105. assert "Invalid" in result["message"] or "Verification failed" in result["message"]
  106. class TestBambuCloudTOTPVerification:
  107. """Test TOTP verification flow."""
  108. @pytest.fixture
  109. def cloud_service(self):
  110. """Create a BambuCloudService instance."""
  111. return BambuCloudService()
  112. @pytest.mark.asyncio
  113. async def test_verify_totp_success(self, cloud_service):
  114. """When TOTP code is correct, should return success with token."""
  115. mock_response = MagicMock()
  116. mock_response.status_code = 200
  117. mock_response.text = '{"token": "test-access-token"}'
  118. mock_response.json.return_value = {
  119. "token": "test-access-token",
  120. }
  121. mock_response.cookies = {}
  122. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  123. mock_post.return_value = mock_response
  124. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  125. assert result["success"] is True
  126. assert cloud_service.access_token == "test-access-token"
  127. @pytest.mark.asyncio
  128. async def test_verify_totp_uses_correct_endpoint(self, cloud_service):
  129. """TOTP verification should use bambulab.com, not api.bambulab.com."""
  130. mock_response = MagicMock()
  131. mock_response.status_code = 200
  132. mock_response.text = '{"token": "test-token"}'
  133. mock_response.json.return_value = {"token": "test-token"}
  134. mock_response.cookies = {}
  135. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  136. mock_post.return_value = mock_response
  137. await cloud_service.verify_totp("test-tfa-key", "123456")
  138. # Check the URL used
  139. call_args = mock_post.call_args
  140. url = call_args[0][0]
  141. assert "bambulab.com/api/sign-in/tfa" in url
  142. assert "api.bambulab.com" not in url
  143. @pytest.mark.asyncio
  144. async def test_verify_totp_empty_response(self, cloud_service):
  145. """When TOTP returns empty response, should handle gracefully."""
  146. mock_response = MagicMock()
  147. mock_response.status_code = 400
  148. mock_response.text = ""
  149. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  150. mock_post.return_value = mock_response
  151. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  152. assert result["success"] is False
  153. assert "empty response" in result["message"].lower()
  154. @pytest.mark.asyncio
  155. async def test_verify_totp_cloudflare_blocked(self, cloud_service):
  156. """When Cloudflare returns a 'Just a moment...' interstitial instead of
  157. JSON, surface the actionable CF-specific message (issue #1575) rather
  158. than the opaque "Invalid response from Bambu Cloud" parse error."""
  159. mock_response = MagicMock()
  160. mock_response.status_code = 403
  161. mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
  162. mock_response.headers = {}
  163. # json() raises an error when response is HTML
  164. mock_response.json.side_effect = ValueError("No JSON")
  165. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  166. mock_post.return_value = mock_response
  167. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  168. assert result["success"] is False
  169. assert "Cloudflare" in result["message"]
  170. assert "bambulab.com" in result["message"]
  171. @pytest.mark.asyncio
  172. async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
  173. """TOTP verification identifies as Bambuddy, not as a browser.
  174. The TOTP endpoint previously sent a Chrome User-Agent + Origin/Referer
  175. headers under the assumption Cloudflare would block non-browser
  176. identification. Verified 2026-05-12 that ``https://bambulab.com/api/sign-in/tfa``
  177. accepts ``Bambuddy/X.Y.Z`` cleanly — the expected application-level
  178. response comes back, no Cloudflare interstitial. Browser impersonation
  179. was removed to stay clearly on the right side of Bambu Lab's
  180. "no falsified client identity" line from the 2026-05-12 cloud-access
  181. blog post.
  182. """
  183. mock_response = MagicMock()
  184. mock_response.status_code = 200
  185. mock_response.text = '{"token": "test-token"}'
  186. mock_response.json.return_value = {"token": "test-token"}
  187. mock_response.cookies = {}
  188. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  189. mock_post.return_value = mock_response
  190. await cloud_service.verify_totp("test-tfa-key", "123456")
  191. call_args = mock_post.call_args
  192. headers = call_args[1]["headers"]
  193. assert headers["User-Agent"].startswith("Bambuddy/")
  194. # Browser-impersonation strings must not creep back in
  195. assert "Mozilla" not in headers["User-Agent"]
  196. assert "Chrome" not in headers["User-Agent"]
  197. # Origin / Referer headers were spoofing bambulab.com origin — gone
  198. assert "Origin" not in headers
  199. assert "Referer" not in headers
  200. class TestBambuCloudRegion:
  201. """Region routing — China-region instances must hit api.bambulab.cn."""
  202. def test_global_region_uses_com_base(self):
  203. """Default / 'global' region should use api.bambulab.com."""
  204. cloud = BambuCloudService() # default region
  205. assert cloud.base_url == "https://api.bambulab.com"
  206. cloud_explicit = BambuCloudService(region="global")
  207. assert cloud_explicit.base_url == "https://api.bambulab.com"
  208. def test_china_region_uses_cn_base(self):
  209. """'china' region should use api.bambulab.cn."""
  210. cloud = BambuCloudService(region="china")
  211. assert cloud.base_url == "https://api.bambulab.cn"
  212. @pytest.mark.asyncio
  213. async def test_china_region_login_hits_cn_endpoint(self):
  214. """A login_request from a China-region instance must POST to api.bambulab.cn."""
  215. cloud = BambuCloudService(region="china")
  216. mock_response = MagicMock()
  217. mock_response.status_code = 200
  218. mock_response.json.return_value = {"loginType": "verifyCode"}
  219. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  220. mock_post.return_value = mock_response
  221. await cloud.login_request("test@example.com", "password")
  222. url = mock_post.call_args[0][0]
  223. assert "api.bambulab.cn" in url
  224. assert "api.bambulab.com" not in url
  225. @pytest.mark.asyncio
  226. async def test_china_region_totp_hits_cn_tfa_endpoint(self):
  227. """TOTP verification from a China-region instance uses the CN TFA endpoint."""
  228. cloud = BambuCloudService(region="china")
  229. mock_response = MagicMock()
  230. mock_response.status_code = 200
  231. mock_response.text = '{"token": "t"}'
  232. mock_response.json.return_value = {"token": "t"}
  233. mock_response.cookies = {}
  234. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  235. mock_post.return_value = mock_response
  236. await cloud.verify_totp("tfa-key", "123456")
  237. url = mock_post.call_args[0][0]
  238. assert "bambulab.cn/api/sign-in/tfa" in url
  239. assert "bambulab.com" not in url
  240. # ===========================================================================
  241. # Issue #1575: Cloudflare interstitial → actionable error message
  242. # ===========================================================================
  243. class TestCloudflareChallengeDetection:
  244. """The _detect_cloudflare_challenge helper inspects a response and returns
  245. the user-actionable message when CF returned a challenge / mitigation page
  246. instead of JSON. None otherwise."""
  247. # The actual interstitial fragment captured from issue #1575's log — keeping
  248. # this verbatim so future regressions in detection are checked against the
  249. # exact body shape the user hit, not a stylised copy.
  250. _REPORTER_INTERSTITIAL = (
  251. '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...'
  252. '</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
  253. '<meta http-equiv="X-UA-Compatible" content="IE=Edge">'
  254. '<meta name="robots" content="noindex,nofollow">'
  255. '<meta name="viewport" content="width=device-width,initial-scale=1">'
  256. )
  257. def test_just_a_moment_title_in_body(self):
  258. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  259. response = MagicMock()
  260. response.text = self._REPORTER_INTERSTITIAL
  261. response.status_code = 200
  262. response.headers = {}
  263. assert _detect_cloudflare_challenge(response) is not None
  264. def test_challenges_cloudflare_com_in_body(self):
  265. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  266. response = MagicMock()
  267. response.text = (
  268. '<html><body><script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script></body></html>'
  269. )
  270. response.status_code = 200
  271. response.headers = {}
  272. assert _detect_cloudflare_challenge(response) is not None
  273. def test_cf_mitigated_403(self):
  274. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  275. response = MagicMock()
  276. response.text = ""
  277. response.status_code = 403
  278. response.headers = {"cf-mitigated": "challenge"}
  279. assert _detect_cloudflare_challenge(response) is not None
  280. def test_cf_ray_503(self):
  281. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  282. response = MagicMock()
  283. response.text = "<html>Under attack</html>"
  284. response.status_code = 503
  285. response.headers = {"cf-ray": "abc-DEF"}
  286. assert _detect_cloudflare_challenge(response) is not None
  287. def test_real_json_400_is_not_a_challenge(self):
  288. """Application-level 400 with the real "Login failed" JSON the API
  289. normally returns must NOT be misclassified as a CF challenge — that
  290. would suppress the actionable upstream error."""
  291. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  292. response = MagicMock()
  293. response.text = '{"code":5,"error":"Login failed"}'
  294. response.status_code = 400
  295. response.headers = {"cf-ray": "abc-DEF", "server": "cloudflare"}
  296. assert _detect_cloudflare_challenge(response) is None
  297. def test_message_mentions_bambu_lab_and_cloudflare(self):
  298. """The message must clearly attribute the block to Bambu Lab's
  299. Cloudflare protection — not to Bambuddy — so users know what to do."""
  300. from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
  301. response = MagicMock()
  302. response.text = "<title>Just a moment...</title>"
  303. response.status_code = 200
  304. response.headers = {}
  305. msg = _detect_cloudflare_challenge(response)
  306. assert msg is not None
  307. assert "Cloudflare" in msg
  308. assert "bambulab.com" in msg
  309. @pytest.mark.asyncio
  310. async def test_verify_code_surfaces_cf_message_on_interstitial(self):
  311. """verify_code (email-code path) must surface the CF message when the
  312. endpoint returns an HTML interstitial — same shape as verify_totp."""
  313. cloud = BambuCloudService()
  314. mock_response = MagicMock()
  315. mock_response.status_code = 403
  316. mock_response.text = self._REPORTER_INTERSTITIAL
  317. mock_response.headers = {}
  318. mock_response.json.side_effect = ValueError("No JSON")
  319. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  320. mock_post.return_value = mock_response
  321. result = await cloud.verify_code("test@example.com", "123456")
  322. assert result["success"] is False
  323. assert "Cloudflare" in result["message"]
  324. @pytest.mark.asyncio
  325. async def test_login_request_surfaces_cf_message_on_interstitial(self):
  326. """login_request must surface the CF message when the endpoint returns
  327. an HTML interstitial. Previously the parse error bubbled to
  328. BambuCloudAuthError with an opaque "Expecting value..." detail."""
  329. cloud = BambuCloudService()
  330. mock_response = MagicMock()
  331. mock_response.status_code = 403
  332. mock_response.text = self._REPORTER_INTERSTITIAL
  333. mock_response.headers = {}
  334. mock_response.json.side_effect = ValueError("No JSON")
  335. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  336. mock_post.return_value = mock_response
  337. result = await cloud.login_request("test@example.com", "password")
  338. assert result["success"] is False
  339. assert result["needs_verification"] is False
  340. assert "Cloudflare" in result["message"]