test_cloud_token_expiry.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. """Tests for Bambu Cloud sign-in expiry detection.
  2. Bambu's access token is opaque — no readable expiry — and Bambuddy does not
  3. persist the refresh token, so the only authority on whether a stored token still
  4. works is Bambu itself. Bambuddy used to pretend otherwise: ``set_token()``
  5. stamped ``token_expiry = now + 30 days`` *every time a stored token was loaded*,
  6. which reset the expiry check on every request and made ``is_authenticated``
  7. incapable of ever returning False. ``/cloud/status`` therefore reported
  8. "connected" indefinitely while every cloud call 401'd, and the user was shown
  9. Bambu's own ``{"error": "Please login."}`` as a toast — on a UI that was
  10. simultaneously telling them they were signed in.
  11. These tests pin: the expiry is no longer invented, a 401 is recorded durably,
  12. a Bambu outage does not masquerade as an expired sign-in, and a fresh login
  13. clears the flag.
  14. """
  15. from __future__ import annotations
  16. from datetime import datetime, timezone
  17. from unittest.mock import AsyncMock, MagicMock
  18. import httpx
  19. import pytest
  20. from backend.app.api.routes.cloud import clear_token, store_token
  21. from backend.app.models.settings import Settings
  22. from backend.app.services import bambu_cloud as bc
  23. from backend.app.services.bambu_cloud import BambuCloudService
  24. from backend.app.services.bambu_cloud_credentials import (
  25. CLOUD_EMAIL_KEY,
  26. CLOUD_REGION_KEY,
  27. CLOUD_TOKEN_INVALID_KEY,
  28. CLOUD_TOKEN_KEY,
  29. is_cloud_token_invalid,
  30. )
  31. @pytest.fixture(autouse=True)
  32. def _clear_validation_cache():
  33. """The validation verdict cache is module-level; don't leak across tests."""
  34. bc.invalidate_validation_cache()
  35. yield
  36. bc.invalidate_validation_cache()
  37. # Bambu's genuine "token expired" 401 body — the only 401 that means sign-out.
  38. _EXPIRY_401_BODY = {"code": 4, "error": "Please login.", "message": ""}
  39. def _service(
  40. status_code: int = 200,
  41. *,
  42. on_auth_failure=None,
  43. raises: Exception | None = None,
  44. body: object | None = None,
  45. json_raises: bool = False,
  46. ):
  47. svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient), on_auth_failure=on_auth_failure)
  48. resp = MagicMock()
  49. resp.status_code = status_code
  50. # A 401 defaults to Bambu's expiry body so existing "rejected token" cases
  51. # mean a real expiry; pass body= to exercise a transient/benign 401.
  52. if json_raises:
  53. resp.json = MagicMock(side_effect=ValueError("not json"))
  54. else:
  55. resp.json = MagicMock(return_value=_EXPIRY_401_BODY if (body is None and status_code == 401) else (body or {}))
  56. svc._client.get = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=resp)
  57. return svc
  58. class TestNoInventedExpiry:
  59. def test_set_token_records_no_expiry(self):
  60. """The bug in one line: this used to be ``now + 30 days``, re-derived on
  61. every request from a token of entirely unknown age."""
  62. svc = _service()
  63. svc.set_token("stored-token-of-unknown-age")
  64. assert svc.token_expiry is None
  65. def test_is_authenticated_means_loaded_not_accepted(self):
  66. """It still answers True for a loaded token — that is all it ever knew.
  67. The point is that nobody may now read it as "Bambu accepts this"."""
  68. svc = _service()
  69. assert svc.is_authenticated is False
  70. svc.set_token("stored-token")
  71. assert svc.is_authenticated is True
  72. class TestValidateToken:
  73. @pytest.mark.asyncio
  74. async def test_accepted_token_returns_true(self):
  75. svc = _service(200)
  76. svc.set_token("good-token")
  77. assert await svc.validate_token() is True
  78. @pytest.mark.asyncio
  79. async def test_rejected_token_returns_false(self):
  80. svc = _service(401) # defaults to Bambu's genuine expiry body
  81. svc.set_token("dead-token")
  82. assert await svc.validate_token() is False
  83. @pytest.mark.asyncio
  84. async def test_transient_401_is_unknown_not_invalid(self):
  85. """A 401 WITHOUT Bambu's expiry signature is edge/endpoint noise, not a
  86. dead token — it must read as unknown, never sign the user out. This is
  87. the regression that logged users out on a single stray 401."""
  88. svc = _service(401, body={"code": 1, "error": "forbidden"})
  89. svc.set_token("good-token")
  90. assert await svc.validate_token() is None
  91. @pytest.mark.asyncio
  92. async def test_unparseable_401_is_unknown_not_invalid(self):
  93. svc = _service(401, json_raises=True)
  94. svc.set_token("good-token")
  95. assert await svc.validate_token() is None
  96. @pytest.mark.asyncio
  97. async def test_expiry_signature_via_please_login_text(self):
  98. """The `code:4` field is primary, but the "Please login." text alone
  99. (no/other code) is still accepted as the expiry signal."""
  100. svc = _service(401, body={"error": "Please login.", "message": ""})
  101. svc.set_token("dead-token")
  102. assert await svc.validate_token() is False
  103. @pytest.mark.asyncio
  104. async def test_no_token_is_not_authenticated(self):
  105. svc = _service(200)
  106. assert await svc.validate_token() is False
  107. @pytest.mark.asyncio
  108. async def test_network_failure_is_unknown_not_invalid(self):
  109. """A Bambu outage must never present as "your sign-in expired" — that
  110. would sign every user out of a perfectly good session."""
  111. svc = _service(raises=httpx.ConnectError("no route to host"))
  112. svc.set_token("good-token")
  113. assert await svc.validate_token() is None
  114. @pytest.mark.asyncio
  115. async def test_server_error_is_unknown_not_invalid(self):
  116. svc = _service(503)
  117. svc.set_token("good-token")
  118. assert await svc.validate_token() is None
  119. @pytest.mark.asyncio
  120. async def test_cloudflare_challenge_is_unknown_not_invalid(self):
  121. """418/403 from Bambu's anti-abuse edge means the *request* was refused,
  122. not the token. Declaring the credential dead there would log users out
  123. whenever Cloudflare gets suspicious of their IP."""
  124. svc = _service(418)
  125. svc.set_token("good-token")
  126. assert await svc.validate_token() is None
  127. @pytest.mark.asyncio
  128. async def test_verdict_is_cached(self):
  129. """/cloud/status is polled by several components; without the cache each
  130. render would put a Bambu round-trip in front of the settings page."""
  131. svc = _service(200)
  132. svc.set_token("good-token")
  133. assert await svc.validate_token() is True
  134. assert await svc.validate_token() is True
  135. assert svc._client.get.await_count == 1
  136. @pytest.mark.asyncio
  137. async def test_cache_is_keyed_per_token(self):
  138. svc = _service(200)
  139. svc.set_token("token-a")
  140. assert await svc.validate_token() is True
  141. other = _service(401)
  142. other.set_token("token-b")
  143. assert await other.validate_token() is False, "a different token must not inherit the cached verdict"
  144. @pytest.mark.asyncio
  145. async def test_login_drops_a_cached_rejection(self):
  146. """Re-login must not leave the user staring at "sign-in expired" for the
  147. rest of the cache TTL."""
  148. svc = _service(401)
  149. svc.set_token("tok")
  150. assert await svc.validate_token() is False
  151. fresh = _service(200)
  152. fresh._set_tokens({"accessToken": "tok"}) # same string, freshly minted upstream
  153. assert await fresh.validate_token() is True
  154. class TestAuthFailureCallback:
  155. @pytest.mark.asyncio
  156. async def test_401_fires_the_callback(self):
  157. calls: list[int] = []
  158. async def _cb() -> None:
  159. calls.append(1)
  160. svc = _service(401, on_auth_failure=_cb)
  161. svc.set_token("dead-token")
  162. await svc.validate_token()
  163. assert calls == [1]
  164. @pytest.mark.asyncio
  165. async def test_reported_once_per_service(self):
  166. """A route that makes several cloud calls must not write the flag once
  167. per call."""
  168. calls: list[int] = []
  169. async def _cb() -> None:
  170. calls.append(1)
  171. svc = _service(401, on_auth_failure=_cb)
  172. svc.set_token("dead-token")
  173. resp = MagicMock()
  174. resp.status_code = 401
  175. resp.json = MagicMock(return_value=_EXPIRY_401_BODY)
  176. await svc._note_response(resp)
  177. await svc._note_response(resp)
  178. await svc._note_response(resp)
  179. assert calls == [1]
  180. @pytest.mark.asyncio
  181. async def test_transient_401_does_not_fire_the_callback(self):
  182. """A benign 401 must not durably invalidate — the callback that persists
  183. the dead-token flag stays untouched."""
  184. calls: list[int] = []
  185. async def _cb() -> None:
  186. calls.append(1)
  187. svc = _service(401, on_auth_failure=_cb, body={"code": 1, "error": "forbidden"})
  188. svc.set_token("good-token")
  189. await svc.validate_token()
  190. assert calls == []
  191. @pytest.mark.asyncio
  192. async def test_success_does_not_fire_the_callback(self):
  193. calls: list[int] = []
  194. async def _cb() -> None:
  195. calls.append(1)
  196. svc = _service(200, on_auth_failure=_cb)
  197. svc.set_token("good-token")
  198. await svc.validate_token()
  199. assert calls == []
  200. @pytest.mark.asyncio
  201. async def test_callback_failure_does_not_mask_the_401(self):
  202. """Recording the dead credential is bookkeeping. If it throws, the caller
  203. must still get the auth failure it was actually waiting for."""
  204. async def _cb() -> None:
  205. raise RuntimeError("database is on fire")
  206. svc = _service(401, on_auth_failure=_cb)
  207. svc.set_token("dead-token")
  208. assert await svc.validate_token() is False
  209. class TestPersistedFlag:
  210. """Auth-disabled deployments keep cloud credentials in the Settings table."""
  211. @pytest.mark.asyncio
  212. async def test_absent_by_default(self, db_session):
  213. assert await is_cloud_token_invalid(db_session, None) is False
  214. @pytest.mark.asyncio
  215. async def test_set_flag_is_read_back(self, db_session):
  216. db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=datetime.now(timezone.utc).isoformat()))
  217. await db_session.commit()
  218. assert await is_cloud_token_invalid(db_session, None) is True
  219. @pytest.mark.asyncio
  220. async def test_fresh_login_clears_the_flag(self, db_session):
  221. """Otherwise the new sign-in is reported as expired the instant it's stored."""
  222. db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
  223. await db_session.commit()
  224. await store_token(db_session, "brand-new-token", "user@example.com", "global", None)
  225. assert await is_cloud_token_invalid(db_session, None) is False
  226. @pytest.mark.asyncio
  227. async def test_logout_clears_the_flag(self, db_session):
  228. for key, value in [
  229. (CLOUD_TOKEN_KEY, "dead"),
  230. (CLOUD_EMAIL_KEY, "user@example.com"),
  231. (CLOUD_REGION_KEY, "global"),
  232. (CLOUD_TOKEN_INVALID_KEY, "2026-07-14T07:00:00+00:00"),
  233. ]:
  234. db_session.add(Settings(key=key, value=value))
  235. await db_session.commit()
  236. await clear_token(db_session, None)
  237. assert await is_cloud_token_invalid(db_session, None) is False
  238. class TestStatusRoute:
  239. """The endpoint that was lying. ``GET /cloud/status`` drives the "Connected
  240. as ..." bar on the Profiles page and the green dot in Settings."""
  241. async def _store(self, db_session, *, invalid: bool = False):
  242. db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="stored-token"))
  243. db_session.add(Settings(key=CLOUD_EMAIL_KEY, value="user@example.com"))
  244. db_session.add(Settings(key=CLOUD_REGION_KEY, value="global"))
  245. if invalid:
  246. db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
  247. await db_session.commit()
  248. @pytest.mark.asyncio
  249. async def test_no_token_is_not_expired(self, async_client, db_session):
  250. body = (await async_client.get("/api/v1/cloud/status")).json()
  251. assert body["is_authenticated"] is False
  252. assert body["sign_in_expired"] is False
  253. @pytest.mark.asyncio
  254. async def test_token_bambu_rejects_reports_expired(self, async_client, db_session, monkeypatch):
  255. """The whole bug: a stored token Bambu no longer accepts used to come back
  256. as ``is_authenticated: true``, forever."""
  257. await self._store(db_session)
  258. monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=False))
  259. body = (await async_client.get("/api/v1/cloud/status")).json()
  260. assert body["is_authenticated"] is False
  261. assert body["sign_in_expired"] is True
  262. assert body["email"] is None
  263. @pytest.mark.asyncio
  264. async def test_token_bambu_accepts_reports_connected(self, async_client, db_session, monkeypatch):
  265. await self._store(db_session)
  266. monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=True))
  267. body = (await async_client.get("/api/v1/cloud/status")).json()
  268. assert body["is_authenticated"] is True
  269. assert body["sign_in_expired"] is False
  270. assert body["email"] == "user@example.com"
  271. @pytest.mark.asyncio
  272. async def test_bambu_unreachable_keeps_the_user_signed_in(self, async_client, db_session, monkeypatch):
  273. """Unknown is not invalid. A Bambu outage must not log the whole install
  274. out of the cloud."""
  275. await self._store(db_session)
  276. monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
  277. body = (await async_client.get("/api/v1/cloud/status")).json()
  278. assert body["is_authenticated"] is True
  279. assert body["sign_in_expired"] is False
  280. @pytest.mark.asyncio
  281. async def test_bambu_unreachable_does_not_resurrect_a_known_dead_token(self, async_client, db_session, monkeypatch):
  282. """...but "unknown" must fall back to what we last knew, not to True."""
  283. await self._store(db_session, invalid=True)
  284. monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
  285. body = (await async_client.get("/api/v1/cloud/status")).json()
  286. assert body["is_authenticated"] is False
  287. assert body["sign_in_expired"] is True
  288. @pytest.mark.asyncio
  289. async def test_known_dead_token_does_not_re_ask_bambu(self, async_client, db_session, monkeypatch):
  290. """Only a new login can revive it, and that clears the flag — so polling
  291. Bambu on every status call would be pure waste."""
  292. await self._store(db_session, invalid=True)
  293. validate = AsyncMock(return_value=False)
  294. monkeypatch.setattr(BambuCloudService, "validate_token", validate)
  295. await async_client.get("/api/v1/cloud/status")
  296. validate.assert_not_awaited()