test_cloud_auth.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  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, MagicMock, 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.api.routes.cloud 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 get_stored_token, store_token
  237. await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
  238. token, email, region = await get_stored_token(db_session, user=None)
  239. assert token == "test-token-123"
  240. assert email == "test@example.com"
  241. assert region == "global"
  242. @pytest.mark.asyncio
  243. async def test_store_and_get_per_user_token(self, db_session):
  244. """store_token with user stores on the user record."""
  245. from backend.app.api.routes.cloud import get_stored_token, store_token
  246. from backend.app.core.auth import get_password_hash
  247. from backend.app.models.user import User
  248. user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
  249. db_session.add(user)
  250. await db_session.commit()
  251. await db_session.refresh(user)
  252. await store_token(db_session, "user-token-abc", "user@example.com", "global", user=user)
  253. # Re-fetch user to verify persistence
  254. from sqlalchemy import select
  255. result = await db_session.execute(select(User).where(User.id == user.id))
  256. refreshed = result.scalar_one()
  257. assert refreshed.cloud_token == "user-token-abc"
  258. assert refreshed.cloud_email == "user@example.com"
  259. assert refreshed.cloud_region == "global"
  260. @pytest.mark.asyncio
  261. async def test_per_user_token_does_not_affect_global(self, db_session):
  262. """Storing per-user token should not affect global Settings."""
  263. from backend.app.api.routes.cloud import get_stored_token, store_token
  264. from backend.app.core.auth import get_password_hash
  265. from backend.app.models.user import User
  266. user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
  267. db_session.add(user)
  268. await db_session.commit()
  269. await db_session.refresh(user)
  270. # Store per-user token
  271. await store_token(db_session, "per-user-token", "per-user@test.com", "global", user=user)
  272. # Global should still be empty
  273. global_token, global_email, _ = await get_stored_token(db_session, user=None)
  274. assert global_token is None
  275. assert global_email is None
  276. @pytest.mark.asyncio
  277. async def test_clear_per_user_token(self, db_session):
  278. """clear_token with user clears only that user's credentials."""
  279. from backend.app.api.routes.cloud import clear_token, store_token
  280. from backend.app.core.auth import get_password_hash
  281. from backend.app.models.user import User
  282. user = User(username="cleartest", password_hash=get_password_hash("pass"), role="user")
  283. db_session.add(user)
  284. await db_session.commit()
  285. await db_session.refresh(user)
  286. await store_token(db_session, "to-clear", "clear@test.com", "china", user=user)
  287. await clear_token(db_session, user=user)
  288. from sqlalchemy import select
  289. result = await db_session.execute(select(User).where(User.id == user.id))
  290. refreshed = result.scalar_one()
  291. assert refreshed.cloud_token is None
  292. assert refreshed.cloud_email is None
  293. assert refreshed.cloud_region is None
  294. @pytest.mark.asyncio
  295. async def test_clear_global_token(self, db_session):
  296. """clear_token with user=None clears from global Settings."""
  297. from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
  298. await store_token(db_session, "global-token", "global@test.com", "global", user=None)
  299. await clear_token(db_session, user=None)
  300. token, email, region = await get_stored_token(db_session, user=None)
  301. assert token is None
  302. assert email is None
  303. assert region == "global" # normalised default
  304. @pytest.mark.asyncio
  305. async def test_two_users_independent_tokens(self, db_session):
  306. """Two users should have completely independent cloud tokens and regions."""
  307. from backend.app.api.routes.cloud import get_stored_token, store_token
  308. from backend.app.core.auth import get_password_hash
  309. from backend.app.models.user import User
  310. user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
  311. user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
  312. db_session.add_all([user_a, user_b])
  313. await db_session.commit()
  314. await db_session.refresh(user_a)
  315. await db_session.refresh(user_b)
  316. # Different regions on purpose — a China user and a Global user must not
  317. # bleed their region into each other's lookups.
  318. await store_token(db_session, "token-a", "a@test.com", "china", user=user_a)
  319. await store_token(db_session, "token-b", "b@test.com", "global", user=user_b)
  320. # Verify each user reads their own token (re-fetch from DB)
  321. from sqlalchemy import select
  322. result_a = await db_session.execute(select(User).where(User.id == user_a.id))
  323. result_b = await db_session.execute(select(User).where(User.id == user_b.id))
  324. fresh_a = result_a.scalar_one()
  325. fresh_b = result_b.scalar_one()
  326. token_a, email_a, region_a = await get_stored_token(db_session, user=fresh_a)
  327. token_b, email_b, region_b = await get_stored_token(db_session, user=fresh_b)
  328. assert token_a == "token-a"
  329. assert email_a == "a@test.com"
  330. assert region_a == "china"
  331. assert token_b == "token-b"
  332. assert email_b == "b@test.com"
  333. assert region_b == "global"
  334. class TestCloudRegionPersistence:
  335. """Region must survive a DB round-trip so restarts don't silently flip users to api.bambulab.com."""
  336. @pytest.mark.asyncio
  337. async def test_region_survives_roundtrip_per_user(self, db_session):
  338. """Stored China region is returned on subsequent get_stored_token calls."""
  339. from backend.app.api.routes.cloud import get_stored_token, store_token
  340. from backend.app.core.auth import get_password_hash
  341. from backend.app.models.user import User
  342. user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
  343. db_session.add(user)
  344. await db_session.commit()
  345. await db_session.refresh(user)
  346. await store_token(db_session, "cn-token", "token-auth", "china", user=user)
  347. # Simulate "next request": re-fetch the user fresh from the DB.
  348. from sqlalchemy import select
  349. result = await db_session.execute(select(User).where(User.id == user.id))
  350. refreshed = result.scalar_one()
  351. _token, _email, region = await get_stored_token(db_session, user=refreshed)
  352. assert region == "china"
  353. @pytest.mark.asyncio
  354. async def test_region_survives_roundtrip_global_fallback(self, db_session):
  355. """Stored China region in auth-disabled Settings fallback survives too."""
  356. from backend.app.api.routes.cloud import get_stored_token, store_token
  357. await store_token(db_session, "cn-token", "token-auth", "china", user=None)
  358. _token, _email, region = await get_stored_token(db_session, user=None)
  359. assert region == "china"
  360. @pytest.mark.asyncio
  361. async def test_invalid_region_is_normalised_to_global(self, db_session):
  362. """Unknown region values fall back to 'global' rather than mis-route."""
  363. from backend.app.api.routes.cloud import get_stored_token, store_token
  364. await store_token(db_session, "t", "x@test.com", "mars", user=None)
  365. _token, _email, region = await get_stored_token(db_session, user=None)
  366. assert region == "global"
  367. @pytest.mark.asyncio
  368. async def test_build_authenticated_cloud_uses_stored_region(self, db_session):
  369. """build_authenticated_cloud wires the stored region into the per-request service."""
  370. from backend.app.api.routes.cloud import build_authenticated_cloud, store_token
  371. from backend.app.core.auth import get_password_hash
  372. from backend.app.models.user import User
  373. user = User(username="cn-build", password_hash=get_password_hash("pass"), role="user")
  374. db_session.add(user)
  375. await db_session.commit()
  376. await db_session.refresh(user)
  377. await store_token(db_session, "cn-token", "token-auth", "china", user=user)
  378. from sqlalchemy import select
  379. result = await db_session.execute(select(User).where(User.id == user.id))
  380. refreshed = result.scalar_one()
  381. cloud = await build_authenticated_cloud(db_session, refreshed)
  382. assert cloud is not None
  383. try:
  384. assert cloud.base_url == "https://api.bambulab.cn"
  385. assert cloud.access_token == "cn-token"
  386. finally:
  387. await cloud.close()
  388. class TestCloudRouteRegionPlumbing:
  389. """Route-level proof that region=china on the wire actually steers outbound
  390. HTTP calls to api.bambulab.cn / bambulab.cn. This is the core bug the PR
  391. fixes — unit tests prove the service does the right thing given the region,
  392. storage tests prove the region persists, but only these tests prove the
  393. route handlers plumb the region through end-to-end.
  394. Auth is disabled (Settings-fallback path) to keep the fixture footprint
  395. minimal; the region plumbing code path is identical for the per-user path.
  396. """
  397. @staticmethod
  398. def _make_response(json_body: dict, status: int = 200):
  399. """Build a MagicMock httpx.Response stand-in for patched posts/gets."""
  400. response = MagicMock()
  401. response.status_code = status
  402. response.text = "{}"
  403. response.json.return_value = json_body
  404. response.cookies = {}
  405. return response
  406. @pytest.mark.asyncio
  407. @pytest.mark.integration
  408. async def test_set_token_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
  409. """POST /cloud/token with region=china routes get_user_profile to api.bambulab.cn."""
  410. import httpx
  411. with (
  412. patch("backend.app.core.auth.is_auth_enabled", return_value=False),
  413. patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get,
  414. ):
  415. mock_get.return_value = self._make_response({"uid": "123", "email": "x"})
  416. response = await async_client.post(
  417. "/api/v1/cloud/token",
  418. json={"access_token": "cn-token", "region": "china"},
  419. )
  420. assert response.status_code == 200
  421. # The profile check call must have hit api.bambulab.cn, never .com
  422. called_urls = [str(call.args[0]) for call in mock_get.call_args_list if call.args]
  423. assert any("api.bambulab.cn" in url for url in called_urls), called_urls
  424. assert not any("api.bambulab.com" in url for url in called_urls), called_urls
  425. @pytest.mark.asyncio
  426. @pytest.mark.integration
  427. async def test_login_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
  428. """POST /cloud/login with region=china routes login_request to api.bambulab.cn."""
  429. import httpx
  430. with (
  431. patch("backend.app.core.auth.is_auth_enabled", return_value=False),
  432. patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post,
  433. ):
  434. mock_post.return_value = self._make_response({"loginType": "verifyCode"})
  435. response = await async_client.post(
  436. "/api/v1/cloud/login",
  437. json={"email": "user@example.com", "password": "x", "region": "china"},
  438. )
  439. assert response.status_code == 200
  440. called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args]
  441. assert any("api.bambulab.cn" in url for url in called_urls), called_urls
  442. assert not any("api.bambulab.com" in url for url in called_urls), called_urls
  443. @pytest.mark.asyncio
  444. @pytest.mark.integration
  445. async def test_verify_route_with_china_region_hits_cn_tfa_endpoint(self, async_client: AsyncClient):
  446. """POST /cloud/verify with region=china + tfa_key routes TOTP to bambulab.cn."""
  447. import httpx
  448. with (
  449. patch("backend.app.core.auth.is_auth_enabled", return_value=False),
  450. patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post,
  451. ):
  452. mock_post.return_value = self._make_response({"token": "t"})
  453. response = await async_client.post(
  454. "/api/v1/cloud/verify",
  455. json={
  456. "email": "user@example.com",
  457. "code": "123456",
  458. "tfa_key": "tfa-xyz",
  459. "region": "china",
  460. },
  461. )
  462. assert response.status_code == 200
  463. called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args]
  464. # TOTP endpoint lives on bambulab.cn (without the api. prefix),
  465. # NOT bambulab.com — that's exactly the bug we just fixed.
  466. assert any("bambulab.cn/api/sign-in/tfa" in url for url in called_urls), called_urls
  467. assert not any("bambulab.com" in url for url in called_urls), called_urls
  468. @pytest.mark.asyncio
  469. @pytest.mark.integration
  470. async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
  471. """GET /cloud/status returns the stored region so the UI can render
  472. 'Connected (China)' after a reload."""
  473. from backend.app.api.routes.cloud import store_token
  474. from backend.app.core.database import async_session
  475. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  476. async with async_session() as db:
  477. await store_token(db, "cn-token", "token-auth", "china", user=None)
  478. response = await async_client.get("/api/v1/cloud/status")
  479. assert response.status_code == 200
  480. data = response.json()
  481. assert data["is_authenticated"] is True
  482. assert data["region"] == "china"
  483. @pytest.mark.asyncio
  484. @pytest.mark.integration
  485. async def test_cloud_status_region_is_null_when_unauthenticated(self, async_client: AsyncClient):
  486. """No stored token ⇒ no region in the status payload."""
  487. with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
  488. response = await async_client.get("/api/v1/cloud/status")
  489. assert response.status_code == 200
  490. data = response.json()
  491. assert data["is_authenticated"] is False
  492. assert data["region"] is None