Files
insight-updater/backend/tests/test_ldap_auth.py
T
B0rbor4d 8305352497 LDAP/AD-Auth fuer Dashboard: User-Bind gegen insight.local, Zugriff nur fuer Domänen-Admins
- ldap_auth Service: User bindet als user@domain (kein Servicekonto noetig),
  Gruppenpruefung ueber Matching-Rule-In-Chain (verschachtelte Gruppen)
- Login-Route: lokaler User zuerst, dann LDAP-Fallback; LDAP-User werden
  lokal gespiegelt (is_ldap, is_admin, Profildaten aus AD)
- Neue Config: ldap_domain, ldap_admin_group, ldap_timeout_seconds
- ldap3 Dependency, 7 Unit-Tests (ldap3 gemockt), ruff+mypy sauber
- Gruppen-DN verified am DC: CN=Domänen-Admins,CN=Users,DC=insight,DC=local
2026-09-28 22:06:05 +00:00

124 lines
4.5 KiB
Python

"""Unit tests for LDAP user-bind authentication (ldap3 fully mocked)."""
from unittest.mock import MagicMock
import pytest
from app.core.config import Settings
from app.services.ldap_auth import LdapAuthError, authenticate
def _settings(**overrides: object) -> Settings:
base: dict[str, object] = {
"ldap_enabled": True,
"ldap_uri": "ldaps://ads-server-01.insight.local:636",
"ldap_domain": "insight.local",
"ldap_user_search_base": "DC=insight,DC=local",
"ldap_user_filter": "(sAMAccountName={username})",
"ldap_admin_group": "CN=Domain Admins,CN=Users,DC=insight,DC=local",
"ldap_timeout_seconds": 10,
}
base.update(overrides)
return Settings(**base) # type: ignore[arg-type]
def _entry(full_name: str = "Flo Hartmann", email: str = "flo@example.com") -> MagicMock:
entry = MagicMock()
entry.displayName = full_name
entry.mail = email
return entry
def _patch_ldap(monkeypatch: pytest.MonkeyPatch, conn: MagicMock) -> None:
mock_ldap3 = MagicMock()
mock_ldap3.Server.return_value = MagicMock()
mock_ldap3.Connection.return_value = conn
mock_ldap3.SUBTREE = "SUBTREE"
mock_ldap3.NONE = "NO_INFO"
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
def test_disabled_raises() -> None:
with pytest.raises(LdapAuthError, match="nicht aktiviert"):
authenticate("flo", "secret", _settings(ldap_enabled=False))
def test_missing_admin_group_raises() -> None:
with pytest.raises(LdapAuthError, match="nicht konfiguriert"):
authenticate("flo", "secret", _settings(ldap_admin_group=""))
def test_empty_password_raises() -> None:
with pytest.raises(LdapAuthError):
authenticate("flo", "", _settings())
def test_bind_failure_raises_generic(monkeypatch: pytest.MonkeyPatch) -> None:
from ldap3.core.exceptions import LDAPExceptionError
mock_ldap3 = MagicMock()
mock_ldap3.Server.return_value = MagicMock()
mock_ldap3.Connection.side_effect = LDAPExceptionError("invalidCredentials")
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
with pytest.raises(LdapAuthError, match="Benutzername oder Passwort falsch"):
authenticate("flo", "wrong", _settings())
def test_search_without_match_denied(monkeypatch: pytest.MonkeyPatch) -> None:
conn = MagicMock()
conn.__enter__.return_value = conn
conn.search.return_value = True
conn.entries = []
_patch_ldap(monkeypatch, conn)
with pytest.raises(LdapAuthError, match="Kein Zugriff"):
authenticate("flo", "secret", _settings())
def test_success_returns_admin_user(monkeypatch: pytest.MonkeyPatch) -> None:
conn = MagicMock()
conn.__enter__.return_value = conn
conn.search.return_value = True
conn.entries = [_entry()]
mock_ldap3 = MagicMock()
mock_ldap3.Server.return_value = MagicMock()
mock_ldap3.Connection.return_value = conn
mock_ldap3.SUBTREE = "SUBTREE"
mock_ldap3.NONE = "NO_INFO"
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
user = authenticate("flo", "secret", _settings())
assert user.username == "flo"
assert user.full_name == "Flo Hartmann"
assert user.email == "flo@example.com"
assert user.is_admin is True
# bind DN uses user@domain format
assert mock_ldap3.Connection.call_args.kwargs["user"] == "flo@insight.local"
# search filter proves admin-group membership via in-chain rule
search_kwargs = conn.search.call_args.kwargs
assert "memberOf:1.2.840.113556.1941:=" in search_kwargs["search_filter"]
assert "CN=Domain Admins,CN=Users,DC=insight,DC=local" in search_kwargs["search_filter"]
assert search_kwargs["search_base"] == "DC=insight,DC=local"
def test_optional_fields_none_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
conn = MagicMock()
conn.__enter__.return_value = conn
conn.search.return_value = True
conn.entries = [_entry(full_name="", email="")]
mock_ldap3 = MagicMock()
mock_ldap3.Server.return_value = MagicMock()
mock_ldap3.Connection.return_value = conn
mock_ldap3.SUBTREE = "SUBTREE"
mock_ldap3.NONE = "NO_INFO"
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
user = authenticate("flo", "secret", _settings())
assert user.full_name is None
assert user.email is None