test_cloud_token_auth_migration.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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 pytest
  13. from httpx import AsyncClient
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.api.routes.cloud import (
  17. CLOUD_EMAIL_KEY,
  18. CLOUD_REGION_KEY,
  19. CLOUD_TOKEN_KEY,
  20. get_stored_token,
  21. )
  22. from backend.app.core.auth import get_password_hash
  23. from backend.app.models.settings import Settings
  24. from backend.app.models.user import User
  25. async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
  26. db.add(Settings(key=CLOUD_TOKEN_KEY, value=token))
  27. db.add(Settings(key=CLOUD_EMAIL_KEY, value="owner@example.com"))
  28. db.add(Settings(key=CLOUD_REGION_KEY, value=region))
  29. await db.commit()
  30. async def _global_rows(db: AsyncSession) -> dict[str, str]:
  31. rows = (
  32. (
  33. await db.execute(
  34. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  35. )
  36. )
  37. .scalars()
  38. .all()
  39. )
  40. return {r.key: r.value for r in rows}
  41. async def _make_admin(db: AsyncSession, username: str) -> User:
  42. user = User(
  43. username=username,
  44. password_hash=get_password_hash("AdminPass1!"),
  45. role="admin",
  46. is_active=True,
  47. )
  48. db.add(user)
  49. await db.commit()
  50. await db.refresh(user)
  51. return user
  52. # ---------------------------------------------------------------------------
  53. # auth OFF -> ON
  54. # ---------------------------------------------------------------------------
  55. @pytest.mark.asyncio
  56. async def test_setup_migrates_global_token_to_created_admin(async_client: AsyncClient, db_session: AsyncSession):
  57. """The reporter's exact path: link cloud with auth off, then enable auth."""
  58. await _seed_global_token(db_session)
  59. resp = await async_client.post(
  60. "/api/v1/auth/setup",
  61. json={"auth_enabled": True, "admin_username": "admin", "admin_password": "AdminPass1!"},
  62. )
  63. assert resp.status_code == 200, resp.text
  64. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  65. token, email, region = await get_stored_token(db_session, admin)
  66. assert token == "tok-global"
  67. assert email == "owner@example.com"
  68. assert region == "china", "region must survive the hop, not silently reset to global"
  69. # Credential must not be left at rest in a table nothing reads any more.
  70. assert await _global_rows(db_session) == {}
  71. @pytest.mark.asyncio
  72. async def test_setup_migrates_to_sole_pre_existing_admin(async_client: AsyncClient, db_session: AsyncSession):
  73. """Re-enabling auth when exactly one admin already exists has one obvious owner."""
  74. admin = await _make_admin(db_session, "solo")
  75. await _seed_global_token(db_session, token="tok-solo")
  76. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
  77. assert resp.status_code == 200, resp.text
  78. assert resp.json()["admin_created"] is False
  79. await db_session.refresh(admin)
  80. token, _, _ = await get_stored_token(db_session, admin)
  81. assert token == "tok-solo"
  82. assert await _global_rows(db_session) == {}
  83. @pytest.mark.asyncio
  84. async def test_setup_refuses_to_guess_owner_when_multiple_admins(async_client: AsyncClient, db_session: AsyncSession):
  85. """Two admins, one credential: handing it to either is a security decision we don't make."""
  86. a = await _make_admin(db_session, "admin_a")
  87. b = await _make_admin(db_session, "admin_b")
  88. await _seed_global_token(db_session, token="tok-ambiguous")
  89. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
  90. assert resp.status_code == 200, resp.text
  91. await db_session.refresh(a)
  92. await db_session.refresh(b)
  93. assert a.cloud_token is None
  94. assert b.cloud_token is None
  95. # Left intact so the operator can re-link rather than lose it.
  96. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-ambiguous"
  97. @pytest.mark.asyncio
  98. async def test_setup_with_auth_disabled_leaves_global_token_untouched(
  99. async_client: AsyncClient, db_session: AsyncSession
  100. ):
  101. """Completing setup while declining auth must not move anything."""
  102. await _seed_global_token(db_session, token="tok-stay")
  103. resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": False})
  104. assert resp.status_code == 200, resp.text
  105. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-stay"
  106. # ---------------------------------------------------------------------------
  107. # auth ON -> OFF
  108. # ---------------------------------------------------------------------------
  109. async def _admin_bearer(async_client: AsyncClient, username: str = "admin") -> str:
  110. await async_client.post(
  111. "/api/v1/auth/setup",
  112. json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
  113. )
  114. login = await async_client.post(
  115. "/api/v1/auth/login",
  116. json={"username": username, "password": "AdminPass1!"},
  117. )
  118. return login.json()["access_token"]
  119. @pytest.mark.asyncio
  120. async def test_disable_auth_migrates_admin_token_to_global(async_client: AsyncClient, db_session: AsyncSession):
  121. bearer = await _admin_bearer(async_client)
  122. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  123. admin.cloud_token = "tok-user"
  124. admin.cloud_email = "user@example.com"
  125. admin.cloud_region = "china"
  126. await db_session.commit()
  127. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  128. assert resp.status_code == 200, resp.text
  129. rows = await _global_rows(db_session)
  130. assert rows[CLOUD_TOKEN_KEY] == "tok-user"
  131. assert rows[CLOUD_REGION_KEY] == "china"
  132. await db_session.refresh(admin)
  133. assert admin.cloud_token is None, "credential must not be duplicated across both stores"
  134. # And the no-auth read path now finds it.
  135. token, _, _ = await get_stored_token(db_session, None)
  136. assert token == "tok-user"
  137. @pytest.mark.asyncio
  138. async def test_disable_auth_does_not_clobber_existing_global_token(async_client: AsyncClient, db_session: AsyncSession):
  139. """A stale global row is still somebody's credential — refuse rather than overwrite."""
  140. bearer = await _admin_bearer(async_client)
  141. admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
  142. admin.cloud_token = "tok-user"
  143. await db_session.commit()
  144. await _seed_global_token(db_session, token="tok-preexisting")
  145. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  146. assert resp.status_code == 200, resp.text
  147. assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-preexisting"
  148. await db_session.refresh(admin)
  149. assert admin.cloud_token == "tok-user", "admin keeps their token when we decline to migrate"
  150. @pytest.mark.asyncio
  151. async def test_disable_auth_with_no_cloud_token_is_a_noop(async_client: AsyncClient, db_session: AsyncSession):
  152. bearer = await _admin_bearer(async_client)
  153. resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
  154. assert resp.status_code == 200, resp.text
  155. assert await _global_rows(db_session) == {}