test_local_login_gate.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """Integration tests for the local login gate + autologin (#1589).
  2. Covers the four contracts described on the GitHub issue:
  3. 1. POST /auth/login rejects local credentials when local_login_enabled=false
  4. AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
  5. 2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
  6. 3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
  7. 4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
  8. can render the right UI in a single query.
  9. """
  10. from __future__ import annotations
  11. from unittest.mock import patch
  12. import pytest
  13. from httpx import AsyncClient
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.models.settings import Settings
  17. from backend.app.services.ldap_service import LDAPUserInfo
  18. async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
  19. result = await db.execute(select(Settings).where(Settings.key == key))
  20. row = result.scalar_one_or_none()
  21. if row is None:
  22. db.add(Settings(key=key, value=value))
  23. else:
  24. row.value = value
  25. await db.commit()
  26. async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
  27. """Set up an auth-enabled install with a known admin so /auth/login is reachable."""
  28. await async_client.post(
  29. "/api/v1/auth/setup",
  30. json={
  31. "auth_enabled": True,
  32. "admin_username": username,
  33. "admin_password": "GatePass1!",
  34. },
  35. )
  36. class TestLocalLoginGate:
  37. """The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
  38. when the env-var recovery bypass is not in play."""
  39. @pytest.mark.asyncio
  40. @pytest.mark.integration
  41. async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
  42. """Default install (setting absent) keeps the pre-#1589 behaviour."""
  43. await _enable_auth(async_client, "gatedefault")
  44. response = await async_client.post(
  45. "/api/v1/auth/login",
  46. json={"username": "gatedefault", "password": "GatePass1!"},
  47. )
  48. assert response.status_code == 200, response.text
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. async def test_login_rejected_when_local_disabled(
  52. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  53. ):
  54. """With local_login_enabled=false and no env bypass, valid creds are
  55. rejected with the same generic 401 as bad creds (no UI-stating leak)."""
  56. await _enable_auth(async_client, "gatedeny")
  57. await _set_setting(db_session, "local_login_enabled", "false")
  58. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  59. response = await async_client.post(
  60. "/api/v1/auth/login",
  61. json={"username": "gatedeny", "password": "GatePass1!"},
  62. )
  63. assert response.status_code == 401
  64. # Same wording as wrong-password 401 — never leaks whether local
  65. # login is disabled (would help credential stuffing prioritise).
  66. assert "Incorrect username or password" in response.json()["detail"]
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_env_var_bypasses_local_disabled_gate(
  70. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  71. ):
  72. """BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
  73. DB setting forbids local login (SSO-broken admin recovery)."""
  74. await _enable_auth(async_client, "gatebypass")
  75. await _set_setting(db_session, "local_login_enabled", "false")
  76. monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
  77. response = await async_client.post(
  78. "/api/v1/auth/login",
  79. json={"username": "gatebypass", "password": "GatePass1!"},
  80. )
  81. assert response.status_code == 200, response.text
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_unrecognized_env_value_does_not_500_the_login_path(
  85. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  86. ):
  87. """The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
  88. an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
  89. never raise -- env_bool is strict for the startup OIDC reader but lenient
  90. here. A raise would 500 the very endpoint the bypass exists to keep open."""
  91. await _enable_auth(async_client, "gateonval")
  92. await _set_setting(db_session, "local_login_enabled", "false")
  93. monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
  94. response = await async_client.post(
  95. "/api/v1/auth/login",
  96. json={"username": "gateonval", "password": "GatePass1!"},
  97. )
  98. # Bypass stays off (same 401 as no env var), and crucially not a 500.
  99. assert response.status_code == 401, response.text
  100. def test_the_bypass_var_is_registered_in_the_typo_guard(self):
  101. """config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
  102. Unregistered, this one tells an operator who is locked out and following
  103. the documented recovery that the variable they just set is not real --
  104. while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
  105. from backend.app.core.config import _INTENTIONAL_UNSETTINGS
  106. assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
  107. @pytest.mark.asyncio
  108. @pytest.mark.integration
  109. async def test_forgot_password_rejected_when_local_disabled(
  110. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  111. ):
  112. """Forgot-password is a local-credentials flow — useless when local
  113. login is off (the reset wouldn't grant access anyway)."""
  114. await _enable_auth(async_client, "gatefp")
  115. await _set_setting(db_session, "local_login_enabled", "false")
  116. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  117. response = await async_client.post(
  118. "/api/v1/auth/forgot-password",
  119. json={"email": "x@example.com"},
  120. )
  121. assert response.status_code == 403
  122. assert "Local login is disabled" in response.json()["detail"]
  123. class TestLdapLoginNotAffectedByGate:
  124. """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
  125. entirely. This is the regression suite for the refactor in #1589 — without
  126. these tests, an LDAP user could fail to log in when local login is
  127. disabled even though the gate is supposed to leave LDAP alone."""
  128. async def _enable_ldap(self, db: AsyncSession) -> None:
  129. for key, value in {
  130. "ldap_enabled": "true",
  131. "ldap_server_url": "ldaps://ldap.test",
  132. "ldap_bind_dn": "cn=svc,dc=test,dc=com",
  133. "ldap_bind_password": "x",
  134. "ldap_search_base": "dc=test,dc=com",
  135. "ldap_user_filter": "(uid={username})",
  136. "ldap_security": "ldaps",
  137. "ldap_group_mapping": "{}",
  138. "ldap_auto_provision": "true",
  139. "ldap_default_group": "",
  140. }.items():
  141. await _set_setting(db, key, value)
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_ldap_login_succeeds_when_local_disabled(
  145. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  146. ):
  147. """LDAP-authenticated login must still issue a JWT even when the
  148. local-login gate is off and no env-var bypass is set. The original
  149. cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
  150. await _enable_auth(async_client, "ldapseed")
  151. await self._enable_ldap(db_session)
  152. await _set_setting(db_session, "local_login_enabled", "false")
  153. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  154. fake_ldap = LDAPUserInfo(
  155. username="ldapuser",
  156. email="ldapuser@test.com",
  157. display_name="LDAP User",
  158. groups=[],
  159. )
  160. with patch(
  161. "backend.app.services.ldap_service.authenticate_ldap_user",
  162. return_value=fake_ldap,
  163. ):
  164. response = await async_client.post(
  165. "/api/v1/auth/login",
  166. json={"username": "ldapuser", "password": "anything"},
  167. )
  168. assert response.status_code == 200, response.text
  169. assert "access_token" in response.json()
  170. assert response.json()["user"]["username"] == "ldapuser"
  171. class TestAdvancedAuthStatusSurfacesGate:
  172. """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
  173. decisions in a single query — it must surface both new #1589 fields."""
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_status_includes_local_login_and_autologin(
  177. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  178. ):
  179. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  180. response = await async_client.get("/api/v1/auth/advanced-auth/status")
  181. assert response.status_code == 200
  182. result = response.json()
  183. assert "local_login_enabled" in result
  184. assert "autologin_provider_id" in result
  185. # Default install: local on, no autologin provider.
  186. assert result["local_login_enabled"] is True
  187. assert result["autologin_provider_id"] is None
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_env_var_bypass_flips_status_back_to_true(
  191. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  192. ):
  193. """When the DB setting is false but the env-var bypass is set, the
  194. status reports local_login_enabled=true so the LoginPage shows the
  195. credentials form (matching what the route will actually accept)."""
  196. await _set_setting(db_session, "local_login_enabled", "false")
  197. monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
  198. response = await async_client.get("/api/v1/auth/advanced-auth/status")
  199. assert response.status_code == 200
  200. assert response.json()["local_login_enabled"] is True