Просмотр исходного кода

fix(cloud): report an unreachable Bambu Cloud as 502, not as a server error or wrong credentials

maziggy 1 неделя назад
Родитель
Сommit
bdd3111547

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Bambu Cloud being unreachable is reported as such** — When Bambu Cloud failed (no internet, a blocked or failing connection, an error or an error page such as a Cloudflare challenge), the cloud preset, device and firmware views answered with a server error (500), and pasting an access token answered as if the token were wrong (401). Signing in or entering the email code without a connection to Bambu Cloud also answered 401, as if the password or code were wrong. These now answer 502 (Bambu Cloud unreachable) with the cause, as the preset lookups already did; a pasted token counts as invalid only when Bambu Cloud itself refuses it. An expired session is answered as such (401, sign in again) instead of with a server error.
 - **Editing a smart plug can no longer save a plug that cannot be read back** — Adding a plug checks its values: a Tasmota plug needs an IPv4 address, a Home Assistant plug an entity of the right kind, an MQTT plug a topic, a REST plug an ON or OFF URL, and the name must not be empty. Editing one did not, so clearing such a field, switching the type without filling in the new one, or typing a hostname as a Tasmota address saved a plug that the smart plug list, the plug itself and the edit all failed on with a server error until it was fixed in the database. Such an edit is now refused with the same message as when adding a plug, and the plug stays as it was. Switching a plug to Tasmota on a printer that already has a Tasmota plug is refused as well (adding one already was); before, it gave the printer a second one, and adding a plug to that printer then failed with a server error.
 - **Importing a project from a file without a priority** — The import gave such a project the priority `0`. On PostgreSQL the import failed with a server error; on SQLite the project got a priority the project editor itself refuses. An imported project now keeps a valid priority (low, normal, high or urgent) and gets **normal** otherwise, from a file and from the JSON import alike.
 - **The "Password Reset" email template is used again** — Since password resets send a link instead of a new password, the reset email (self-service and admin) was always the built-in one: editing the "Password Reset" template under Notifications changed nothing, and its default text still promised a new password. The reset email is now built from that template, and Bambuddy adds the reset link at the end of it (and as a button); the template itself cannot place or contain the link. `{password}` and `{login_url}` are gone from this template. The default text is updated on upgrade unless you edited it; if you did, reword it for a link instead of a new password.

+ 50 - 20
backend/app/api/routes/cloud.py

