test_cloud_auth.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. """Integration tests for per-user cloud credentials and cloud endpoint permissions.
  2. Regression tests for:
  3. - Per-user cloud token storage (when auth enabled)
  4. - Global fallback (when auth disabled)
  5. - Cloud endpoints use CLOUD_AUTH permission (not SETTINGS_READ)
  6. """
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. class TestPerUserCloudCredentials:
  11. """Tests that cloud credentials are stored per-user when auth is enabled."""
  12. @pytest.fixture
  13. async def user_with_cloud_auth(self, db_session):
  14. """Create a user with CLOUD_AUTH permission via a group."""
  15. from backend.app.core.auth import get_password_hash
  16. from backend.app.models.group import Group
  17. from backend.app.models.user import User
  18. group = Group(
  19. name="CloudUsers",
  20. permissions=["cloud:auth", "filaments:read", "printers:read", "firmware:read"],
  21. )
  22. db_session.add(group)
  23. await db_session.flush()
  24. user = User(
  25. username="clouduser",
  26. password_hash=get_password_hash("testpass123"),
  27. role="user",
  28. )
  29. db_session.add(user)
  30. await db_session.flush()
  31. user.groups.append(group)
  32. await db_session.commit()
  33. await db_session.refresh(user)
  34. return user
  35. @pytest.fixture
  36. async def second_user_with_cloud_auth(self, db_session):
  37. """Create a second user with CLOUD_AUTH permission."""
  38. from sqlalchemy import select
  39. from backend.app.core.auth import get_password_hash
  40. from backend.app.models.group import Group
  41. from backend.app.models.user import User
  42. result = await db_session.execute(select(Group).where(Group.name == "CloudUsers"))
  43. group = result.scalar_one_or_none()
  44. if not group:
  45. group = Group(
  46. name="CloudUsers2",
  47. permissions=["cloud:auth", "filaments:read", "printers:read", "firmware:read"],
  48. )
  49. db_session.add(group)
  50. await db_session.flush()
  51. user = User(
  52. username="clouduser2",
  53. password_hash=get_password_hash("testpass456"),
  54. role="user",
  55. )
  56. db_session.add(user)
  57. await db_session.flush()
  58. user.groups.append(group)
  59. await db_session.commit()
  60. await db_session.refresh(user)
  61. return user
  62. @pytest.fixture
  63. async def cloud_auth_token(self, user_with_cloud_auth, async_client: AsyncClient):
  64. """Get auth token for user with cloud permissions."""
  65. response = await async_client.post(
  66. "/api/v1/auth/login",
  67. json={"username": "clouduser", "password": "testpass123"},
  68. )
  69. if response.status_code == 200:
  70. return response.json().get("access_token")
  71. return None
  72. @pytest.fixture
  73. async def second_auth_token(self, second_user_with_cloud_auth, async_client: AsyncClient):
  74. """Get auth token for second user."""
  75. response = await async_client.post(
  76. "/api/v1/auth/login",
  77. json={"username": "clouduser2", "password": "testpass456"},
  78. )
  79. if response.status_code == 200:
  80. return response.json().get("access_token")
  81. return None
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_cloud_status_returns_not_authenticated_by_default(self, async_client: AsyncClient):
  85. """Cloud status should show not authenticated when no token is stored."""
  86. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  87. response = await async_client.get("/api/v1/cloud/status")
  88. assert response.status_code == 200
  89. data = response.json()
  90. assert data["is_authenticated"] is False
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_cloud_status_accessible_when_auth_disabled(self, async_client: AsyncClient):
  94. """Cloud endpoints should work when auth is disabled (global fallback)."""
  95. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  96. response = await async_client.get("/api/v1/cloud/status")
  97. assert response.status_code == 200
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_cloud_status_requires_auth_when_enabled(self, async_client: AsyncClient):
  101. """Cloud endpoints should require auth when auth is enabled."""
  102. with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
  103. response = await async_client.get("/api/v1/cloud/status")
  104. assert response.status_code == 401
  105. class TestCloudEndpointPermissions:
  106. """Tests that cloud endpoints use CLOUD_AUTH permission, not SETTINGS_READ.
  107. Uses JWT tokens created directly (not via login endpoint) to avoid
  108. test infrastructure complexity with user creation across sessions.
  109. """
  110. @pytest.fixture
  111. async def settings_only_setup(self, async_client: AsyncClient):
  112. """Create user with settings:read but NOT cloud:auth, return JWT."""
  113. from backend.app.core.auth import create_access_token, get_password_hash
  114. from backend.app.core.database import async_session
  115. from backend.app.models.group import Group
  116. from backend.app.models.user import User
  117. async with async_session() as db:
  118. group = Group(name="SettingsReaders", permissions=["settings:read"])
  119. db.add(group)
  120. user = User(
  121. username="settingsuser",
  122. password_hash=get_password_hash("testpass123"),
  123. role="user",
  124. )
  125. db.add(user)
  126. await db.commit()
  127. await db.refresh(group)
  128. await db.refresh(user)
  129. from sqlalchemy import text
  130. await db.execute(
  131. text("INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)"),
  132. {"uid": user.id, "gid": group.id},
  133. )
  134. await db.commit()
  135. return create_access_token(data={"sub": "settingsuser"})
  136. @pytest.fixture
  137. async def cloud_only_setup(self, async_client: AsyncClient):
  138. """Create user with cloud:auth but NOT settings:read, return JWT."""
  139. from backend.app.core.auth import create_access_token, get_password_hash
  140. from backend.app.core.database import async_session
  141. from backend.app.models.group import Group
  142. from backend.app.models.user import User
  143. async with async_session() as db:
  144. group = Group(name="CloudOnly", permissions=["cloud:auth"])
  145. db.add(group)
  146. user = User(
  147. username="cloudonly",
  148. password_hash=get_password_hash("testpass123"),
  149. role="user",
  150. )
  151. db.add(user)
  152. await db.commit()
  153. await db.refresh(group)
  154. await db.refresh(user)
  155. from sqlalchemy import text
  156. await db.execute(
  157. text("INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)"),
  158. {"uid": user.id, "gid": group.id},
  159. )
  160. await db.commit()
  161. return create_access_token(data={"sub": "cloudonly"})
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_cloud_settings_requires_cloud_auth_not_settings_read(
  165. self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
  166. ):
  167. """GET /cloud/settings should require CLOUD_AUTH, not SETTINGS_READ.
  168. Regression test: previously used SETTINGS_READ which blocked users who
  169. had cloud:auth permission but not settings:read.
  170. """
  171. with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
  172. # User with only settings:read should be denied
  173. response = await async_client.get(
  174. "/api/v1/cloud/settings",
  175. headers={"Authorization": f"Bearer {settings_only_setup}"},
  176. )
  177. assert response.status_code == 403
  178. # User with cloud:auth should be allowed (will get 401 since no cloud token,
  179. # but NOT 403 — permission check passes)
  180. response = await async_client.get(
  181. "/api/v1/cloud/settings",
  182. headers={"Authorization": f"Bearer {cloud_only_setup}"},
  183. )
  184. assert response.status_code == 401 # No cloud token, but permission OK
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_cloud_status_requires_cloud_auth(
  188. self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
  189. ):
  190. """GET /cloud/status should require CLOUD_AUTH."""
  191. with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
  192. # settings:read only → 403
  193. response = await async_client.get(
  194. "/api/v1/cloud/status",
  195. headers={"Authorization": f"Bearer {settings_only_setup}"},
  196. )
  197. assert response.status_code == 403
  198. # cloud:auth → 200
  199. response = await async_client.get(
  200. "/api/v1/cloud/status",
  201. headers={"Authorization": f"Bearer {cloud_only_setup}"},
  202. )
  203. assert response.status_code == 200
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_cloud_fields_requires_cloud_auth(
  207. self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
  208. ):
  209. """GET /cloud/fields should require CLOUD_AUTH, not SETTINGS_READ."""
  210. with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
  211. # settings:read only → 403
  212. response = await async_client.get(
  213. "/api/v1/cloud/fields",
  214. headers={"Authorization": f"Bearer {settings_only_setup}"},
  215. )
  216. assert response.status_code == 403
  217. # cloud:auth → 200
  218. response = await async_client.get(
  219. "/api/v1/cloud/fields",
  220. headers={"Authorization": f"Bearer {cloud_only_setup}"},
  221. )
  222. assert response.status_code == 200
  223. class TestCloudTokenStorage:
  224. """Unit-level tests for the token storage functions."""
  225. @pytest.mark.asyncio
  226. async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
  227. """get_stored_token with user=None and no global token returns (None, None)."""
  228. from backend.app.services.bambu_cloud_credentials import get_stored_token
  229. token, email, region = await get_stored_token(db_session, user=None)
  230. assert token is None
  231. assert email is None
  232. assert region == "global" # default for missing rows
  233. @pytest.mark.asyncio
  234. async def test_store_and_get_global_token(self, db_session):
  235. """store_token with user=None stores in global Settings table."""
  236. from backend.app.api.routes.cloud import store_token
  237. from backend.app.services.bambu_cloud_credentials import get_stored_token
  238. await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
  239. token, email, region = await get_stored_token(db_session, user=None)
  240. assert token == "test-token-123"
  241. assert email == "test@example.com"
  242. assert region == "global"
  243. @pytest.mark.asyncio
  244. async def test_store_and_get_per_user_token(self, db_session):
  245. """store_token with user stores on the user record."""
  246. from backend.app.api.routes.cloud import store_token
  247. from backend.app.core.auth import get_password_hash
  248. from backend.app.models.user import User
  249. from backend.app.services.bambu_cloud_credentials import get_stored_token
  250. user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
  251. db_session.add(user)
  252. await db_session.commit()
  253. await db_session.refresh(user)
  254. await store_token(db_session, "user-token-abc", "user@example.com", "global", user=user)
  255. # Re-fetch user to verify persistence
  256. from sqlalchemy import select
  257. result = await db_session.execute(select(User).where(User.id == user.id))
  258. refreshed = result.scalar_one()
  259. assert refreshed.cloud_token == "user-token-abc"
  260. assert refreshed.cloud_email == "user@example.com"
  261. assert refreshed.cloud_region == "global"
  262. @pytest.mark.asyncio
  263. async def test_per_user_token_does_not_affect_global(self, db_session):
  264. """Storing per-user token should not affect global Settings."""
  265. from backend.app.api.routes.cloud import store_token
  266. from backend.app.core.auth import get_password_hash
  267. from backend.app.models.user import User
  268. from backend.app.services.bambu_cloud_credentials import get_stored_token
  269. user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
  270. db_session.add(user)
  271. await db_session.commit()
  272. await db_session.refresh(user)
  273. # Store per-user token
  274. await store_token(db_session, "per-user-token", "per-user@test.com", "global", user=user)
  275. # Global should still be empty
  276. global_token, global_email, _ = await get_stored_token(db_session, user=None)
  277. assert global_token is None
  278. assert global_email is None
  279. @pytest.mark.asyncio
  280. async def test_clear_per_user_token(self, db_session):
  281. """clear_token with user clears only that user's credentials."""
  282. from backend.app.api.routes.cloud import clear_token, store_token
  283. from backend.app.core.auth import get_password_hash
  284. from backend.app.models.user import User
  285. user = User(username="cleartest", password_hash=get_password_hash("pass"), role="user")
  286. db_session.add(user)
  287. await db_session.commit()
  288. await db_session.refresh(user)
  289. await store_token(db_session, "to-clear", "clear@test.com", "china", user=user)
  290. await clear_token(db_session, user=user)
  291. from sqlalchemy import select
  292. result = await db_session.execute(select(User).where(User.id == user.id))
  293. refreshed = result.scalar_one()
  294. assert refreshed.cloud_token is None
  295. assert refreshed.cloud_email is None
  296. assert refreshed.cloud_region is None
  297. @pytest.mark.asyncio
  298. async def test_clear_global_token(self, db_session):
  299. """clear_token with user=None clears from global Settings."""
  300. from backend.app.api.routes.cloud import clear_token, store_token
  301. from backend.app.services.bambu_cloud_credentials import get_stored_token
  302. await store_token(db_session, "global-token", "global@test.com", "global", user=None)
  303. await clear_token(db_session, user=None)
  304. token, email, region = await get_stored_token(db_session, user=None)
  305. assert token is None
  306. assert email is None
  307. assert region == "global" # normalised default
  308. @pytest.mark.asyncio
  309. async def test_two_users_independent_tokens(self, db_session):
  310. """Two users should have completely independent cloud tokens and regions."""
  311. from backend.app.api.routes.cloud import store_token
  312. from backend.app.core.auth import get_password_hash
  313. from backend.app.models.user import User
  314. from backend.app.services.bambu_cloud_credentials import get_stored_token
  315. user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
  316. user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
  317. db_session.add_all([user_a, user_b])
  318. await db_session.commit()
  319. await db_session.refresh(user_a)
  320. await db_session.refresh(user_b)
  321. # Different regions on purpose — a China user and a Global user must not
  322. # bleed their region into each other's lookups.
  323. await store_token(db_session, "token-a", "a@test.com", "china", user=user_a)
  324. await store_token(db_session, "token-b", "b@test.com", "global", user=user_b)
  325. # Verify each user reads their own token (re-fetch from DB)
  326. from sqlalchemy import select
  327. result_a = await db_session.execute(select(User).where(User.id == user_a.id))
  328. result_b = await db_session.execute(select(User).where(User.id == user_b.id))
  329. fresh_a = result_a.scalar_one()
  330. fresh_b = result_b.scalar_one()
  331. token_a, email_a, region_a = await get_stored_token(db_session, user=fresh_a)
  332. token_b, email_b, region_b = await get_stored_token(db_session, user=fresh_b)
  333. assert token_a == "token-a"
  334. assert email_a == "a@test.com"
  335. assert region_a == "china"
  336. assert token_b == "token-b"
  337. assert email_b == "b@test.com"
  338. assert region_b == "global"
  339. class TestCloudRegionPersistence:
  340. """Region must survive a DB round-trip so restarts don't silently flip users to api.bambulab.com."""
  341. @pytest.mark.asyncio
  342. async def test_region_survives_roundtrip_per_user(self, db_session):
  343. """Stored China region is returned on subsequent get_stored_token calls."""
  344. from backend.app.api.routes.cloud import store_token
  345. from backend.app.core.auth import get_password_hash
  346. from backend.app.models.user import User
  347. from backend.app.services.bambu_cloud_credentials import get_stored_token
  348. user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
  349. db_session.add(user)
  350. await db_session.commit()
  351. await db_session.refresh(user)
  352. await store_token(db_session, "cn-token", "token-auth", "china", user=user)
  353. # Simulate "next request": re-fetch the user fresh from the DB.
  354. from sqlalchemy import select
  355. result = await db_session.execute(select(User).where(User.id == user.id))
  356. refreshed = result.scalar_one()
  357. _token, _email, region = await get_stored_token(db_session, user=refreshed)
  358. assert region == "china"
  359. @pytest.mark.asyncio
  360. async def test_region_survives_roundtrip_global_fallback(self, db_session):
  361. """Stored China region in auth-disabled Settings fallback survives too."""
  362. from backend.app.api.routes.cloud import store_token
  363. from backend.app.services.bambu_cloud_credentials import get_stored_token
  364. await store_token(db_session, "cn-token", "token-auth", "china", user=None)
  365. _token, _email, region = await get_stored_token(db_session, user=None)
  366. assert region == "china"
  367. @pytest.mark.asyncio
  368. async def test_invalid_region_is_normalised_to_global(self, db_session):
  369. """Unknown region values fall back to 'global' rather than mis-route."""
  370. from backend.app.api.routes.cloud import store_token
  371. from backend.app.services.bambu_cloud_credentials import get_stored_token
  372. await store_token(db_session, "t", "x@test.com", "mars", user=None)
  373. _token, _email, region = await get_stored_token(db_session, user=None)
  374. assert region == "global"
  375. @pytest.mark.asyncio
  376. async def test_build_authenticated_cloud_uses_stored_region(self, db_session):
  377. """build_authenticated_cloud wires the stored region into the per-request service."""
  378. from backend.app.api.routes.cloud import build_authenticated_cloud, store_token
  379. from backend.app.core.auth import get_password_hash
  380. from backend.app.models.user import User
  381. user = User(username="cn-build", password_hash=get_password_hash("pass"), role="user")
  382. db_session.add(user)
  383. await db_session.commit()
  384. await db_session.refresh(user)
  385. await store_token(db_session, "cn-token", "token-auth", "china", user=user)
  386. from sqlalchemy import select
  387. result = await db_session.execute(select(User).where(User.id == user.id))
  388. refreshed = result.scalar_one()
  389. cloud = await build_authenticated_cloud(db_session, refreshed)
  390. assert cloud is not None
  391. try:
  392. assert cloud.base_url == "https://api.bambulab.cn"
  393. assert cloud.access_token == "cn-token"
  394. finally:
  395. await cloud.close()
  396. class TestCloudRouteRegionPlumbing:
  397. """Route-level proof that region=china on the wire actually steers outbound
  398. HTTP calls to api.bambulab.cn / bambulab.cn. This is the core bug the PR
  399. fixes — unit tests prove the service does the right thing given the region,
  400. storage tests prove the region persists, but only these tests prove the
  401. route handlers plumb the region through end-to-end.
  402. Auth is disabled (Settings-fallback path) to keep the fixture footprint
  403. minimal; the region plumbing code path is identical for the per-user path.
  404. """
  405. @staticmethod
  406. def _capturing_client(response_json: dict, status: int = 200):
  407. """Build an httpx.AsyncClient backed by MockTransport that records every
  408. outbound request URL. Returns ``(client, captured_urls)``.
  409. Using MockTransport (rather than ``patch.object(httpx.AsyncClient, ...)``)
  410. is critical: class-level method patches also intercept the ASGI test
  411. client's own requests, so the route handler never runs and the
  412. assertions end up inspecting the test-client URL instead of the
  413. backend's outbound URL. MockTransport only affects the client we
  414. inject into the backend via ``set_shared_http_client``.
  415. """
  416. import httpx
  417. captured: list[str] = []
  418. def handler(request: httpx.Request) -> httpx.Response:
  419. captured.append(str(request.url))
  420. # The TOTP path now performs a CSRF handshake first (#2696): it
  421. # fetches /api/csrf and refuses to submit the code unless that call
  422. # yields a bbl_csrf_token cookie. Mint one here so region-routing
  423. # tests reach the TFA POST they are actually asserting on.
  424. if request.url.path == "/api/csrf":
  425. return httpx.Response(204, headers={"set-cookie": "bbl_csrf_token=csrf-test-token; Path=/"})
  426. return httpx.Response(status, json=response_json)
  427. client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  428. return client, captured
  429. @pytest.mark.asyncio
  430. @pytest.mark.integration
  431. async def test_set_token_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
  432. """POST /cloud/token with region=china routes get_user_profile to api.bambulab.cn."""
  433. from backend.app.services.bambu_cloud import set_shared_http_client
  434. mock_client, captured_urls = self._capturing_client({"uid": "123", "email": "x"})
  435. set_shared_http_client(mock_client)
  436. try:
  437. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  438. response = await async_client.post(
  439. "/api/v1/cloud/token",
  440. json={"access_token": "cn-token", "region": "china"},
  441. )
  442. assert response.status_code == 200
  443. assert any("api.bambulab.cn" in url for url in captured_urls), captured_urls
  444. assert not any("api.bambulab.com" in url for url in captured_urls), captured_urls
  445. finally:
  446. set_shared_http_client(None)
  447. await mock_client.aclose()
  448. @pytest.mark.asyncio
  449. @pytest.mark.integration
  450. async def test_login_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
  451. """POST /cloud/login with region=china routes login_request to api.bambulab.cn."""
  452. from backend.app.services.bambu_cloud import set_shared_http_client
  453. mock_client, captured_urls = self._capturing_client({"loginType": "verifyCode"})
  454. set_shared_http_client(mock_client)
  455. try:
  456. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  457. response = await async_client.post(
  458. "/api/v1/cloud/login",
  459. json={"email": "user@example.com", "password": "x", "region": "china"},
  460. )
  461. assert response.status_code == 200
  462. assert any("api.bambulab.cn" in url for url in captured_urls), captured_urls
  463. assert not any("api.bambulab.com" in url for url in captured_urls), captured_urls
  464. finally:
  465. set_shared_http_client(None)
  466. await mock_client.aclose()
  467. @pytest.mark.asyncio
  468. @pytest.mark.integration
  469. async def test_verify_route_with_china_region_hits_cn_tfa_endpoint(self, async_client: AsyncClient):
  470. """POST /cloud/verify with region=china + tfa_key routes TOTP to bambulab.cn."""
  471. from backend.app.services.bambu_cloud import set_shared_http_client
  472. mock_client, captured_urls = self._capturing_client({"token": "t"})
  473. set_shared_http_client(mock_client)
  474. try:
  475. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  476. response = await async_client.post(
  477. "/api/v1/cloud/verify",
  478. json={
  479. "email": "user@example.com",
  480. "code": "123456",
  481. "tfa_key": "tfa-xyz",
  482. "region": "china",
  483. },
  484. )
  485. assert response.status_code == 200
  486. # TOTP endpoint lives on bambulab.cn (without the api. prefix),
  487. # NOT bambulab.com — that's exactly the bug we just fixed.
  488. assert any("bambulab.cn/api/sign-in/tfa" in url for url in captured_urls), captured_urls
  489. # The CSRF handshake (#2696) must follow the same origin —
  490. # fetching a token from the global site would hand the .cn
  491. # endpoint a cookie it never issued.
  492. assert any("bambulab.cn/api/csrf" in url for url in captured_urls), captured_urls
  493. assert not any("bambulab.com" in url for url in captured_urls), captured_urls
  494. finally:
  495. set_shared_http_client(None)
  496. await mock_client.aclose()
  497. @pytest.mark.asyncio
  498. @pytest.mark.integration
  499. async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
  500. """GET /cloud/status returns the stored region so the UI can render
  501. 'Connected (China)' after a reload.
  502. ``validate_token`` is stubbed because the endpoint now asks Bambu whether
  503. the stored token is still accepted rather than assuming it is — without
  504. the stub this test would make a live call to api.bambulab.cn with a fake
  505. token, get a 401, and correctly report the session as expired. Region
  506. plumbing is what's under test here.
  507. """
  508. from backend.app.api.routes.cloud import store_token
  509. from backend.app.core.database import async_session
  510. from backend.app.services.bambu_cloud import BambuCloudService
  511. with (
  512. patch("backend.app.core.auth.is_auth_enabled", return_value=False),
  513. patch.object(BambuCloudService, "validate_token", AsyncMock(return_value=True)),
  514. ):
  515. async with async_session() as db:
  516. await store_token(db, "cn-token", "token-auth", "china", user=None)
  517. response = await async_client.get("/api/v1/cloud/status")
  518. assert response.status_code == 200
  519. data = response.json()
  520. assert data["is_authenticated"] is True
  521. assert data["region"] == "china"
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_cloud_status_region_is_null_when_unauthenticated(self, async_client: AsyncClient):
  525. """No stored token ⇒ no region in the status payload."""
  526. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  527. response = await async_client.get("/api/v1/cloud/status")
  528. assert response.status_code == 200
  529. data = response.json()
  530. assert data["is_authenticated"] is False
  531. assert data["region"] is None