95291df971
Matching-Rule-In-Chain liefert gegen die insight.local-DCs grundsaetzlich keine Treffer (auch nicht bei ASCII-Gruppen und direkter Mitgliedschaft) - live im Container verifiziert. Direkte memberOf-Pruefung funktioniert, verschachtelte Gruppen werden bewusst nicht aufgeloest.
123 lines
4.4 KiB
Python
123 lines
4.4 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 memberOf
|
|
search_kwargs = conn.search.call_args.kwargs
|
|
assert "(memberOf=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
|