@@ -454,7 +454,8 @@ async def login(
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -500,7 +501,8 @@ async def verify_code(
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -518,16 +520,28 @@ async def set_token(
     selected ``region`` is persisted alongside the token so every subsequent
     request hits the right Bambu API endpoint, including after a restart.
     """
+    # A pasted token often carries a stray space or line break. One that still
+    # cannot be sent as a header (control characters, spaces, non-ASCII) is not
+    # a token Bambu could accept: refused here, without reaching Bambu, and
+    # without echoing it back in an HTTP client's error.
+    token = request.access_token.strip()
+    if not token or not token.isascii() or not token.isprintable() or " " in token:
+        raise HTTPException(status_code=401, detail="Invalid token")
+
     cloud = BambuCloudService(region=request.region)
-    cloud.set_token(request.access_token)
+    cloud.set_token(token)
 
     try:
         # Verify token works by trying to get profile
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth", request.region, current_user)
+        await store_token(db, token, "token-auth", request.region, current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
-    except BambuCloudError:
-        raise HTTPException(status_code=401, detail="Invalid token")
+    except BambuCloudError as e:
+        # Invalid only when Bambu Cloud refused it; a cloud that could not be
+        # reached or failed says nothing about the token.
+        if isinstance(e, BambuCloudAuthError) or e.status_code in (401, 403):
+            raise HTTPException(status_code=401, detail="Invalid token")
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -605,10 +619,12 @@ async def get_slicer_settings(
 
         return result
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -632,10 +648,12 @@ async def get_setting_detail(
         data = await cloud.get_setting_detail(setting_id)
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1018,10 +1036,12 @@ async def get_devices(
             for d in devices
         ]
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1076,6 +1096,8 @@ async def get_firmware_updates(
                         release_notes=firmware_info.get("release_notes"),
                     )
                 )
+            except BambuCloudAuthError:
+                raise  # the session expired: not an unknown version per device
             except BambuCloudError as e:
                 logger.warning("Failed to get firmware info for %s: %s", device_name, e)
                 # Still include device but with unknown firmware status
@@ -1093,10 +1115,12 @@ async def get_firmware_updates(
         return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
 
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1129,10 +1153,12 @@ async def create_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1161,10 +1187,12 @@ async def update_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 
@@ -1191,10 +1219,12 @@ async def delete_setting(
             message=result.get("message", "Setting deleted"),
         )
     except BambuCloudAuthError:
-        await clear_token(db, current_user)
+        # The sign-in is kept: /cloud/status shows it as expired from the stored
+        # token and its invalid flag, and the region carries to the next sign-in.
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
-        raise HTTPException(status_code=500, detail=str(e))
+        # Bambu Cloud failed or could not be reached: a bad gateway.
+        raise HTTPException(status_code=502, detail=str(e))
     finally:
         await cloud.close()
 

+ 54 - 19
backend/app/services/bambu_cloud.py

@@ -260,6 +260,18 @@ class BambuCloudAuthError(BambuCloudError):
     pass
 
 
+def _cloud_json(response: httpx.Response):
+    """The response's JSON body, or a BambuCloudError when it is not JSON (a
+    Cloudflare challenge or an edge error page): an answer from Bambu Cloud
+    that it could not use, like any other failed request."""
+    try:
+        return response.json()
+    except ValueError:
+        raise BambuCloudError(
+            f"Unexpected response from Bambu Cloud (HTTP {response.status_code})", status_code=response.status_code
+        ) from None
+
+
 _shared_http_client: httpx.AsyncClient | None = None
 
 
@@ -539,7 +551,8 @@ class BambuCloudService:
 
         except Exception as e:
             logger.error("Login request failed: %s", e)
-            raise BambuCloudAuthError(f"Login request failed: {e}")
+            # The request never got an answer: not a refused login.
+            raise BambuCloudError(f"Login request failed: {e}")
 
     async def verify_code(self, email: str, code: str) -> dict:
         """
@@ -576,7 +589,8 @@ class BambuCloudService:
 
         except Exception as e:
             logger.error("Email verification failed: %s", e)
-            raise BambuCloudAuthError(f"Verification failed: {e}")
+            # The request never got an answer: not a refused code.
+            raise BambuCloudError(f"Verification failed: {e}")
 
     async def _fetch_csrf_token(self, web_origin: str) -> str | None:
         """Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
@@ -781,9 +795,23 @@ class BambuCloudService:
             )
 
             if response.status_code == 200:
-                return response.json()
-
-            raise BambuCloudError(f"Failed to get profile: {response.status_code}")
+                return _cloud_json(response)
+
+            # Only Bambu's own answer judges the token: JSON, or no body at all
+            # (how it refuses a wrong token here). A Cloudflare block or an
+            # edge or proxy page says nothing about it, so it carries no
+            # status to judge by.
+            cf_message = _detect_cloudflare_challenge(response)
+            if cf_message:
+                raise BambuCloudError(cf_message)
+            if response.content:
+                try:
+                    response.json()
+                except ValueError:
+                    raise BambuCloudError(
+                        f"Unexpected response from Bambu Cloud (HTTP {response.status_code})"
+                    ) from None
+            raise BambuCloudError(f"Failed to get profile: {response.status_code}", status_code=response.status_code)
 
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
@@ -809,9 +837,10 @@ class BambuCloudService:
                 params={"version": version},
             )
 
-            data = response.json()
+            data = _cloud_json(response)
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code == 200:
                 return data
 
@@ -832,9 +861,10 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
             )
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code == 200:
-                return response.json()
+                return _cloud_json(response)
 
             # Include body so a future contract change is self-diagnostic from logs.
             body = (response.text or "")[:200]
@@ -884,9 +914,10 @@ class BambuCloudService:
                 f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
             )
 
-            data = response.json()
+            data = _cloud_json(response)
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code in (200, 201):
                 return data
 
@@ -970,9 +1001,10 @@ class BambuCloudService:
                 f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
             )
 
-            data = response.json()
+            data = _cloud_json(response)
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code == 200:
                 return data
 
@@ -1002,11 +1034,12 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
             )
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code in (200, 204):
                 return {"success": True, "message": "Setting deleted"}
 
-            data = response.json() if response.content else {}
+            data = _cloud_json(response) if response.content else {}
             error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
             raise BambuCloudError(f"Failed to delete setting: {error_msg}")
 
@@ -1023,9 +1056,10 @@ class BambuCloudService:
                 f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
             )
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code == 200:
-                return response.json()
+                return _cloud_json(response)
 
             raise BambuCloudError(f"Failed to get devices: {response.status_code}")
 
@@ -1052,9 +1086,10 @@ class BambuCloudService:
                 params={"device_id": device_id},
             )
 
-            await self._note_response(response)
+            if await self._note_response(response):
+                raise BambuCloudAuthError("Bambu Cloud session expired")
             if response.status_code == 200:
-                data = response.json()
+                data = _cloud_json(response)
                 # API wraps response in 'data' field
                 return data.get("data", data)
 

+ 350 - 0
backend/tests/integration/test_cloud_upstream_errors.py

@@ -0,0 +1,350 @@
+"""Bambu Cloud being unreachable is a bad gateway, not a server fault.
+
+The cloud routes answered 500 when Bambu Cloud failed (no network, a refused
+connection, an error from the cloud), and a login or verification that never
+reached Bambu Cloud answered 401 as if the credentials were wrong. Both are an
+upstream failure: 502, as the preset lookups already answer.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_cloud import BambuCloudAuthError, BambuCloudError, BambuCloudService
+
+UPSTREAM = BambuCloudError("Request failed: [Errno 101] Network is unreachable")
+
+
+def _failing_cloud() -> MagicMock:
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    for method in (
+        "get_slicer_settings",
+        "get_setting_detail",
+        "get_devices",
+        "create_setting",
+        "update_setting",
+        "delete_setting",
+    ):
+        setattr(cloud, method, AsyncMock(side_effect=UPSTREAM))
+    cloud.close = AsyncMock()
+    return cloud
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    ("method", "path", "body"),
+    [
+        ("GET", "/api/v1/cloud/settings", None),
+        ("GET", "/api/v1/cloud/settings/PFUS123", None),
+        ("GET", "/api/v1/cloud/devices", None),
+        ("GET", "/api/v1/cloud/firmware-updates", None),
+        ("POST", "/api/v1/cloud/settings", {"type": "filament", "name": "N", "base_id": "GFSA00"}),
+        ("PUT", "/api/v1/cloud/settings/PFUS123", {"name": "N"}),
+        ("DELETE", "/api/v1/cloud/settings/PFUS123", None),
+    ],
+)
+async def test_an_unreachable_cloud_is_a_bad_gateway(async_client: AsyncClient, method, path, body):
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=_failing_cloud())):
+        response = await async_client.request(method, path, json=body)
+
+    assert response.status_code == 502, response.text
+    assert "Network is unreachable" in response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    ("path", "body", "call"),
+    [
+        ("/api/v1/cloud/login", {"email": "a@example.com", "password": "pw"}, "login_request"),
+        ("/api/v1/cloud/verify", {"email": "a@example.com", "code": "123456"}, "verify_code"),
+    ],
+)
+async def test_a_login_that_never_reached_the_cloud_is_not_a_bad_password(async_client: AsyncClient, path, body, call):
+    """The client really cannot connect: a network error, not wrong credentials."""
+
+    def _unreachable_service(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client.post = AsyncMock(side_effect=httpx.ConnectError("[Errno 101] Network is unreachable"))
+        return service
+
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=_unreachable_service):
+        response = await async_client.post(path, json=body)
+
+    assert response.status_code == 502, response.text
+
+
+@pytest.mark.asyncio
+async def test_the_service_reports_a_network_failure_as_a_cloud_error_not_an_auth_error():
+    service = BambuCloudService()
+    try:
+        with patch.object(service._client, "post", new=AsyncMock(side_effect=httpx.ConnectError("unreachable"))):
+            for attempt in (service.login_request("a@example.com", "pw"), service.verify_code("a@example.com", "1")):
+                with pytest.raises(BambuCloudError) as raised:
+                    await attempt
+                assert not isinstance(raised.value, BambuCloudAuthError)
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_expired_session_is_still_unauthorized(async_client: AsyncClient):
+    cloud = _failing_cloud()
+    cloud.get_devices = AsyncMock(side_effect=BambuCloudAuthError("Token expired"))
+
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
+        response = await async_client.get("/api/v1/cloud/devices")
+
+    assert response.status_code == 401, response.text
+
+
+def _service_answering(status: int, body: bytes, content_type: str = "text/html"):
+    """A BambuCloudService whose requests all get this answer."""
+
+    def handler(_request: httpx.Request) -> httpx.Response:
+        return httpx.Response(status, content=body, headers={"content-type": content_type})
+
+    def factory(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+        return service
+
+    return factory
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    ("status", "expected"),
+    [(401, 401), (403, 401), (500, 502), (503, 502)],
+)
+async def test_a_pasted_token_is_invalid_only_when_bambu_says_so(async_client: AsyncClient, status, expected):
+    with patch(
+        "backend.app.api.routes.cloud.BambuCloudService", new=_service_answering(status, b"{}", "application/json")
+    ):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
+
+    assert response.status_code == expected, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_pasted_token_with_the_cloud_unreachable_is_a_bad_gateway(async_client: AsyncClient):
+    def _unreachable_service(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client.get = AsyncMock(side_effect=httpx.ConnectError("[Errno 101] Network is unreachable"))
+        return service
+
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=_unreachable_service):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
+
+    assert response.status_code == 502, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("call", "args"),
+    [
+        ("get_user_profile", ()),
+        ("get_slicer_settings", ()),
+        ("get_setting_detail", ("PFUS1",)),
+        ("create_setting", ()),
+        ("update_setting", ("PFUS1", "N")),
+        ("delete_setting", ("PFUS1",)),
+        ("get_devices", ()),
+        ("get_firmware_version", ("DEV1",)),
+    ],
+)
+@pytest.mark.parametrize("status", [200, 503])
+async def test_an_html_page_from_the_cloud_is_a_cloud_error(call, args, status):
+    """A Cloudflare challenge or an edge error page is not JSON."""
+    if call == "delete_setting" and status == 200:
+        pytest.skip("a delete answered 200 succeeded; its body is not read")
+    service = _service_answering(status, b"<html>Just a moment...</html>")()
+    service.set_token("tok")
+    try:
+        if call == "create_setting":
+            attempt = service.create_setting(preset_type="filament", name="N", base_id="GFSA00", setting={})
+        else:
+            attempt = getattr(service, call)(*args)
+        with pytest.raises(BambuCloudError) as raised:
+            await attempt
+        assert not isinstance(raised.value, BambuCloudAuthError)
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("call", "args"),
+    [
+        ("get_slicer_settings", ()),
+        ("get_setting_detail", ("PFUS1",)),
+        ("create_setting", ()),
+        ("update_setting", ("PFUS1", "N")),
+        ("delete_setting", ("PFUS1",)),
+        ("get_devices", ()),
+        ("get_firmware_version", ("DEV1",)),
+    ],
+)
+async def test_an_expired_session_is_an_auth_error_not_an_outage(call, args):
+    """Bambu's expiry answer signs the token out; the caller is told the session
+    expired (401 at the routes), not that the cloud failed."""
+    service = _service_answering(401, b'{"code":4,"error":"Please login.","message":""}', "application/json")()
+    service.set_token("tok")
+    try:
+        if call == "create_setting":
+            attempt = service.create_setting(preset_type="filament", name="N", base_id="GFSA00", setting={})
+        else:
+            attempt = getattr(service, call)(*args)
+        with pytest.raises(BambuCloudAuthError):
+            await attempt
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+async def test_a_stray_401_is_not_taken_for_an_expired_session():
+    service = _service_answering(401, b'{"code":1,"message":"region"}', "application/json")()
+    service.set_token("tok")
+    try:
+        with pytest.raises(BambuCloudError) as raised:
+            await service.get_devices()
+        assert not isinstance(raised.value, BambuCloudAuthError)
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_expired_session_keeps_the_sign_in_for_the_expired_banner(async_client: AsyncClient, db_session):
+    """/cloud/status owns the sign-in: it shows 'sign-in expired' from the
+    stored token and its invalid flag, and the region is kept for the next
+    sign-in. A cloud view that sees the expiry answers 401 and clears nothing."""
+    from backend.app.api.routes.cloud import get_stored_token, store_token
+
+    await store_token(db_session, "tok", "a@example.com", "china", None)
+    await db_session.commit()
+    cloud = _failing_cloud()
+    cloud.get_devices = AsyncMock(side_effect=BambuCloudAuthError("Bambu Cloud session expired"))
+
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
+        response = await async_client.get("/api/v1/cloud/devices")
+
+    assert response.status_code == 401, response.text
+    db_session.expire_all()
+    assert await get_stored_token(db_session, None) == ("tok", "a@example.com", "china")
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_expiry_during_the_firmware_check_is_not_an_unknown_version(async_client: AsyncClient):
+    cloud = _failing_cloud()
+    cloud.get_devices = AsyncMock(
+        return_value={"devices": [{"dev_id": "D1", "name": "A"}, {"dev_id": "D2", "name": "B"}]}
+    )
+    cloud.get_firmware_version = AsyncMock(side_effect=BambuCloudAuthError("Bambu Cloud session expired"))
+
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=AsyncMock(return_value=cloud)):
+        response = await async_client.get("/api/v1/cloud/firmware-updates")
+
+    assert response.status_code == 401, response.text
+    assert cloud.get_firmware_version.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_pasted_token_blocked_by_cloudflare_is_not_invalid(async_client: AsyncClient):
+    def handler(_request: httpx.Request) -> httpx.Response:
+        return httpx.Response(403, content=b"<html>blocked</html>", headers={"cf-mitigated": "challenge"})
+
+    def factory(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+        return service
+
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
+
+    assert response.status_code == 502, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("status", [401, 403])
+async def test_a_pasted_token_refused_by_an_edge_page_is_not_invalid(async_client: AsyncClient, status):
+    """Only Bambu's own (JSON) answer judges the token; an HTML 401/403 from a
+    proxy or a Cloudflare access rule does not."""
+    with patch(
+        "backend.app.api.routes.cloud.BambuCloudService",
+        new=_service_answering(status, b"<html>Access denied</html>", "text/html"),
+    ):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
+
+    assert response.status_code == 502, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_bambus_own_refusal_of_a_pasted_token_is_invalid(async_client: AsyncClient):
+    """Measured: Bambu answers a wrong token on this endpoint with a 401 and an
+    empty body (content-length 0)."""
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=_service_answering(401, b"", "text/plain")):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "tok"})
+
+    assert response.status_code == 401, response.text
+    assert "Invalid token" in response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_pasted_token_with_surrounding_whitespace_is_trimmed(async_client: AsyncClient, db_session):
+    from backend.app.api.routes.cloud import get_stored_token
+
+    seen: list[str] = []
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        seen.append(request.headers["authorization"])
+        return httpx.Response(200, json={"uid": 1})
+
+    def factory(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+        return service
+
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": "  tok-value \n"})
+
+    assert response.status_code == 200, response.text
+    assert seen == ["Bearer tok-value"]
+    db_session.expire_all()
+    assert (await get_stored_token(db_session, None))[0] == "tok-value"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("token", ["tok\x00value", "tok value", "tök", ""])
+async def test_a_token_that_cannot_be_sent_is_invalid_not_an_outage(async_client: AsyncClient, token):
+    calls: list[int] = []
+
+    def factory(*args, **kwargs):
+        service = BambuCloudService(*args, **kwargs)
+        service._client = httpx.AsyncClient(
+            transport=httpx.MockTransport(lambda _r: calls.append(1) or httpx.Response(200, json={}))
+        )
+        return service
+
+    with patch("backend.app.api.routes.cloud.BambuCloudService", new=factory):
+        response = await async_client.post("/api/v1/cloud/token", json={"access_token": token})
+
+    assert response.status_code == 401, response.text
+    assert "Invalid token" in response.text
+    assert token.strip() == "" or token not in response.text
+    assert calls == []