test_oidc_env_apply.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """Upserting the env-managed OIDC provider (#2593).
  2. Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
  3. never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
  4. recreating the provider would silently unlink every account bound to it.
  5. """
  6. from __future__ import annotations
  7. import pytest
  8. from sqlalchemy import select
  9. from backend.app.core.oidc_env import apply_env_oidc_provider
  10. from backend.app.models.oidc_provider import OIDCProvider
  11. REQUIRED = {
  12. "BAMBUDDY_OIDC_NAME": "Keycloak",
  13. "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
  14. "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
  15. "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
  16. }
  17. ALL_VARS = (
  18. *REQUIRED,
  19. "BAMBUDDY_OIDC_SCOPES",
  20. "BAMBUDDY_OIDC_ENABLED",
  21. "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
  22. "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
  23. "BAMBUDDY_OIDC_EMAIL_CLAIM",
  24. "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
  25. "BAMBUDDY_OIDC_ICON_URL",
  26. "BAMBUDDY_OIDC_AUTOLOGIN",
  27. )
  28. @pytest.fixture(autouse=True)
  29. def clean_env(monkeypatch):
  30. for key in ALL_VARS:
  31. monkeypatch.delenv(key, raising=False)
  32. def _configure(monkeypatch, **overrides):
  33. for key, value in REQUIRED.items():
  34. monkeypatch.setenv(key, value)
  35. for key, value in overrides.items():
  36. monkeypatch.setenv(key, value)
  37. async def _env_provider(db_session) -> OIDCProvider | None:
  38. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  39. return result.scalar_one_or_none()
  40. @pytest.mark.asyncio
  41. async def test_creates_the_provider_from_env(db_session, monkeypatch):
  42. _configure(monkeypatch)
  43. await apply_env_oidc_provider(db_session)
  44. provider = await _env_provider(db_session)
  45. assert provider is not None
  46. assert provider.name == "Keycloak"
  47. assert provider.client_id == "bambuddy"
  48. assert provider.is_env_managed is True
  49. assert provider.client_secret == "s3cr3t" # property decrypts
  50. @pytest.mark.asyncio
  51. async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
  52. """The id must survive: user_oidc_links references it with ON DELETE
  53. CASCADE, so a delete-recreate would unlink every bound account."""
  54. _configure(monkeypatch)
  55. await apply_env_oidc_provider(db_session)
  56. original_id = (await _env_provider(db_session)).id
  57. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  58. await apply_env_oidc_provider(db_session)
  59. provider = await _env_provider(db_session)
  60. assert provider.id == original_id
  61. assert provider.client_id == "rotated"
  62. @pytest.mark.asyncio
  63. async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
  64. _configure(monkeypatch)
  65. await apply_env_oidc_provider(db_session)
  66. original_id = (await _env_provider(db_session)).id
  67. for key in ALL_VARS:
  68. monkeypatch.delenv(key, raising=False)
  69. await apply_env_oidc_provider(db_session)
  70. # Looked up by name, not by the flag: releasing the provider clears the flag,
  71. # and the point of this test is that the ROW survives either way.
  72. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  73. provider = result.scalar_one_or_none()
  74. assert provider is not None, "deleting would cascade away every account link"
  75. assert provider.id == original_id
  76. assert provider.is_enabled is False
  77. @pytest.mark.asyncio
  78. async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
  79. """Only one provider may be the autologin target; the env one wins."""
  80. ui_provider = OIDCProvider(
  81. name="UI provider",
  82. issuer_url="https://other.example.com",
  83. client_id="ui",
  84. is_autologin=True,
  85. )
  86. ui_provider.client_secret = "ui-secret"
  87. db_session.add(ui_provider)
  88. await db_session.commit()
  89. _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
  90. await apply_env_oidc_provider(db_session)
  91. await db_session.refresh(ui_provider)
  92. assert (await _env_provider(db_session)).is_autologin is True
  93. assert ui_provider.is_autologin is False
  94. @pytest.mark.asyncio
  95. async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
  96. ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
  97. ui_provider.client_secret = "ui-secret"
  98. db_session.add(ui_provider)
  99. await db_session.commit()
  100. _configure(monkeypatch)
  101. await apply_env_oidc_provider(db_session)
  102. await db_session.refresh(ui_provider)
  103. assert ui_provider.is_env_managed is False
  104. assert ui_provider.is_enabled is True
  105. assert ui_provider.client_id == "ui"
  106. @pytest.mark.asyncio
  107. async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
  108. """auto-link + unverified email is the SEC-1 account-takeover shape. The
  109. schema rejects it for the UI, and env config must not be a way around that
  110. -- but a bad variable must not stop the app from booting either."""
  111. _configure(
  112. monkeypatch,
  113. BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
  114. BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
  115. )
  116. await apply_env_oidc_provider(db_session)
  117. assert await _env_provider(db_session) is None
  118. @pytest.mark.asyncio
  119. async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
  120. """Every boot re-applies; the second run must not create a second row."""
  121. _configure(monkeypatch)
  122. await apply_env_oidc_provider(db_session)
  123. await apply_env_oidc_provider(db_session)
  124. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  125. assert len(result.scalars().all()) == 1
  126. # --- identity is the name, not the flag ---------------------------------------
  127. # The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
  128. # Matching on is_env_managed instead made three things impossible: adopting a
  129. # provider that already carries the name (the insert hit the unique constraint
  130. # and took startup down with it), releasing the provider when the config goes
  131. # away, and finding it again afterwards.
  132. @pytest.mark.asyncio
  133. async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
  134. """An operator who names the env provider after one they created in the UI
  135. must not end up with an app that refuses to boot."""
  136. ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
  137. ui_provider.client_secret = "ui-secret"
  138. db_session.add(ui_provider)
  139. await db_session.commit()
  140. original_id = ui_provider.id
  141. _configure(monkeypatch)
  142. await apply_env_oidc_provider(db_session)
  143. provider = await _env_provider(db_session)
  144. assert provider is not None
  145. assert provider.id == original_id, "adopted, not duplicated"
  146. assert provider.client_id == "bambuddy"
  147. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  148. assert len(result.scalars().all()) == 1
  149. @pytest.mark.asyncio
  150. async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
  151. """Nothing manages it any more, so the API must stop refusing edits and
  152. deletes -- otherwise the row is a dead end only reachable via the database."""
  153. _configure(monkeypatch)
  154. await apply_env_oidc_provider(db_session)
  155. for key in ALL_VARS:
  156. monkeypatch.delenv(key, raising=False)
  157. await apply_env_oidc_provider(db_session)
  158. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
  159. provider = result.scalar_one()
  160. assert provider.is_enabled is False
  161. assert provider.is_env_managed is False
  162. @pytest.mark.asyncio
  163. async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
  164. """The account links hang off this row; a second provider would orphan them."""
  165. _configure(monkeypatch)
  166. await apply_env_oidc_provider(db_session)
  167. original_id = (await _env_provider(db_session)).id
  168. for key in ALL_VARS:
  169. monkeypatch.delenv(key, raising=False)
  170. await apply_env_oidc_provider(db_session)
  171. _configure(monkeypatch)
  172. await apply_env_oidc_provider(db_session)
  173. provider = await _env_provider(db_session)
  174. assert provider.id == original_id
  175. assert provider.is_enabled is True
  176. @pytest.mark.asyncio
  177. async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
  178. _configure(monkeypatch)
  179. await apply_env_oidc_provider(db_session)
  180. original_id = (await _env_provider(db_session)).id
  181. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
  182. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
  183. await apply_env_oidc_provider(db_session)
  184. provider = await _env_provider(db_session)
  185. assert provider.id == original_id
  186. assert provider.issuer_url == "https://sso.example.com/realms/other"
  187. assert provider.client_id == "rotated"