test_local_login_gate.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  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_forgot_password_rejected_when_local_disabled(
  85. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  86. ):
  87. """Forgot-password is a local-credentials flow — useless when local
  88. login is off (the reset wouldn't grant access anyway)."""
  89. await _enable_auth(async_client, "gatefp")
  90. await _set_setting(db_session, "local_login_enabled", "false")
  91. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  92. response = await async_client.post(
  93. "/api/v1/auth/forgot-password",
  94. json={"email": "x@example.com"},
  95. )
  96. assert response.status_code == 403
  97. assert "Local login is disabled" in response.json()["detail"]
  98. class TestLdapLoginNotAffectedByGate:
  99. """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
  100. entirely. This is the regression suite for the refactor in #1589 — without
  101. these tests, an LDAP user could fail to log in when local login is
  102. disabled even though the gate is supposed to leave LDAP alone."""
  103. async def _enable_ldap(self, db: AsyncSession) -> None:
  104. for key, value in {
  105. "ldap_enabled": "true",
  106. "ldap_server_url": "ldaps://ldap.test",
  107. "ldap_bind_dn": "cn=svc,dc=test,dc=com",
  108. "ldap_bind_password": "x",
  109. "ldap_search_base": "dc=test,dc=com",
  110. "ldap_user_filter": "(uid={username})",
  111. "ldap_security": "ldaps",
  112. "ldap_group_mapping": "{}",
  113. "ldap_auto_provision": "true",
  114. "ldap_default_group": "",
  115. }.items():
  116. await _set_setting(db, key, value)
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_ldap_login_succeeds_when_local_disabled(
  120. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  121. ):
  122. """LDAP-authenticated login must still issue a JWT even when the
  123. local-login gate is off and no env-var bypass is set. The original
  124. cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
  125. await _enable_auth(async_client, "ldapseed")
  126. await self._enable_ldap(db_session)
  127. await _set_setting(db_session, "local_login_enabled", "false")
  128. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  129. fake_ldap = LDAPUserInfo(
  130. username="ldapuser",
  131. email="ldapuser@test.com",
  132. display_name="LDAP User",
  133. groups=[],
  134. )
  135. with patch(
  136. "backend.app.services.ldap_service.authenticate_ldap_user",
  137. return_value=fake_ldap,
  138. ):
  139. response = await async_client.post(
  140. "/api/v1/auth/login",
  141. json={"username": "ldapuser", "password": "anything"},
  142. )
  143. assert response.status_code == 200, response.text
  144. assert "access_token" in response.json()
  145. assert response.json()["user"]["username"] == "ldapuser"
  146. class TestAdvancedAuthStatusSurfacesGate:
  147. """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
  148. decisions in a single query — it must surface both new #1589 fields."""
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_status_includes_local_login_and_autologin(
  152. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  153. ):
  154. monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
  155. response = await async_client.get("/api/v1/auth/advanced-auth/status")
  156. assert response.status_code == 200
  157. result = response.json()
  158. assert "local_login_enabled" in result
  159. assert "autologin_provider_id" in result
  160. # Default install: local on, no autologin provider.
  161. assert result["local_login_enabled"] is True
  162. assert result["autologin_provider_id"] is None
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_env_var_bypass_flips_status_back_to_true(
  166. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
  167. ):
  168. """When the DB setting is false but the env-var bypass is set, the
  169. status reports local_login_enabled=true so the LoginPage shows the
  170. credentials form (matching what the route will actually accept)."""
  171. await _set_setting(db_session, "local_login_enabled", "false")
  172. monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
  173. response = await async_client.get("/api/v1/auth/advanced-auth/status")
  174. assert response.status_code == 200
  175. assert response.json()["local_login_enabled"] is True