test_cloud_token_auth_migration.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. """Cloud-credential migration across the auth on/off boundary (#2530).
  2. ``get_stored_token`` reads global ``Settings`` rows when auth is disabled and
  3. ``User.cloud_token`` when it's enabled. Toggling auth therefore switches which
  4. store the ``/cloud/*`` routes consult. Without an explicit hand-off the token
  5. is stranded in the store nobody reads: ``build_authenticated_cloud`` returns
  6. ``None``, Phase 2 of ``get_filament_info`` is skipped entirely, and the caller
  7. sees a ``200`` full of local-preset fallbacks with no sign the cloud was never
  8. contacted. That silent degradation is what #2530 actually reported.
  9. These tests pin the hand-off in both directions, and — just as importantly —
  10. pin the two cases where Bambuddy must refuse to guess who owns a credential.
  11. """
  12. import logging
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.api.routes import cloud as cloud_routes
  18. from backend.app.api.routes.cloud import (
  19. CLOUD_EMAIL_KEY,
  20. CLOUD_REGION_KEY,
  21. CLOUD_TOKEN_KEY,
  22. get_stored_token,
  23. )
  24. from backend.app.core.auth import get_password_hash
  25. from backend.app.models.settings import Settings
  26. from backend.app.models.user import User
  27. from backend.app.services.bambu_cloud import BambuCloudError
  28. async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
  29. db.add(Settings(key=CLOUD_TOKEN_KEY, value=token))
  30. db.add(Settings(key=CLOUD_EMAIL_KEY, value="owner@example.com"))
  31. db.add(Settings(key=CLOUD_REGION_KEY, value=region))
  32. await db.commit()
  33. async def _global_rows(db: AsyncSession) -> dict[str, str]:
  34. rows = (
  35. (
  36. await db.execute(
  37. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  38. )
  39. )
  40. .scalars()
  41. .all()
  42. )
  43. return {r.key: r.value for r in rows}
  44. async def _make_admin(db: AsyncSession, username: str) -> User:
  45. user = User(
  46. username=username,
  47. password_hash=get_password_hash("AdminPass1!"),
  48. role="admin",
  49. is_active=True,
  50. )
  51. db.add(user)
  52. await db.commit()
  53. await db.refresh(user)
  54. return user
  55. # ---------------------------------------------------------------------------
  56. # auth OFF -> ON
  57. # ---------------------------------------------------------------------------
  58. @pytest.mark.asyncio
  59. async def test_setup_migrates_global_token_to_created_admin(async_client: AsyncClient, db_session: AsyncSession):
  60. """The reporter's exact path: link cloud with auth off, then enable auth."""
  61. await _seed_global_token(db_session)
  62. resp = await async_client.post(
  63. "/api/v1/auth/setup",
  64. json={"auth_enabled": True, "admin_username": "admin", "admin_password": "AdminPass1!"},
  65. )
  66. assert resp.status_code == 200, resp.text
  67. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  68. token, email, region = await get_stored_token(db_session, admin)
  69. assert token == "tok-global"
  70. assert email == "owner@example.com"
  71. assert region == "china", "region must survive the hop, not silently reset to global"
  72. # Credential must not be left at rest in a table nothing reads any more.
  73. assert await _global_rows(db_session) == {}
  74. @pytest.mark.asyncio
  75. async def test_setup_migrates_to_sole_pre_existing_admin(async_client: AsyncClient, db_session: AsyncSession):
  76. """Re-enabling auth when exactly one admin already exists has one obvious owner."""
  77. admin = await _make_admin(db_session, "solo")
  78. await _seed_global_token(db_session, token="tok-solo")
  79. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
  80. assert resp.status_code == 200, resp.text
  81. assert resp.json()["admin_created"] is False
  82. await db_session.refresh(admin)
  83. token, _, _ = await get_stored_token(db_session, admin)
  84. assert token == "tok-solo"
  85. assert await _global_rows(db_session) == {}
  86. @pytest.mark.asyncio
  87. async def test_setup_refuses_to_guess_owner_when_multiple_admins(async_client: AsyncClient, db_session: AsyncSession):
  88. """Two admins, one credential: handing it to either is a security decision we don't make."""
  89. a = await _make_admin(db_session, "admin_a")
  90. b = await _make_admin(db_session, "admin_b")
  91. await _seed_global_token(db_session, token="tok-ambiguous")
  92. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
  93. assert resp.status_code == 200, resp.text
  94. await db_session.refresh(a)
  95. await db_session.refresh(b)
  96. assert a.cloud_token is None
  97. assert b.cloud_token is None
  98. # Left intact so the operator can re-link rather than lose it.
  99. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-ambiguous"
  100. @pytest.mark.asyncio
  101. async def test_setup_with_auth_disabled_leaves_global_token_untouched(
  102. async_client: AsyncClient, db_session: AsyncSession
  103. ):
  104. """Completing setup while declining auth must not move anything."""
  105. await _seed_global_token(db_session, token="tok-stay")
  106. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": False})
  107. assert resp.status_code == 200, resp.text
  108. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-stay"
  109. # ---------------------------------------------------------------------------
  110. # auth ON -> OFF
  111. # ---------------------------------------------------------------------------
  112. async def _admin_bearer(async_client: AsyncClient, username: str = "admin") -> str:
  113. await async_client.post(
  114. "/api/v1/auth/setup",
  115. json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
  116. )
  117. login = await async_client.post(
  118. "/api/v1/auth/login",
  119. json={"username": username, "password": "AdminPass1!"},
  120. )
  121. return login.json()["access_token"]
  122. @pytest.mark.asyncio
  123. async def test_disable_auth_migrates_admin_token_to_global(async_client: AsyncClient, db_session: AsyncSession):
  124. bearer = await _admin_bearer(async_client)
  125. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  126. admin.cloud_token = "tok-user"
  127. admin.cloud_email = "user@example.com"
  128. admin.cloud_region = "china"
  129. await db_session.commit()
  130. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  131. assert resp.status_code == 200, resp.text
  132. rows = await _global_rows(db_session)
  133. assert rows[CLOUD_TOKEN_KEY] == "tok-user"
  134. assert rows[CLOUD_REGION_KEY] == "china"
  135. await db_session.refresh(admin)
  136. assert admin.cloud_token is None, "credential must not be duplicated across both stores"
  137. # And the no-auth read path now finds it.
  138. token, _, _ = await get_stored_token(db_session, None)
  139. assert token == "tok-user"
  140. @pytest.mark.asyncio
  141. async def test_disable_auth_does_not_clobber_existing_global_token(async_client: AsyncClient, db_session: AsyncSession):
  142. """A stale global row is still somebody's credential — refuse rather than overwrite."""
  143. bearer = await _admin_bearer(async_client)
  144. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  145. admin.cloud_token = "tok-user"
  146. await db_session.commit()
  147. await _seed_global_token(db_session, token="tok-preexisting")
  148. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  149. assert resp.status_code == 200, resp.text
  150. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-preexisting"
  151. await db_session.refresh(admin)
  152. assert admin.cloud_token == "tok-user", "admin keeps their token when we decline to migrate"
  153. @pytest.mark.asyncio
  154. async def test_disable_auth_with_no_cloud_token_is_a_noop(async_client: AsyncClient, db_session: AsyncSession):
  155. bearer = await _admin_bearer(async_client)
  156. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  157. assert resp.status_code == 200, resp.text
  158. assert await _global_rows(db_session) == {}
  159. # ---------------------------------------------------------------------------
  160. # Log-level classification for cloud preset misses (#2530)
  161. # ---------------------------------------------------------------------------
  162. def test_bambu_cloud_error_carries_status_code():
  163. """The 400-vs-fault distinction depends on this attribute existing."""
  164. from backend.app.services.bambu_cloud import BambuCloudError
  165. assert BambuCloudError("boom").status_code is None
  166. assert BambuCloudError("boom", status_code=400).status_code == 400
  167. # Every pre-existing raise site passes a bare message; must not break.
  168. assert str(BambuCloudError("Request failed: timeout")) == "Request failed: timeout"
  169. class _StubCloud:
  170. """Minimal stand-in for BambuCloudService that fails every preset lookup."""
  171. def __init__(self, error: Exception):
  172. self._error = error
  173. self.is_authenticated = True
  174. async def get_setting_detail(self, setting_id: str) -> dict:
  175. raise self._error
  176. async def close(self) -> None:
  177. return None
  178. @pytest.mark.asyncio
  179. @pytest.mark.parametrize(
  180. ("error", "expected_level"),
  181. [
  182. (BambuCloudError("missing", status_code=400), logging.DEBUG),
  183. (BambuCloudError("unauthorized", status_code=401), logging.WARNING),
  184. (BambuCloudError("bad gateway", status_code=502), logging.WARNING),
  185. (BambuCloudError("Request failed: timeout"), logging.WARNING),
  186. ],
  187. ids=["expected-400-miss", "expired-token", "cloud-outage", "transport-failure"],
  188. )
  189. async def test_preset_miss_logs_at_debug_but_faults_stay_at_warning(
  190. monkeypatch, caplog, db_session: AsyncSession, error: Exception, expected_level: int
  191. ):
  192. """A preset the catalog doesn't carry is routine; an expired token is a fault.
  193. Drives the real ``get_filament_info`` route so the classification is exercised
  194. where it lives, not re-derived in the test.
  195. """
  196. monkeypatch.setattr(cloud_routes, "build_authenticated_cloud", _stub_builder(error))
  197. # Phase 1 would otherwise short-circuit the cloud call on a warm cache.
  198. monkeypatch.setattr(cloud_routes, "_filament_cache", {})
  199. with caplog.at_level(logging.DEBUG, logger="backend.app.api.routes.cloud"):
  200. result = await cloud_routes.get_filament_info(setting_ids=["GFL05"], db=db_session, current_user=None)
  201. records = [r for r in caplog.records if "Failed to get cloud preset" in r.getMessage()]
  202. assert len(records) == 1, "the miss must be logged exactly once"
  203. assert records[0].levelno == expected_level
  204. assert "GFL05" in records[0].getMessage()
  205. assert "GFSL05" in records[0].getMessage(), "the translated API ID stays in the message"
  206. # Whatever the level, the endpoint still answers and falls through to Phase 3.
  207. assert isinstance(result, dict)
  208. def _stub_builder(error: Exception):
  209. async def _build(db, user):
  210. return _StubCloud(error)
  211. return _build