test_cloud_upstream_errors.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Bambu Cloud being unreachable is a bad gateway, not a server fault.
  2. The cloud routes answered 500 when Bambu Cloud failed (no network, a refused
  3. connection, an error from the cloud), and a login or verification that never
  4. reached Bambu Cloud answered 401 as if the credentials were wrong. Both are an
  5. upstream failure: 502, as the preset lookups already answer.
  6. """
  7. from __future__ import annotations
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import httpx
  10. import pytest
  11. from httpx import AsyncClient
  12. from backend.app.services.bambu_cloud import BambuCloudAuthError, BambuCloudError, BambuCloudService
  13. UPSTREAM = BambuCloudError("Request failed: [Errno 101] Network is unreachable")
  14. def _failing_cloud() -> MagicMock:
  15. cloud = MagicMock()
  16. cloud.is_authenticated = True
  17. for method in (
  18. "get_slicer_settings",
  19. "get_setting_detail",
  20. "get_devices",
  21. "create_setting",
  22. "update_setting",
  23. "delete_setting",
  24. ):
  25. setattr(cloud, method, AsyncMock(side_effect=UPSTREAM))
  26. cloud.close = AsyncMock()
  27. return cloud
  28. @pytest.mark.asyncio
  29. @pytest.mark.integration
  30. @pytest.mark.parametrize(
  31. ("method", "path", "body"),
  32. [
  33. ("GET", "/api/v1/cloud/settings", None),
  34. ("GET", "/api/v1/cloud/settings/PFUS123", None),
  35. ("GET", "/api/v1/cloud/devices", None),
  36. ("GET", "/api/v1/cloud/firmware-updates", None),
  37. ("POST", "/api/v1/cloud/settings", {"type": "filament", "name": "N", "base_id": "GFSA00"}),
  38. ("PUT", "/api/v1/cloud/settings/PFUS123", {"name": "N"}),
  39. ("DELETE", "/api/v1/cloud/settings/PFUS123", None),
  40. ],
  41. )
  42. async def test_an_unreachable_cloud_is_a_bad_gateway(async_client: AsyncClient, method, path, body):
  43. with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=_failing_cloud())):
  44. response = await async_client.request(method, path, json=body)
  45. assert response.status_code == 502, response.text
  46. assert "Network is unreachable" in response.text
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. @pytest.mark.parametrize(
  50. ("path", "body", "call"),
  51. [
  52. ("/api/v1/cloud/login", {"email": "a@example.com", "password": "pw"}, "login_request"),
  53. ("/api/v1/cloud/verify", {"email": "a@example.com", "code": "123456"}, "verify_code"),
  54. ],
  55. )
  56. async def test_a_login_that_never_reached_the_cloud_is_not_a_bad_password(async_client: AsyncClient, path, body, call):
  57. """The client really cannot connect: a network error, not wrong credentials."""
  58. def _unreachable_service(*args, **kwargs):
  59. service = BambuCloudService(*args, **kwargs)
  60. service._client.post = AsyncMock(side_effect=httpx.ConnectError("[Errno 101] Network is unreachable"))
  61. return service
  62. with patch("backend.app.api.routes.cloud.BambuCloudService", new=_unreachable_service):
  63. response = await async_client.post(path, json=body)
  64. assert response.status_code == 502, response.text
  65. @pytest.mark.asyncio
  66. async def test_the_service_reports_a_network_failure_as_a_cloud_error_not_an_auth_error():
  67. service = BambuCloudService()
  68. try:
  69. with patch.object(service._client, "post", new=AsyncMock(side_effect=httpx.ConnectError("unreachable"))):
  70. for attempt in (service.login_request("a@example.com", "pw"), service.verify_code("a@example.com", "1")):
  71. with pytest.raises(BambuCloudError) as raised:
  72. await attempt
  73. assert not isinstance(raised.value, BambuCloudAuthError)
  74. finally:
  75. await service.close()
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_an_expired_session_is_still_unauthorized(async_client: AsyncClient):
  79. cloud = _failing_cloud()
  80. cloud.get_devices = AsyncMock(side_effect=BambuCloudAuthError("Token expired"))
  81. with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
  82. response = await async_client.get("/api/v1/cloud/devices")
  83. assert response.status_code == 401, response.text
  84. def _service_answering(status: int, body: bytes, content_type: str = "text/html"):
  85. """A BambuCloudService whose requests all get this answer."""
  86. def handler(_request: httpx.Request) -> httpx.Response:
  87. return httpx.Response(status, content=body, headers={"content-type": content_type})
  88. def factory(*args, **kwargs):
  89. service = BambuCloudService(*args, **kwargs)
  90. service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  91. return service
  92. return factory
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. @pytest.mark.parametrize(
  96. ("status", "expected"),
  97. [(401, 401), (403, 401), (500, 502), (503, 502)],
  98. )
  99. async def test_a_pasted_token_is_invalid_only_when_bambu_says_so(async_client: AsyncClient, status, expected):
  100. with patch(
  101. "backend.app.api.routes.cloud.BambuCloudService", new=_service_answering(status, b"{}", "application/json")
  102. ):
  103. response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
  104. assert response.status_code == expected, response.text
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_a_pasted_token_with_the_cloud_unreachable_is_a_bad_gateway(async_client: AsyncClient):
  108. def _unreachable_service(*args, **kwargs):
  109. service = BambuCloudService(*args, **kwargs)
  110. service._client.get = AsyncMock(side_effect=httpx.ConnectError("[Errno 101] Network is unreachable"))
  111. return service
  112. with patch("backend.app.api.routes.cloud.BambuCloudService", new=_unreachable_service):
  113. response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
  114. assert response.status_code == 502, response.text
  115. @pytest.mark.asyncio
  116. @pytest.mark.parametrize(
  117. ("call", "args"),
  118. [
  119. ("get_user_profile", ()),
  120. ("get_slicer_settings", ()),
  121. ("get_setting_detail", ("PFUS1",)),
  122. ("create_setting", ()),
  123. ("update_setting", ("PFUS1", "N")),
  124. ("delete_setting", ("PFUS1",)),
  125. ("get_devices", ()),
  126. ("get_firmware_version", ("DEV1",)),
  127. ],
  128. )
  129. @pytest.mark.parametrize("status", [200, 503])
  130. async def test_an_html_page_from_the_cloud_is_a_cloud_error(call, args, status):
  131. """A Cloudflare challenge or an edge error page is not JSON."""
  132. if call == "delete_setting" and status == 200:
  133. pytest.skip("a delete answered 200 succeeded; its body is not read")
  134. service = _service_answering(status, b"<html>Just a moment...</html>")()
  135. service.set_token("tok")
  136. try:
  137. if call == "create_setting":
  138. attempt = service.create_setting(preset_type="filament", name="N", base_id="GFSA00", setting={})
  139. else:
  140. attempt = getattr(service, call)(*args)
  141. with pytest.raises(BambuCloudError) as raised:
  142. await attempt
  143. assert not isinstance(raised.value, BambuCloudAuthError)
  144. finally:
  145. await service.close()
  146. @pytest.mark.asyncio
  147. @pytest.mark.parametrize(
  148. ("call", "args"),
  149. [
  150. ("get_slicer_settings", ()),
  151. ("get_setting_detail", ("PFUS1",)),
  152. ("create_setting", ()),
  153. ("update_setting", ("PFUS1", "N")),
  154. ("delete_setting", ("PFUS1",)),
  155. ("get_devices", ()),
  156. ("get_firmware_version", ("DEV1",)),
  157. ],
  158. )
  159. async def test_an_expired_session_is_an_auth_error_not_an_outage(call, args):
  160. """Bambu's expiry answer signs the token out; the caller is told the session
  161. expired (401 at the routes), not that the cloud failed."""
  162. service = _service_answering(401, b'{"code":4,"error":"Please login.","message":""}', "application/json")()
  163. service.set_token("tok")
  164. try:
  165. if call == "create_setting":
  166. attempt = service.create_setting(preset_type="filament", name="N", base_id="GFSA00", setting={})
  167. else:
  168. attempt = getattr(service, call)(*args)
  169. with pytest.raises(BambuCloudAuthError):
  170. await attempt
  171. finally:
  172. await service.close()
  173. @pytest.mark.asyncio
  174. async def test_a_stray_401_is_not_taken_for_an_expired_session():
  175. service = _service_answering(401, b'{"code":1,"message":"region"}', "application/json")()
  176. service.set_token("tok")
  177. try:
  178. with pytest.raises(BambuCloudError) as raised:
  179. await service.get_devices()
  180. assert not isinstance(raised.value, BambuCloudAuthError)
  181. finally:
  182. await service.close()
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_an_expired_session_keeps_the_sign_in_for_the_expired_banner(async_client: AsyncClient, db_session):
  186. """/cloud/status owns the sign-in: it shows 'sign-in expired' from the
  187. stored token and its invalid flag, and the region is kept for the next
  188. sign-in. A cloud view that sees the expiry answers 401 and clears nothing."""
  189. from backend.app.api.routes.cloud import get_stored_token, store_token
  190. await store_token(db_session, "tok", "a@example.com", "china", None)
  191. await db_session.commit()
  192. cloud = _failing_cloud()
  193. cloud.get_devices = AsyncMock(side_effect=BambuCloudAuthError("Bambu Cloud session expired"))
  194. with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
  195. response = await async_client.get("/api/v1/cloud/devices")
  196. assert response.status_code == 401, response.text
  197. db_session.expire_all()
  198. assert await get_stored_token(db_session, None) == ("tok", "a@example.com", "china")
  199. @pytest.mark.asyncio
  200. @pytest.mark.integration
  201. async def test_an_expiry_during_the_firmware_check_is_not_an_unknown_version(async_client: AsyncClient):
  202. cloud = _failing_cloud()
  203. cloud.get_devices = AsyncMock(
  204. return_value={"devices": [{"dev_id": "D1", "name": "A"}, {"dev_id": "D2", "name": "B"}]}
  205. )
  206. cloud.get_firmware_version = AsyncMock(side_effect=BambuCloudAuthError("Bambu Cloud session expired"))
  207. with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
  208. response = await async_client.get("/api/v1/cloud/firmware-updates")
  209. assert response.status_code == 401, response.text
  210. assert cloud.get_firmware_version.await_count == 1
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_a_pasted_token_blocked_by_cloudflare_is_not_invalid(async_client: AsyncClient):
  214. def handler(_request: httpx.Request) -> httpx.Response:
  215. return httpx.Response(403, content=b"<html>blocked</html>", headers={"cf-mitigated": "challenge"})
  216. def factory(*args, **kwargs):
  217. service = BambuCloudService(*args, **kwargs)
  218. service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  219. return service
  220. with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
  221. response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
  222. assert response.status_code == 502, response.text
  223. @pytest.mark.asyncio
  224. @pytest.mark.integration
  225. @pytest.mark.parametrize("status", [401, 403])
  226. async def test_a_pasted_token_refused_by_an_edge_page_is_not_invalid(async_client: AsyncClient, status):
  227. """Only Bambu's own (JSON) answer judges the token; an HTML 401/403 from a
  228. proxy or a Cloudflare access rule does not."""
  229. with patch(
  230. "backend.app.api.routes.cloud.BambuCloudService",
  231. new=_service_answering(status, b"<html>Access denied</html>", "text/html"),
  232. ):
  233. response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
  234. assert response.status_code == 502, response.text
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_bambus_own_refusal_of_a_pasted_token_is_invalid(async_client: AsyncClient):
  238. """Measured: Bambu answers a wrong token on this endpoint with a 401 and an
  239. empty body (content-length 0)."""
  240. with patch("backend.app.api.routes.cloud.BambuCloudService", new=_service_answering(401, b"", "text/plain")):
  241. response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
  242. assert response.status_code == 401, response.text
  243. assert "Invalid token" in response.text
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_a_pasted_token_with_surrounding_whitespace_is_trimmed(async_client: AsyncClient, db_session):
  247. from backend.app.api.routes.cloud import get_stored_token
  248. seen: list[str] = []
  249. def handler(request: httpx.Request) -> httpx.Response:
  250. seen.append(request.headers["authorization"])
  251. return httpx.Response(200, json={"uid": 1})
  252. def factory(*args, **kwargs):
  253. service = BambuCloudService(*args, **kwargs)
  254. service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  255. return service
  256. with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
  257. response = await async_client.post("/api/v1/cloud/token", json={"access_token": " tok-value \n"})
  258. assert response.status_code == 200, response.text
  259. assert seen == ["Bearer tok-value"]
  260. db_session.expire_all()
  261. assert (await get_stored_token(db_session, None))[0] == "tok-value"
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. @pytest.mark.parametrize("token", ["tok\x00value", "tok value", "tök", ""])
  265. async def test_a_token_that_cannot_be_sent_is_invalid_not_an_outage(async_client: AsyncClient, token):
  266. calls: list[int] = []
  267. def factory(*args, **kwargs):
  268. service = BambuCloudService(*args, **kwargs)
  269. service._client = httpx.AsyncClient(
  270. transport=httpx.MockTransport(lambda _r: calls.append(1) or httpx.Response(200, json={}))
  271. )
  272. return service
  273. with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
  274. response = await async_client.post("/api/v1/cloud/token", json={"access_token": token})
  275. assert response.status_code == 401, response.text
  276. assert "Invalid token" in response.text
  277. assert token.strip() == "" or token not in response.text
  278. assert calls == []