8305352497
- 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
124 lines
4.5 KiB
Python
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
|