test_oidc_env_reader.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. """BAMBUDDY_OIDC_* reader (#2593).
  2. The reader is deliberately dumb: it maps env vars to field names and applies
  3. defaults. Whether the resulting provider is *valid* is decided later, by the
  4. same OIDCProviderCreate schema the API uses, so env config cannot bypass a
  5. check the UI enforces.
  6. """
  7. from __future__ import annotations
  8. import pytest
  9. from backend.app.core.oidc_env import EnvOIDCConfigError, env_bool, read_env_oidc_config
  10. REQUIRED = {
  11. "BAMBUDDY_OIDC_NAME": "Keycloak",
  12. "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
  13. "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
  14. "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
  15. }
  16. OPTIONAL = (
  17. "BAMBUDDY_OIDC_SCOPES",
  18. "BAMBUDDY_OIDC_ENABLED",
  19. "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
  20. "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
  21. "BAMBUDDY_OIDC_EMAIL_CLAIM",
  22. "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
  23. "BAMBUDDY_OIDC_ICON_URL",
  24. "BAMBUDDY_OIDC_AUTOLOGIN",
  25. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  26. )
  27. @pytest.fixture(autouse=True)
  28. def clean_env(monkeypatch):
  29. for key in (*REQUIRED, *OPTIONAL):
  30. monkeypatch.delenv(key, raising=False)
  31. def _set_required(monkeypatch):
  32. for key, value in REQUIRED.items():
  33. monkeypatch.setenv(key, value)
  34. def test_returns_none_when_nothing_is_configured():
  35. assert read_env_oidc_config() is None
  36. @pytest.mark.parametrize("missing", sorted(REQUIRED))
  37. def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing):
  38. """All four or nothing -- a half-configured provider must not reach the
  39. database, where it would fail at authorize time instead of at startup."""
  40. _set_required(monkeypatch)
  41. monkeypatch.delenv(missing)
  42. assert read_env_oidc_config() is None
  43. @pytest.mark.parametrize("raw", ["", " ", "\n", " \t\n "])
  44. @pytest.mark.parametrize("key", sorted(REQUIRED))
  45. def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
  46. """`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
  47. not an intentional empty secret -- and neither is one holding only
  48. whitespace, which the optional vars have always treated as unset."""
  49. _set_required(monkeypatch)
  50. monkeypatch.setenv(key, raw)
  51. assert read_env_oidc_config() is None
  52. @pytest.mark.parametrize("key", sorted(REQUIRED))
  53. def test_a_required_var_is_stripped(monkeypatch, key):
  54. """A Kubernetes Secret written as a block scalar carries a trailing
  55. newline, and the schema bounds these four by max_length only -- so an
  56. unstripped issuer_url reaches the database, enables the SSO button and
  57. then raises httpx.InvalidURL on the first click, long after startup could
  58. have refused it."""
  59. _set_required(monkeypatch)
  60. monkeypatch.setenv(key, f" {REQUIRED[key]}\n")
  61. cfg = read_env_oidc_config()
  62. field = {
  63. "BAMBUDDY_OIDC_NAME": "name",
  64. "BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
  65. "BAMBUDDY_OIDC_CLIENT_ID": "client_id",
  66. "BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
  67. }[key]
  68. assert cfg[field] == REQUIRED[key]
  69. def test_reads_the_required_vars(monkeypatch):
  70. _set_required(monkeypatch)
  71. cfg = read_env_oidc_config()
  72. assert cfg["name"] == "Keycloak"
  73. assert cfg["issuer_url"] == "https://sso.example.com/realms/main"
  74. assert cfg["client_id"] == "bambuddy"
  75. assert cfg["client_secret"] == "s3cr3t"
  76. def test_applies_the_documented_defaults(monkeypatch):
  77. _set_required(monkeypatch)
  78. cfg = read_env_oidc_config()
  79. assert cfg["scopes"] == "openid email profile"
  80. assert cfg["is_enabled"] is True
  81. assert cfg["auto_create_users"] is False
  82. assert cfg["auto_link_existing_accounts"] is False
  83. assert cfg["email_claim"] == "email"
  84. assert cfg["require_email_verified"] is True
  85. assert cfg["icon_url"] is None
  86. assert cfg["is_autologin"] is False
  87. @pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "])
  88. def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw):
  89. _set_required(monkeypatch)
  90. monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
  91. assert read_env_oidc_config()["auto_create_users"] is True
  92. @pytest.mark.parametrize("raw", ["false", "FALSE", "False", "0", "no", "NO"])
  93. def test_falsy_values_are_false(monkeypatch, raw):
  94. _set_required(monkeypatch)
  95. monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
  96. assert read_env_oidc_config()["auto_create_users"] is False
  97. @pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
  98. def test_an_unrecognized_boolean_is_rejected(monkeypatch, raw):
  99. """Only the documented spellings are accepted; an unrecognised value must
  100. not silently turn a flag on or off -- it must refuse the whole config
  101. instead of guessing (M-R4 strict boolean parsing)."""
  102. _set_required(monkeypatch)
  103. monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
  104. with pytest.raises(EnvOIDCConfigError, match="BAMBUDDY_OIDC_AUTO_CREATE_USERS"):
  105. read_env_oidc_config()
  106. def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch):
  107. _set_required(monkeypatch)
  108. monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false")
  109. assert read_env_oidc_config()["require_email_verified"] is False
  110. # --- env_bool, tested directly ------------------------------------------------
  111. # The reader-level tests above pin the contract through read_env_oidc_config;
  112. # these exercise the helper itself so its default/blank/reject behavior is
  113. # proven independently of any particular BAMBUDDY_OIDC_* field.
  114. @pytest.mark.parametrize("raw", ["false", "FALSE", "0", "no", "NO"])
  115. def test_env_bool_falsy_values_are_false(monkeypatch, raw):
  116. monkeypatch.setenv("SOME_FLAG", raw)
  117. assert env_bool("SOME_FLAG", True) is False
  118. @pytest.mark.parametrize("default", [True, False])
  119. def test_env_bool_absent_is_the_given_default(monkeypatch, default):
  120. monkeypatch.delenv("SOME_FLAG", raising=False)
  121. assert env_bool("SOME_FLAG", default) is default
  122. @pytest.mark.parametrize("raw", ["", " "])
  123. @pytest.mark.parametrize("default", [True, False])
  124. def test_env_bool_blank_is_the_given_default(monkeypatch, raw, default):
  125. monkeypatch.setenv("SOME_FLAG", raw)
  126. assert env_bool("SOME_FLAG", default) is default
  127. @pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
  128. def test_env_bool_rejects_an_unrecognized_value(monkeypatch, raw):
  129. monkeypatch.setenv("SOME_FLAG", raw)
  130. with pytest.raises(EnvOIDCConfigError, match="SOME_FLAG"):
  131. env_bool("SOME_FLAG", True)
  132. @pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
  133. @pytest.mark.parametrize("default", [True, False])
  134. def test_env_bool_lenient_falls_back_to_default_on_unrecognized(monkeypatch, raw, default):
  135. """strict=False (the request-path callers like BAMBUDDY_LOCAL_LOGIN): an
  136. unrecognized value must return the default, never raise -- a raise there
  137. would 500 a live endpoint rather than skip a startup config."""
  138. monkeypatch.setenv("SOME_FLAG", raw)
  139. assert env_bool("SOME_FLAG", default, strict=False) is default
  140. def test_optional_strings_override_their_defaults(monkeypatch):
  141. _set_required(monkeypatch)
  142. monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups")
  143. monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail")
  144. monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png")
  145. cfg = read_env_oidc_config()
  146. assert cfg["scopes"] == "openid profile groups"
  147. assert cfg["email_claim"] == "mail"
  148. assert cfg["icon_url"] == "https://sso.example.com/logo.png"
  149. @pytest.mark.parametrize("raw", ["", " "])
  150. def test_a_blank_scopes_is_unset(monkeypatch, raw):
  151. """`BAMBUDDY_OIDC_SCOPES=` in a compose file is a forgotten value, not a
  152. request for a provider with no scopes -- same rule as default_group."""
  153. _set_required(monkeypatch)
  154. monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", raw)
  155. assert read_env_oidc_config()["scopes"] == "openid email profile"
  156. @pytest.mark.parametrize("raw", ["", " "])
  157. def test_a_blank_email_claim_is_unset(monkeypatch, raw):
  158. _set_required(monkeypatch)
  159. monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", raw)
  160. assert read_env_oidc_config()["email_claim"] == "email"
  161. @pytest.mark.parametrize("raw", ["", " "])
  162. def test_a_blank_icon_url_is_unset(monkeypatch, raw):
  163. """Uncommenting `# BAMBUDDY_OIDC_ICON_URL=` in .env.example must not take
  164. the provider down -- the reader must still return a config, not refuse it."""
  165. _set_required(monkeypatch)
  166. monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", raw)
  167. cfg = read_env_oidc_config()
  168. assert cfg is not None, "a blank optional var must not refuse the whole provider"
  169. assert cfg["icon_url"] is None
  170. def test_the_default_group_is_read_as_a_name(monkeypatch):
  171. """A name, not an id: group ids differ per install, so an id in a compose
  172. file would point at whatever group happened to be created third."""
  173. _set_required(monkeypatch)
  174. monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Operators")
  175. cfg = read_env_oidc_config()
  176. assert cfg["default_group"] == "Operators"
  177. assert "default_group_id" not in cfg, "resolution needs the database, not the reader"
  178. @pytest.mark.parametrize("raw", ["", " "])
  179. def test_a_blank_default_group_is_unset(monkeypatch, raw):
  180. _set_required(monkeypatch)
  181. monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", raw)
  182. assert read_env_oidc_config()["default_group"] is None
  183. def test_every_var_the_reader_knows_is_registered_in_the_typo_guard():
  184. """An unregistered BAMBUDDY_* var logs "possible typo" at every boot, which
  185. would tell operators their correct config is wrong. Asserted against the
  186. reader's own vars rather than a copied list, so a var added later is caught
  187. here instead of in someone's logs."""
  188. from backend.app.core.config import _INTENTIONAL_UNSETTINGS
  189. unregistered = {v for v in (*REQUIRED, *OPTIONAL) if v not in _INTENTIONAL_UNSETTINGS}
  190. assert not unregistered