test_bambu_cloud.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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 blocks request, should handle gracefully."""
  157. mock_response = MagicMock()
  158. mock_response.status_code = 403
  159. mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
  160. # json() raises an error when response is HTML
  161. mock_response.json.side_effect = ValueError("No JSON")
  162. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  163. mock_post.return_value = mock_response
  164. result = await cloud_service.verify_totp("test-tfa-key", "123456")
  165. assert result["success"] is False
  166. assert "Invalid response" in result["message"]
  167. @pytest.mark.asyncio
  168. async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
  169. """TOTP verification identifies as Bambuddy, not as a browser.
  170. The TOTP endpoint previously sent a Chrome User-Agent + Origin/Referer
  171. headers under the assumption Cloudflare would block non-browser
  172. identification. Verified 2026-05-12 that ``https://bambulab.com/api/sign-in/tfa``
  173. accepts ``Bambuddy/X.Y.Z`` cleanly — the expected application-level
  174. response comes back, no Cloudflare interstitial. Browser impersonation
  175. was removed to stay clearly on the right side of Bambu Lab's
  176. "no falsified client identity" line from the 2026-05-12 cloud-access
  177. blog post.
  178. """
  179. mock_response = MagicMock()
  180. mock_response.status_code = 200
  181. mock_response.text = '{"token": "test-token"}'
  182. mock_response.json.return_value = {"token": "test-token"}
  183. mock_response.cookies = {}
  184. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  185. mock_post.return_value = mock_response
  186. await cloud_service.verify_totp("test-tfa-key", "123456")
  187. call_args = mock_post.call_args
  188. headers = call_args[1]["headers"]
  189. assert headers["User-Agent"].startswith("Bambuddy/")
  190. # Browser-impersonation strings must not creep back in
  191. assert "Mozilla" not in headers["User-Agent"]
  192. assert "Chrome" not in headers["User-Agent"]
  193. # Origin / Referer headers were spoofing bambulab.com origin — gone
  194. assert "Origin" not in headers
  195. assert "Referer" not in headers
  196. class TestBambuCloudRegion:
  197. """Region routing — China-region instances must hit api.bambulab.cn."""
  198. def test_global_region_uses_com_base(self):
  199. """Default / 'global' region should use api.bambulab.com."""
  200. cloud = BambuCloudService() # default region
  201. assert cloud.base_url == "https://api.bambulab.com"
  202. cloud_explicit = BambuCloudService(region="global")
  203. assert cloud_explicit.base_url == "https://api.bambulab.com"
  204. def test_china_region_uses_cn_base(self):
  205. """'china' region should use api.bambulab.cn."""
  206. cloud = BambuCloudService(region="china")
  207. assert cloud.base_url == "https://api.bambulab.cn"
  208. @pytest.mark.asyncio
  209. async def test_china_region_login_hits_cn_endpoint(self):
  210. """A login_request from a China-region instance must POST to api.bambulab.cn."""
  211. cloud = BambuCloudService(region="china")
  212. mock_response = MagicMock()
  213. mock_response.status_code = 200
  214. mock_response.json.return_value = {"loginType": "verifyCode"}
  215. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  216. mock_post.return_value = mock_response
  217. await cloud.login_request("test@example.com", "password")
  218. url = mock_post.call_args[0][0]
  219. assert "api.bambulab.cn" in url
  220. assert "api.bambulab.com" not in url
  221. @pytest.mark.asyncio
  222. async def test_china_region_totp_hits_cn_tfa_endpoint(self):
  223. """TOTP verification from a China-region instance uses the CN TFA endpoint."""
  224. cloud = BambuCloudService(region="china")
  225. mock_response = MagicMock()
  226. mock_response.status_code = 200
  227. mock_response.text = '{"token": "t"}'
  228. mock_response.json.return_value = {"token": "t"}
  229. mock_response.cookies = {}
  230. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  231. mock_post.return_value = mock_response
  232. await cloud.verify_totp("tfa-key", "123456")
  233. url = mock_post.call_args[0][0]
  234. assert "bambulab.cn/api/sign-in/tfa" in url
  235. assert "bambulab.com" not in url