| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- """Integration tests for the local login gate + autologin (#1589).
- Covers the four contracts described on the GitHub issue:
- 1. POST /auth/login rejects local credentials when local_login_enabled=false
- AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
- 2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
- 3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
- 4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
- can render the right UI in a single query.
- """
- from __future__ import annotations
- from unittest.mock import patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.settings import Settings
- from backend.app.services.ldap_service import LDAPUserInfo
- async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
- result = await db.execute(select(Settings).where(Settings.key == key))
- row = result.scalar_one_or_none()
- if row is None:
- db.add(Settings(key=key, value=value))
- else:
- row.value = value
- await db.commit()
- async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
- """Set up an auth-enabled install with a known admin so /auth/login is reachable."""
- await async_client.post(
- "/api/v1/auth/setup",
- json={
- "auth_enabled": True,
- "admin_username": username,
- "admin_password": "GatePass1!",
- },
- )
- class TestLocalLoginGate:
- """The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
- when the env-var recovery bypass is not in play."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
- """Default install (setting absent) keeps the pre-#1589 behaviour."""
- await _enable_auth(async_client, "gatedefault")
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "gatedefault", "password": "GatePass1!"},
- )
- assert response.status_code == 200, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_login_rejected_when_local_disabled(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """With local_login_enabled=false and no env bypass, valid creds are
- rejected with the same generic 401 as bad creds (no UI-stating leak)."""
- await _enable_auth(async_client, "gatedeny")
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "gatedeny", "password": "GatePass1!"},
- )
- assert response.status_code == 401
- # Same wording as wrong-password 401 — never leaks whether local
- # login is disabled (would help credential stuffing prioritise).
- assert "Incorrect username or password" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_env_var_bypasses_local_disabled_gate(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
- DB setting forbids local login (SSO-broken admin recovery)."""
- await _enable_auth(async_client, "gatebypass")
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "gatebypass", "password": "GatePass1!"},
- )
- assert response.status_code == 200, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unrecognized_env_value_does_not_500_the_login_path(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
- an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
- never raise -- env_bool is strict for the startup OIDC reader but lenient
- here. A raise would 500 the very endpoint the bypass exists to keep open."""
- await _enable_auth(async_client, "gateonval")
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "gateonval", "password": "GatePass1!"},
- )
- # Bypass stays off (same 401 as no env var), and crucially not a 500.
- assert response.status_code == 401, response.text
- def test_the_bypass_var_is_registered_in_the_typo_guard(self):
- """config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
- Unregistered, this one tells an operator who is locked out and following
- the documented recovery that the variable they just set is not real --
- while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
- from backend.app.core.config import _INTENTIONAL_UNSETTINGS
- assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_forgot_password_rejected_when_local_disabled(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """Forgot-password is a local-credentials flow — useless when local
- login is off (the reset wouldn't grant access anyway)."""
- await _enable_auth(async_client, "gatefp")
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
- response = await async_client.post(
- "/api/v1/auth/forgot-password",
- json={"email": "x@example.com"},
- )
- assert response.status_code == 403
- assert "Local login is disabled" in response.json()["detail"]
- class TestLdapLoginNotAffectedByGate:
- """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
- entirely. This is the regression suite for the refactor in #1589 — without
- these tests, an LDAP user could fail to log in when local login is
- disabled even though the gate is supposed to leave LDAP alone."""
- async def _enable_ldap(self, db: AsyncSession) -> None:
- for key, value in {
- "ldap_enabled": "true",
- "ldap_server_url": "ldaps://ldap.test",
- "ldap_bind_dn": "cn=svc,dc=test,dc=com",
- "ldap_bind_password": "x",
- "ldap_search_base": "dc=test,dc=com",
- "ldap_user_filter": "(uid={username})",
- "ldap_security": "ldaps",
- "ldap_group_mapping": "{}",
- "ldap_auto_provision": "true",
- "ldap_default_group": "",
- }.items():
- await _set_setting(db, key, value)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ldap_login_succeeds_when_local_disabled(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """LDAP-authenticated login must still issue a JWT even when the
- local-login gate is off and no env-var bypass is set. The original
- cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
- await _enable_auth(async_client, "ldapseed")
- await self._enable_ldap(db_session)
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
- fake_ldap = LDAPUserInfo(
- username="ldapuser",
- email="ldapuser@test.com",
- display_name="LDAP User",
- groups=[],
- )
- with patch(
- "backend.app.services.ldap_service.authenticate_ldap_user",
- return_value=fake_ldap,
- ):
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "ldapuser", "password": "anything"},
- )
- assert response.status_code == 200, response.text
- assert "access_token" in response.json()
- assert response.json()["user"]["username"] == "ldapuser"
- class TestAdvancedAuthStatusSurfacesGate:
- """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
- decisions in a single query — it must surface both new #1589 fields."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_status_includes_local_login_and_autologin(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
- response = await async_client.get("/api/v1/auth/advanced-auth/status")
- assert response.status_code == 200
- result = response.json()
- assert "local_login_enabled" in result
- assert "autologin_provider_id" in result
- # Default install: local on, no autologin provider.
- assert result["local_login_enabled"] is True
- assert result["autologin_provider_id"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_env_var_bypass_flips_status_back_to_true(
- self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
- ):
- """When the DB setting is false but the env-var bypass is set, the
- status reports local_login_enabled=true so the LoginPage shows the
- credentials form (matching what the route will actually accept)."""
- await _set_setting(db_session, "local_login_enabled", "false")
- monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
- response = await async_client.get("/api/v1/auth/advanced-auth/status")
- assert response.status_code == 200
- assert response.json()["local_login_enabled"] is True
|