test_bambu_cloud.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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_includes_browser_headers(self, cloud_service):
  169. """TOTP verification should include browser-like headers to bypass Cloudflare."""
  170. mock_response = MagicMock()
  171. mock_response.status_code = 200
  172. mock_response.text = '{"token": "test-token"}'
  173. mock_response.json.return_value = {"token": "test-token"}
  174. mock_response.cookies = {}
  175. with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
  176. mock_post.return_value = mock_response
  177. await cloud_service.verify_totp("test-tfa-key", "123456")
  178. # Check headers include User-Agent
  179. call_args = mock_post.call_args
  180. headers = call_args[1]["headers"]
  181. assert "User-Agent" in headers
  182. assert "Mozilla" in headers["User-Agent"]
  183. class TestBambuCloudRegion:
  184. """Region routing — China-region instances must hit api.bambulab.cn."""
  185. def test_global_region_uses_com_base(self):
  186. """Default / 'global' region should use api.bambulab.com."""
  187. cloud = BambuCloudService() # default region
  188. assert cloud.base_url == "https://api.bambulab.com"
  189. cloud_explicit = BambuCloudService(region="global")
  190. assert cloud_explicit.base_url == "https://api.bambulab.com"
  191. def test_china_region_uses_cn_base(self):
  192. """'china' region should use api.bambulab.cn."""
  193. cloud = BambuCloudService(region="china")
  194. assert cloud.base_url == "https://api.bambulab.cn"
  195. @pytest.mark.asyncio
  196. async def test_china_region_login_hits_cn_endpoint(self):
  197. """A login_request from a China-region instance must POST to api.bambulab.cn."""
  198. cloud = BambuCloudService(region="china")
  199. mock_response = MagicMock()
  200. mock_response.status_code = 200
  201. mock_response.json.return_value = {"loginType": "verifyCode"}
  202. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  203. mock_post.return_value = mock_response
  204. await cloud.login_request("test@example.com", "password")
  205. url = mock_post.call_args[0][0]
  206. assert "api.bambulab.cn" in url
  207. assert "api.bambulab.com" not in url
  208. @pytest.mark.asyncio
  209. async def test_china_region_totp_hits_cn_tfa_endpoint(self):
  210. """TOTP verification from a China-region instance uses the CN TFA endpoint."""
  211. cloud = BambuCloudService(region="china")
  212. mock_response = MagicMock()
  213. mock_response.status_code = 200
  214. mock_response.text = '{"token": "t"}'
  215. mock_response.json.return_value = {"token": "t"}
  216. mock_response.cookies = {}
  217. with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
  218. mock_post.return_value = mock_response
  219. await cloud.verify_totp("tfa-key", "123456")
  220. url = mock_post.call_args[0][0]
  221. assert "bambulab.cn/api/sign-in/tfa" in url
  222. assert "bambulab.com" not in url