test_ldap_service.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054
  1. """Tests for LDAP authentication service (#794).
  2. Tests the pure logic functions in ldap_service.py:
  3. - Config parsing from settings dict
  4. - LDAP filter escaping (RFC 4515)
  5. - Group mapping resolution
  6. - LDAPConfig/LDAPUserInfo dataclass construction
  7. Network-dependent functions (authenticate_ldap_user, test_ldap_connection)
  8. are not tested here — they require a live LDAP server.
  9. """
  10. from types import SimpleNamespace
  11. import pytest
  12. from ldap3.core.exceptions import (
  13. LDAPObjectClassError,
  14. LDAPSocketOpenError,
  15. LDAPStartTLSError,
  16. LDAPUnwillingToPerformResult,
  17. )
  18. from ldap3.utils.ciDict import CaseInsensitiveDict
  19. from backend.app.services.ldap_service import (
  20. LDAPConfig,
  21. LDAPSearchResult,
  22. LDAPUserInfo,
  23. _ldap_escape,
  24. authenticate_ldap_user,
  25. lookup_ldap_user,
  26. parse_ldap_config,
  27. resolve_group_mapping,
  28. search_ldap_users,
  29. test_ldap_connection as check_ldap_connection,
  30. )
  31. class TestParseConfig:
  32. """Verify parse_ldap_config builds LDAPConfig from settings dict."""
  33. def test_returns_none_when_disabled(self):
  34. settings = {"ldap_enabled": "false", "ldap_server_url": "ldaps://example.com"}
  35. assert parse_ldap_config(settings) is None
  36. def test_returns_none_when_missing_enabled(self):
  37. settings = {"ldap_server_url": "ldaps://example.com"}
  38. assert parse_ldap_config(settings) is None
  39. def test_returns_none_when_no_server_url(self):
  40. settings = {"ldap_enabled": "true", "ldap_server_url": ""}
  41. assert parse_ldap_config(settings) is None
  42. def test_returns_none_when_server_url_whitespace(self):
  43. settings = {"ldap_enabled": "true", "ldap_server_url": " "}
  44. assert parse_ldap_config(settings) is None
  45. def test_parses_minimal_config(self):
  46. settings = {
  47. "ldap_enabled": "true",
  48. "ldap_server_url": "ldaps://ldap.example.com:636",
  49. }
  50. config = parse_ldap_config(settings)
  51. assert config is not None
  52. assert config.server_url == "ldaps://ldap.example.com:636"
  53. assert config.bind_dn == ""
  54. assert config.search_base == ""
  55. assert config.user_filter == "(sAMAccountName={username})"
  56. assert config.security == "starttls"
  57. assert config.group_mapping == {}
  58. assert config.auto_provision is False
  59. assert config.ca_cert_path == ""
  60. assert config.default_group == ""
  61. def test_parses_full_config(self):
  62. settings = {
  63. "ldap_enabled": "true",
  64. "ldap_server_url": "ldaps://ldap.example.com:636",
  65. "ldap_bind_dn": "cn=admin,dc=example,dc=com",
  66. "ldap_bind_password": "secret",
  67. "ldap_search_base": "ou=users,dc=example,dc=com",
  68. "ldap_user_filter": "(uid={username})",
  69. "ldap_security": "ldaps",
  70. "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
  71. "ldap_auto_provision": "true",
  72. "ldap_ca_cert_path": "/path/to/ca.pem",
  73. "ldap_default_group": "Viewers",
  74. }
  75. config = parse_ldap_config(settings)
  76. assert config is not None
  77. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  78. assert config.bind_password == "secret"
  79. assert config.search_base == "ou=users,dc=example,dc=com"
  80. assert config.user_filter == "(uid={username})"
  81. assert config.security == "ldaps"
  82. assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
  83. assert config.auto_provision is True
  84. assert config.ca_cert_path == "/path/to/ca.pem"
  85. assert config.default_group == "Viewers"
  86. def test_handles_invalid_group_mapping_json(self):
  87. settings = {
  88. "ldap_enabled": "true",
  89. "ldap_server_url": "ldaps://ldap.example.com",
  90. "ldap_group_mapping": "not valid json",
  91. }
  92. config = parse_ldap_config(settings)
  93. assert config is not None
  94. assert config.group_mapping == {}
  95. def test_handles_non_dict_group_mapping(self):
  96. settings = {
  97. "ldap_enabled": "true",
  98. "ldap_server_url": "ldaps://ldap.example.com",
  99. "ldap_group_mapping": '["not", "a", "dict"]',
  100. }
  101. config = parse_ldap_config(settings)
  102. assert config is not None
  103. assert config.group_mapping == {}
  104. def test_enabled_case_insensitive(self):
  105. settings = {"ldap_enabled": "True", "ldap_server_url": "ldaps://ldap.example.com"}
  106. assert parse_ldap_config(settings) is not None
  107. settings = {"ldap_enabled": "TRUE", "ldap_server_url": "ldaps://ldap.example.com"}
  108. assert parse_ldap_config(settings) is not None
  109. def test_strips_whitespace(self):
  110. settings = {
  111. "ldap_enabled": "true",
  112. "ldap_server_url": " ldaps://ldap.example.com ",
  113. "ldap_bind_dn": " cn=admin,dc=example,dc=com ",
  114. "ldap_search_base": " dc=example,dc=com ",
  115. "ldap_default_group": " Viewers ",
  116. }
  117. config = parse_ldap_config(settings)
  118. assert config.server_url == "ldaps://ldap.example.com"
  119. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  120. assert config.search_base == "dc=example,dc=com"
  121. assert config.default_group == "Viewers"
  122. class TestLDAPEscape:
  123. """Verify RFC 4515 escaping for LDAP search filter values."""
  124. def test_plain_string(self):
  125. assert _ldap_escape("testuser") == "testuser"
  126. def test_escapes_backslash(self):
  127. assert _ldap_escape("test\\user") == "test\\5cuser"
  128. def test_escapes_asterisk(self):
  129. assert _ldap_escape("test*user") == "test\\2auser"
  130. def test_escapes_open_paren(self):
  131. assert _ldap_escape("test(user") == "test\\28user"
  132. def test_escapes_close_paren(self):
  133. assert _ldap_escape("test)user") == "test\\29user"
  134. def test_escapes_null(self):
  135. assert _ldap_escape("test\x00user") == "test\\00user"
  136. def test_escapes_multiple_chars(self):
  137. assert _ldap_escape("a*b(c)d\\e") == "a\\2ab\\28c\\29d\\5ce"
  138. def test_empty_string(self):
  139. assert _ldap_escape("") == ""
  140. class TestResolveGroupMapping:
  141. """Verify LDAP group DN to BamBuddy group name resolution."""
  142. def test_empty_mapping(self):
  143. assert resolve_group_mapping(["cn=admins,dc=example"], {}) == []
  144. def test_empty_groups(self):
  145. mapping = {"cn=admins,dc=example": "Administrators"}
  146. assert resolve_group_mapping([], mapping) == []
  147. def test_single_match(self):
  148. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  149. groups = ["cn=admins,dc=example,dc=com"]
  150. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  151. def test_multiple_matches(self):
  152. mapping = {
  153. "cn=admins,dc=example,dc=com": "Administrators",
  154. "cn=ops,dc=example,dc=com": "Operators",
  155. }
  156. groups = ["cn=admins,dc=example,dc=com", "cn=ops,dc=example,dc=com"]
  157. result = resolve_group_mapping(groups, mapping)
  158. assert set(result) == {"Administrators", "Operators"}
  159. def test_no_match(self):
  160. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  161. groups = ["cn=users,dc=example,dc=com"]
  162. assert resolve_group_mapping(groups, mapping) == []
  163. def test_case_insensitive_dn(self):
  164. mapping = {"CN=Admins,DC=Example,DC=Com": "Administrators"}
  165. groups = ["cn=admins,dc=example,dc=com"]
  166. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  167. def test_partial_match_not_matched(self):
  168. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  169. groups = ["cn=admins,dc=other,dc=com"]
  170. assert resolve_group_mapping(groups, mapping) == []
  171. def test_extra_groups_ignored(self):
  172. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  173. groups = ["cn=admins,dc=example,dc=com", "cn=users,dc=example,dc=com", "cn=devs,dc=example,dc=com"]
  174. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  175. class TestDataclasses:
  176. """Verify dataclass construction."""
  177. def test_ldap_user_info(self):
  178. info = LDAPUserInfo(
  179. username="testuser",
  180. email="test@example.com",
  181. display_name="Test User",
  182. groups=["cn=admins,dc=example,dc=com"],
  183. )
  184. assert info.username == "testuser"
  185. assert info.email == "test@example.com"
  186. assert info.display_name == "Test User"
  187. assert info.groups == ["cn=admins,dc=example,dc=com"]
  188. def test_ldap_user_info_none_fields(self):
  189. info = LDAPUserInfo(username="testuser", email=None, display_name=None, groups=[])
  190. assert info.email is None
  191. assert info.display_name is None
  192. assert info.groups == []
  193. def test_ldap_config(self):
  194. config = LDAPConfig(
  195. server_url="ldaps://ldap.example.com:636",
  196. bind_dn="cn=admin,dc=example,dc=com",
  197. bind_password="secret",
  198. search_base="dc=example,dc=com",
  199. user_filter="(uid={username})",
  200. security="ldaps",
  201. group_mapping={"cn=admins": "Administrators"},
  202. auto_provision=True,
  203. ca_cert_path="",
  204. default_group="Viewers",
  205. )
  206. assert config.server_url == "ldaps://ldap.example.com:636"
  207. assert config.auto_provision is True
  208. assert config.default_group == "Viewers"
  209. # ---------------------------------------------------------------------------
  210. # Mocked authenticate_ldap_user group-discovery tests
  211. # ---------------------------------------------------------------------------
  212. # These tests mock ldap3.Connection to exercise the group-discovery logic in
  213. # authenticate_ldap_user without a live LDAP server. Added after a bug where
  214. # POSIX primary-group membership (via gidNumber) was ignored — see CHANGELOG.
  215. class _MockAttr:
  216. """Minimal stand-in for ldap3 Attribute objects.
  217. Supports str(), bool(), .value, .values, and iteration — the operations
  218. used by ldap_service against user entry attributes.
  219. """
  220. def __init__(self, value):
  221. self._value = value
  222. @property
  223. def value(self):
  224. return self._value
  225. @property
  226. def values(self):
  227. return self._value if isinstance(self._value, list) else [self._value]
  228. def __str__(self):
  229. return str(self._value)
  230. def __bool__(self):
  231. return bool(self._value)
  232. def __iter__(self):
  233. if isinstance(self._value, list):
  234. return iter(self._value)
  235. return iter([self._value])
  236. class _MockEntry:
  237. """Minimal stand-in for ldap3 Entry. Only attributes passed at construction exist."""
  238. def __init__(self, dn, **attrs):
  239. self.entry_dn = dn
  240. for key, val in attrs.items():
  241. setattr(self, key, _MockAttr(val))
  242. class _MockServer:
  243. """Stand-in for ldap3 Server: only the schema and root DSE info the service reads.
  244. `schema` None is a server that published no schema, where ldap3 checks no
  245. names client-side. Otherwise it carries the attribute types and object
  246. classes the server defines.
  247. """
  248. def __init__(self, attribute_types=None, object_classes=None, naming_contexts=None, active_directory=False):
  249. if attribute_types is None and object_classes is None:
  250. self.schema = None
  251. else:
  252. self.schema = SimpleNamespace(
  253. attribute_types=CaseInsensitiveDict(dict.fromkeys(attribute_types or ())),
  254. object_classes=CaseInsensitiveDict(dict.fromkeys(object_classes or ())),
  255. )
  256. features = [("1.2.840.113556.1.4.800", "FEATURE", "Active directory", "MICROSOFT")] if active_directory else []
  257. if naming_contexts is None and not active_directory:
  258. self.info = None
  259. else:
  260. self.info = SimpleNamespace(naming_contexts=naming_contexts, supported_features=features)
  261. class _MockConnection:
  262. """Mock ldap3 Connection that returns pre-configured entries based on filter substring match.
  263. Every Connection() instance shares a class-level fixture dict so the service-account
  264. connection and the user-bind connection both see the same fake directory.
  265. """
  266. _search_fixture: dict[str, list] = {}
  267. _instances: list["_MockConnection"] = []
  268. # Filter substring that should raise LDAPObjectClassError instead of
  269. # searching, standing in for ldap3's client-side schema validation — it
  270. # rejects an object class the server's published schema doesn't define
  271. # before the request is ever built (#2769).
  272. _raise_object_class_error_on: str | None = None
  273. def __init__(self, server=None, *args, **kwargs):
  274. self.server = server
  275. self.entries: list = []
  276. self.search_calls: list[str] = []
  277. self.search_bases: list[str | None] = []
  278. self.search_attrs: list[list | None] = []
  279. self.last_attrs: list | None = None
  280. _MockConnection._instances.append(self)
  281. def open(self):
  282. pass
  283. def start_tls(self, read_server_info=True):
  284. self.start_tls_read_server_info = read_server_info
  285. def bind(self):
  286. return True
  287. def unbind(self):
  288. pass
  289. def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None, **kwargs):
  290. # **kwargs absorbs ldap3 options like size_limit that the real client supports
  291. self.search_calls.append(search_filter or "")
  292. self.search_bases.append(search_base)
  293. self.last_attrs = list(attributes) if attributes is not None else None
  294. self.search_attrs.append(self.last_attrs)
  295. needle = _MockConnection._raise_object_class_error_on
  296. if needle and needle in (search_filter or ""):
  297. raise LDAPObjectClassError(f"invalid class in objectClass attribute: {needle}")
  298. for needle, entries in _MockConnection._search_fixture.items():
  299. if needle in (search_filter or ""):
  300. self.entries = entries
  301. return True
  302. self.entries = []
  303. return True
  304. @pytest.fixture
  305. def mock_ldap(monkeypatch):
  306. """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
  307. _MockConnection._search_fixture = {}
  308. _MockConnection._instances = []
  309. _MockConnection._raise_object_class_error_on = None
  310. _MockConnection.server_fixture = _MockServer()
  311. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
  312. monkeypatch.setattr(
  313. "backend.app.services.ldap_service._create_server", lambda config: _MockConnection.server_fixture
  314. )
  315. return _MockConnection
  316. def _base_config(**overrides):
  317. """Build a minimal LDAPConfig for mocked tests."""
  318. defaults = {
  319. "server_url": "ldaps://test.example.com:636",
  320. "bind_dn": "cn=admin,dc=test,dc=com",
  321. "bind_password": "x",
  322. "search_base": "dc=test,dc=com",
  323. "user_filter": "(uid={username})",
  324. "security": "ldaps",
  325. "group_mapping": {},
  326. "auto_provision": False,
  327. "ca_cert_path": "",
  328. "default_group": "",
  329. }
  330. defaults.update(overrides)
  331. return LDAPConfig(**defaults)
  332. class TestAuthenticateLdapUserGroups:
  333. """Group-discovery behaviour in authenticate_ldap_user.
  334. Covers the POSIX primary gidNumber lookup and case-insensitive dedupe added
  335. to fix a bug where users whose role came from their primary group were
  336. authenticated without the correct group membership.
  337. """
  338. def test_primary_gidnumber_group_found(self, mock_ldap):
  339. """Regression: POSIX primary group (gidNumber match) must be included in the result."""
  340. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  341. operators_group = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  342. mock_ldap._search_fixture = {
  343. "(uid=mz)": [user_entry],
  344. "memberUid=mz": [], # no supplementary memberships
  345. "gidNumber=10002": [operators_group],
  346. }
  347. info = authenticate_ldap_user(_base_config(), "mz", "password")
  348. assert info is not None
  349. assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
  350. def test_dedupes_group_found_via_both_memberuid_and_primary_gid(self, mock_ldap):
  351. """A user in the same group via BOTH memberUid and primary gidNumber should appear once."""
  352. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  353. group_entry = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  354. mock_ldap._search_fixture = {
  355. "(uid=mz)": [user_entry],
  356. "memberUid=mz": [group_entry], # supplementary membership
  357. "gidNumber=10002": [group_entry], # primary group — same DN
  358. }
  359. info = authenticate_ldap_user(_base_config(), "mz", "password")
  360. assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
  361. def test_case_insensitive_dedupe(self, mock_ldap):
  362. """DNs differing only in case should collapse to a single entry (LDAP DNs are case-insensitive)."""
  363. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  364. upper_dn = _MockEntry("CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com")
  365. lower_dn = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  366. mock_ldap._search_fixture = {
  367. "(uid=mz)": [user_entry],
  368. "memberUid=mz": [upper_dn],
  369. "gidNumber=10002": [lower_dn],
  370. }
  371. info = authenticate_ldap_user(_base_config(), "mz", "password")
  372. assert len(info.groups) == 1
  373. # The first-seen casing (memberUid result) is kept.
  374. assert info.groups[0] == "CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com"
  375. def test_no_gidnumber_skips_primary_search(self, mock_ldap):
  376. """User entries without a gidNumber attribute should not crash and should not issue the primary-gid query."""
  377. user_entry = _MockEntry("cn=tester,dc=test,dc=com", uid="tester") # no gidNumber
  378. viewers_group = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
  379. mock_ldap._search_fixture = {
  380. "(uid=tester)": [user_entry],
  381. "memberUid=tester": [viewers_group],
  382. }
  383. info = authenticate_ldap_user(_base_config(), "tester", "password")
  384. assert info is not None
  385. assert info.groups == ["cn=bambuddy-viewers,ou=groups,dc=test,dc=com"]
  386. # Ensure the primary-gidNumber search was never issued — verifying the guard works.
  387. service_conn = _MockConnection._instances[0]
  388. gidnumber_searches = [call for call in service_conn.search_calls if "gidNumber=" in call]
  389. assert gidnumber_searches == []
  390. class TestDirectoryWithoutPosixGroupClass:
  391. """A directory whose published schema defines no posixGroup class (#2769).
  392. ldap3 fetches the schema at connect time (get_info=ALL) and validates object
  393. class names in a filter against it before building the request, so both POSIX
  394. group searches raise client-side and nothing reaches the server. lldap is the
  395. case in the wild: it puts posixAccount on every account it creates, which
  396. gives each user a gidNumber, but defines no group class beyond groupOfNames.
  397. Left uncaught the exception escaped authenticate_ldap_user and the login route
  398. reported it as "Incorrect username or password", so LDAP login was impossible.
  399. """
  400. def test_authenticates_and_keeps_memberof_groups(self, mock_ldap):
  401. """The reporter's setup: the mapped membership comes from memberOf, which
  402. is read off the user entry and never touches a posixGroup filter."""
  403. user_entry = _MockEntry(
  404. "uid=peter,ou=people,dc=fablab,dc=test",
  405. uid="peter",
  406. gidNumber=1001, # lldap gives every account one
  407. memberOf=["cn=AAUStudents,ou=groups,dc=fablab,dc=test"],
  408. )
  409. mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
  410. mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
  411. info = authenticate_ldap_user(_base_config(), "peter", "password")
  412. assert info is not None
  413. assert info.groups == ["cn=AAUStudents,ou=groups,dc=fablab,dc=test"]
  414. def test_authenticates_with_no_groups_at_all(self, mock_ldap):
  415. """No memberOf either. The user still gets in — auto-provisioning assigns
  416. the configured default group, which is the whole point of that setting."""
  417. user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
  418. mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
  419. mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
  420. info = authenticate_ldap_user(_base_config(), "peter", "password")
  421. assert info is not None
  422. assert info.username == "peter"
  423. assert info.groups == []
  424. def test_abandons_the_primary_gid_search_after_the_first_rejection(self, mock_ldap):
  425. """Both filters name the same class, so once one is rejected the other
  426. cannot succeed. Attempting it would only produce a second identical
  427. exception to swallow."""
  428. user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
  429. mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
  430. mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
  431. authenticate_ldap_user(_base_config(), "peter", "password")
  432. service_conn = _MockConnection._instances[0]
  433. posix_searches = [call for call in service_conn.search_calls if "posixGroup" in call]
  434. assert len(posix_searches) == 1
  435. assert "memberUid=peter" in posix_searches[0]
  436. def test_a_directory_that_defines_the_class_is_untouched(self, mock_ldap):
  437. """The guard must not cost a normal directory its POSIX groups — both
  438. searches still run and both results still land."""
  439. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  440. supplementary = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
  441. primary = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  442. mock_ldap._search_fixture = {
  443. "(uid=mz)": [user_entry],
  444. "memberUid=mz": [supplementary],
  445. "gidNumber=10002": [primary],
  446. }
  447. info = authenticate_ldap_user(_base_config(), "mz", "password")
  448. assert info.groups == [
  449. "cn=bambuddy-viewers,ou=groups,dc=test,dc=com",
  450. "cn=bambuddy-operators,ou=groups,dc=test,dc=com",
  451. ]
  452. # ---------------------------------------------------------------------------
  453. # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
  454. # ---------------------------------------------------------------------------
  455. class TestSearchLdapUsers:
  456. """Admin directory search for the manual-provision flow."""
  457. def test_returns_empty_when_query_too_short(self, mock_ldap):
  458. """Queries under 2 chars must not hit the directory at all."""
  459. results = search_ldap_users(_base_config(), "a")
  460. assert results == []
  461. # No connection was opened — no Connection instance recorded.
  462. assert _MockConnection._instances == []
  463. def test_returns_empty_when_query_whitespace(self, mock_ldap):
  464. results = search_ldap_users(_base_config(), " ")
  465. assert results == []
  466. assert _MockConnection._instances == []
  467. def test_filter_covers_all_common_attributes(self, mock_ldap):
  468. """The fixed OR filter must cover sAMAccountName, uid, mail, displayName, cn."""
  469. _MockConnection._search_fixture = {} # any matching attr; empty result is fine
  470. search_ldap_users(_base_config(), "jdoe")
  471. assert len(_MockConnection._instances) == 1
  472. sent = _MockConnection._instances[0].search_calls[0]
  473. for attr in ("sAMAccountName=*jdoe*", "uid=*jdoe*", "mail=*jdoe*", "displayName=*jdoe*", "cn=*jdoe*"):
  474. assert attr in sent, f"filter missing {attr}: {sent}"
  475. def test_wildcard_in_query_is_escaped(self, mock_ldap):
  476. """A typed * in the query must not enumerate the whole directory."""
  477. _MockConnection._search_fixture = {}
  478. search_ldap_users(_base_config(), "j*")
  479. sent = _MockConnection._instances[0].search_calls[0]
  480. # _ldap_escape replaces * with \2a; the outer wildcards (from our filter)
  481. # must remain, but the user-supplied * must be escaped.
  482. assert "*j\\2a*" in sent
  483. def test_picks_samaccountname_first(self, mock_ldap):
  484. entry = _MockEntry(
  485. "cn=John Doe,dc=test,dc=com",
  486. sAMAccountName="jdoe",
  487. uid="jdoe-uid",
  488. mail="jdoe@test.com",
  489. displayName="John Doe",
  490. cn="John Doe",
  491. )
  492. _MockConnection._search_fixture = {"sAMAccountName=*jdoe*": [entry]}
  493. results = search_ldap_users(_base_config(), "jdoe")
  494. assert len(results) == 1
  495. assert isinstance(results[0], LDAPSearchResult)
  496. assert results[0].username == "jdoe" # sAMAccountName preferred
  497. assert results[0].email == "jdoe@test.com"
  498. assert results[0].display_name == "John Doe"
  499. assert results[0].dn == "cn=John Doe,dc=test,dc=com"
  500. def test_falls_back_to_uid_when_no_samaccountname(self, mock_ldap):
  501. entry = _MockEntry("uid=alice,ou=people,dc=test,dc=com", uid="alice", cn="Alice")
  502. _MockConnection._search_fixture = {"uid=*alice*": [entry]}
  503. results = search_ldap_users(_base_config(), "alice")
  504. assert len(results) == 1
  505. assert results[0].username == "alice"
  506. def test_falls_back_to_cn_when_neither_samaccountname_nor_uid(self, mock_ldap):
  507. """Some OpenLDAP layouts only have cn — make sure we still surface them."""
  508. entry = _MockEntry("cn=Bob,ou=people,dc=test,dc=com", cn="Bob")
  509. _MockConnection._search_fixture = {"cn=*Bob*": [entry]}
  510. results = search_ldap_users(_base_config(), "Bob")
  511. assert len(results) == 1
  512. assert results[0].username == "Bob"
  513. def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
  514. """Bind failures must propagate so the route can return 503 instead of [] (which
  515. would look indistinguishable from 'no matches found' to the admin)."""
  516. class _BindFailConn(_MockConnection):
  517. def bind(self):
  518. raise RuntimeError("simulated bind failure")
  519. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
  520. with pytest.raises(RuntimeError):
  521. search_ldap_users(_base_config(), "anyone")
  522. def test_connection_skips_client_side_attribute_validation(self, mock_ldap, monkeypatch):
  523. """OpenLDAP directories don't define sAMAccountName/displayName in their schema,
  524. so ldap3 would raise LDAPAttributeError client-side before sending the query
  525. — break the regression by asserting Connection is opened with check_names=False
  526. for directory search."""
  527. captured_kwargs: dict = {}
  528. class _CapturingConn(_MockConnection):
  529. def __init__(self, *args, **kwargs):
  530. captured_kwargs.update(kwargs)
  531. super().__init__(*args, **kwargs)
  532. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _CapturingConn)
  533. search_ldap_users(_base_config(), "anyone")
  534. assert captured_kwargs.get("check_names") is False, (
  535. "search_ldap_users must open the connection with check_names=False — "
  536. "otherwise ldap3 rejects sAMAccountName/displayName on OpenLDAP schemas"
  537. )
  538. def test_requests_all_user_attributes_to_bypass_schema_check(self, mock_ldap):
  539. """ldap3's `build_attribute_selection` validates each named attribute against
  540. the server schema regardless of check_names; only the `*` wildcard is in
  541. its hard-coded exclusion list. So search_ldap_users MUST request `["*"]`
  542. — not the explicit AD-flavoured names — or OpenLDAP servers raise
  543. `LDAPAttributeError: invalid attribute type in attribute list: sAMAccountName`."""
  544. _MockConnection._search_fixture = {}
  545. search_ldap_users(_base_config(), "anyone")
  546. # The mock's search() captures search_filter in search_calls but not
  547. # attributes — so monkeypatch its signature briefly to capture both.
  548. # Easier: re-grep ldap3 here. The mock's search() accepts kwargs via
  549. # **kwargs; we just need to verify the attributes arg was the wildcard.
  550. sent_attrs = _MockConnection._instances[0].last_attrs # set by patched search
  551. assert sent_attrs == ["*"], (
  552. f"Expected attributes=['*'] to bypass ldap3 schema validation; got {sent_attrs!r}. "
  553. "Explicit AD attribute names (sAMAccountName, displayName) make ldap3 throw on "
  554. "OpenLDAP directories whose schema doesn't define them."
  555. )
  556. class TestLookupLdapUser:
  557. """Service-bind lookup used by the manual-provision route."""
  558. def test_returns_none_when_user_missing(self, mock_ldap):
  559. _MockConnection._search_fixture = {} # nothing matches
  560. result = lookup_ldap_user(_base_config(), "nobody")
  561. assert result is None
  562. def test_returns_user_info_with_groups(self, mock_ldap):
  563. user_entry = _MockEntry(
  564. "cn=John Doe,dc=test,dc=com",
  565. uid="jdoe",
  566. mail="jdoe@test.com",
  567. displayName="John Doe",
  568. memberOf=["cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"],
  569. )
  570. _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
  571. info = lookup_ldap_user(_base_config(), "jdoe")
  572. assert info is not None
  573. assert info.username == "jdoe"
  574. assert info.email == "jdoe@test.com"
  575. assert info.display_name == "John Doe"
  576. assert set(info.groups) == {"cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"}
  577. def test_does_not_attempt_password_bind(self, mock_ldap):
  578. """lookup_ldap_user MUST NOT call the user-DN bind that authenticate_ldap_user
  579. does — admins are using their own session, not the LDAP user's password."""
  580. user_entry = _MockEntry("cn=jdoe,dc=test,dc=com", uid="jdoe")
  581. _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
  582. lookup_ldap_user(_base_config(), "jdoe")
  583. # authenticate_ldap_user creates TWO Connection objects (service + user-bind).
  584. # lookup_ldap_user must create only ONE.
  585. assert len(_MockConnection._instances) == 1
  586. def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
  587. class _BindFailConn(_MockConnection):
  588. def bind(self):
  589. raise RuntimeError("simulated bind failure")
  590. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
  591. with pytest.raises(RuntimeError):
  592. lookup_ldap_user(_base_config(), "anyone")
  593. # ---------------------------------------------------------------------------
  594. # Group membership on directories where `*` doesn't return memberOf (#3197)
  595. # ---------------------------------------------------------------------------
  596. _USER_DN = "uid=tofm,ou=people,dc=example,dc=com"
  597. _ADMINS_DN = "cn=bambuddy-admins,ou=groups,dc=example,dc=com"
  598. _PEOPLE_BASE = "ou=people,dc=example,dc=com"
  599. def _user_search_attrs(conn: _MockConnection) -> list | None:
  600. """The attribute list sent with the user search (the first search on the service connection)."""
  601. return conn.search_attrs[0]
  602. def _member_searches(conn: _MockConnection) -> list[tuple[str, str | None]]:
  603. return [
  604. (flt, base)
  605. for flt, base in zip(conn.search_calls, conn.search_bases, strict=True)
  606. if "(member=" in flt or "(uniqueMember=" in flt
  607. ]
  608. class TestMemberOfIsRequestedByName:
  609. """lldap fills in memberOf only when it is asked for by name, and OpenLDAP's
  610. memberof overlay makes it operational, so `*` alone returns no groups. The
  611. reporter's lldap user was a member of a mapped group and always got the
  612. default group instead."""
  613. def test_login_asks_for_memberof_when_the_schema_has_it(self, mock_ldap):
  614. mock_ldap.server_fixture = _MockServer(
  615. attribute_types=["uid", "memberOf"], object_classes=["groupOfUniqueNames"]
  616. )
  617. user_entry = _MockEntry(_USER_DN, uid="tofm", memberOf=[_ADMINS_DN])
  618. mock_ldap._search_fixture = {"(uid=tofm)": [user_entry]}
  619. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  620. service_conn = _MockConnection._instances[0]
  621. assert _user_search_attrs(service_conn) == ["*", "memberOf"]
  622. assert info.groups == [_ADMINS_DN]
  623. def test_schema_match_is_case_insensitive(self, mock_ldap):
  624. """Schemas spell it memberof, memberOf or MemberOf; ldap3's schema dict ignores case."""
  625. mock_ldap.server_fixture = _MockServer(attribute_types=["memberof"], object_classes=[])
  626. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  627. authenticate_ldap_user(_base_config(), "tofm", "password")
  628. assert _user_search_attrs(_MockConnection._instances[0]) == ["*", "memberOf"]
  629. def test_groups_are_asked_as_well_as_memberof(self, mock_ldap):
  630. """OpenLDAP's memberof overlay tracks only the group class it was set up
  631. for (osixia's image: groupOfUniqueNames), so a groupOfNames group is
  632. missing from memberOf even though the schema has the attribute."""
  633. mock_ldap.server_fixture = _MockServer(
  634. attribute_types=["memberOf"],
  635. object_classes=["groupOfNames", "groupOfUniqueNames"],
  636. naming_contexts=["dc=example,dc=com"],
  637. )
  638. operators_dn = "cn=bambuddy-operators,ou=groups,dc=example,dc=com"
  639. mock_ldap._search_fixture = {
  640. "(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm", memberOf=[operators_dn])],
  641. f"(member={_USER_DN})": [_MockEntry(_ADMINS_DN), _MockEntry(operators_dn)],
  642. }
  643. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  644. assert info.groups == [operators_dn, _ADMINS_DN]
  645. def test_no_group_side_search_on_active_directory(self, mock_ldap):
  646. """AD keeps memberOf complete, and its groups are objectClass=group, so a
  647. subtree search from the domain root would find nothing."""
  648. mock_ldap.server_fixture = _MockServer(
  649. attribute_types=["memberOf", "member"],
  650. object_classes=["group", "groupOfNames"],
  651. naming_contexts=["dc=example,dc=com"],
  652. active_directory=True,
  653. )
  654. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm", memberOf=[_ADMINS_DN])]}
  655. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  656. assert info.groups == [_ADMINS_DN]
  657. assert _member_searches(_MockConnection._instances[0]) == []
  658. def test_admin_lookup_asks_for_memberof_too(self, mock_ldap):
  659. mock_ldap.server_fixture = _MockServer(attribute_types=["memberOf"], object_classes=[])
  660. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm", memberOf=[_ADMINS_DN])]}
  661. info = lookup_ldap_user(_base_config(), "tofm")
  662. assert _user_search_attrs(_MockConnection._instances[0]) == ["*", "memberOf"]
  663. assert info.groups == [_ADMINS_DN]
  664. def test_memberof_not_requested_when_the_schema_lacks_it(self, mock_ldap):
  665. """ldap3 rejects a requested attribute the schema doesn't define before
  666. sending anything, even with check_names off. Asking anyway would make
  667. every login on such a directory fail."""
  668. mock_ldap.server_fixture = _MockServer(attribute_types=["uid", "cn"], object_classes=[])
  669. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  670. authenticate_ldap_user(_base_config(), "tofm", "password")
  671. assert _user_search_attrs(_MockConnection._instances[0]) == ["*"]
  672. def test_memberof_requested_when_the_server_publishes_no_schema(self, mock_ldap):
  673. """Without a schema ldap3 checks no names, and the server ignores an
  674. attribute it doesn't know."""
  675. mock_ldap.server_fixture = _MockServer()
  676. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  677. authenticate_ldap_user(_base_config(), "tofm", "password")
  678. assert _user_search_attrs(_MockConnection._instances[0]) == ["*", "memberOf"]
  679. class TestGroupsListingTheUserByDn:
  680. """Group membership asked from the group side. Plain OpenLDAP without the
  681. memberof overlay can answer it no other way."""
  682. def _server(self, object_classes=("groupOfNames", "groupOfUniqueNames"), naming_contexts=("dc=example,dc=com",)):
  683. return _MockServer(
  684. attribute_types=["uid", "cn", "member", "uniqueMember"],
  685. object_classes=list(object_classes),
  686. naming_contexts=list(naming_contexts),
  687. )
  688. def test_finds_a_group_outside_the_user_search_base(self, mock_ldap):
  689. """The reporter's layout: users under ou=people, groups under ou=groups.
  690. The group search starts at the naming context, not the user search base."""
  691. mock_ldap.server_fixture = self._server()
  692. mock_ldap._search_fixture = {
  693. "(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")],
  694. f"(member={_USER_DN})": [_MockEntry(_ADMINS_DN)],
  695. }
  696. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  697. assert info.groups == [_ADMINS_DN]
  698. searches = _member_searches(_MockConnection._instances[0])
  699. assert len(searches) == 1
  700. flt, base = searches[0]
  701. assert base == "dc=example,dc=com"
  702. assert flt == (
  703. f"(|(&(objectClass=groupOfNames)(member={_USER_DN}))"
  704. f"(&(objectClass=groupOfUniqueNames)(uniqueMember={_USER_DN})))"
  705. )
  706. def test_only_classes_the_schema_defines_go_into_the_filter(self, mock_ldap):
  707. """Naming an undefined class raises client-side, the #2769 failure."""
  708. mock_ldap.server_fixture = self._server(object_classes=["groupOfUniqueNames"])
  709. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  710. authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  711. (flt, _base) = _member_searches(_MockConnection._instances[0])[0]
  712. assert flt == f"(&(objectClass=groupOfUniqueNames)(uniqueMember={_USER_DN}))"
  713. def test_no_search_when_the_schema_has_neither_class(self, mock_ldap):
  714. mock_ldap.server_fixture = self._server(object_classes=["posixGroup"])
  715. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  716. info = authenticate_ldap_user(_base_config(), "tofm", "password")
  717. assert info.groups == []
  718. assert _member_searches(_MockConnection._instances[0]) == []
  719. def test_dn_is_escaped_in_the_filter(self, mock_ldap):
  720. """A DN may carry filter metacharacters (an escaped comma, a parenthesis)."""
  721. dn = r"cn=Doe\, John (ops),ou=people,dc=example,dc=com"
  722. mock_ldap.server_fixture = self._server(object_classes=["groupOfNames"])
  723. mock_ldap._search_fixture = {"(uid=jdoe)": [_MockEntry(dn, uid="jdoe")]}
  724. authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "jdoe", "password")
  725. (flt, _base) = _member_searches(_MockConnection._instances[0])[0]
  726. assert flt == r"(&(objectClass=groupOfNames)(member=cn=Doe\5c, John \28ops\29,ou=people,dc=example,dc=com))"
  727. def test_search_base_is_used_when_no_naming_context_contains_it(self, mock_ldap):
  728. mock_ldap.server_fixture = self._server(naming_contexts=["dc=other,dc=org"])
  729. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  730. authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  731. (_flt, base) = _member_searches(_MockConnection._instances[0])[0]
  732. assert base == _PEOPLE_BASE
  733. def test_naming_context_match_is_on_whole_components(self, mock_ldap):
  734. """dc=ample,dc=com is not a suffix of ou=people,dc=example,dc=com."""
  735. mock_ldap.server_fixture = self._server(naming_contexts=["dc=ample,dc=com", "DC=Example,DC=Com"])
  736. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  737. authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  738. (_flt, base) = _member_searches(_MockConnection._instances[0])[0]
  739. assert base == "DC=Example,DC=Com"
  740. def test_dedupes_against_posix_groups(self, mock_ldap):
  741. """A group can be both a groupOfNames and a posixGroup (OpenLDAP rfc2307bis)."""
  742. mock_ldap.server_fixture = self._server(object_classes=["groupOfNames", "posixGroup"])
  743. mock_ldap._search_fixture = {
  744. "(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")],
  745. f"(member={_USER_DN})": [_MockEntry(_ADMINS_DN)],
  746. "memberUid=tofm": [_MockEntry(_ADMINS_DN.upper())],
  747. }
  748. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  749. assert info.groups == [_ADMINS_DN]
  750. def test_a_failed_group_search_still_logs_the_user_in(self, mock_ldap, caplog):
  751. """The user gets the groups found by other means, and the log names the
  752. failure without the DN it may contain (#2681)."""
  753. class _GroupSearchFails(_MockConnection):
  754. def search(self, search_base=None, search_filter=None, **kwargs):
  755. if "(member=" in (search_filter or ""):
  756. raise LDAPObjectClassError(f"size limit on {_USER_DN}")
  757. return super().search(search_base=search_base, search_filter=search_filter, **kwargs)
  758. import backend.app.services.ldap_service as ldap_service
  759. mock_ldap.server_fixture = self._server(object_classes=["groupOfNames"])
  760. mock_ldap._search_fixture = {"(uid=tofm)": [_MockEntry(_USER_DN, uid="tofm")]}
  761. original = ldap_service.Connection
  762. ldap_service.Connection = _GroupSearchFails
  763. try:
  764. with caplog.at_level("WARNING", logger="backend.app.services.ldap_service"):
  765. info = authenticate_ldap_user(_base_config(search_base=_PEOPLE_BASE), "tofm", "password")
  766. finally:
  767. ldap_service.Connection = original
  768. assert info is not None
  769. assert info.groups == []
  770. assert "LDAP group membership lookup failed (LDAPObjectClassError)" in caplog.text
  771. assert _USER_DN not in caplog.text
  772. class TestStartTlsRefused:
  773. """lldap offers LDAPS only. Its answer to StartTLS is "Unsupported extended
  774. operation" plus the StartTLS OID, which the reporter had to decode."""
  775. def _refusing(self, error):
  776. class _Conn(_MockConnection):
  777. def start_tls(self, read_server_info=True):
  778. raise error
  779. return _Conn
  780. def test_connection_test_says_what_to_change(self, mock_ldap, monkeypatch):
  781. refusal = LDAPUnwillingToPerformResult(
  782. result=53,
  783. description="unwillingToPerform",
  784. message="Unsupported extended operation: 1.3.6.1.4.1.1466.20037",
  785. response_type="extendedResp",
  786. )
  787. monkeypatch.setattr("backend.app.services.ldap_service.Connection", self._refusing(refusal))
  788. ok, message = check_ldap_connection(_base_config(server_url="ldap://lldap:3890", security="starttls"))
  789. assert ok is False
  790. assert message == (
  791. "LDAP connection failed: the server refused StartTLS (unwillingToPerform). "
  792. "If it only offers LDAPS, choose LDAPS and use its ldaps:// URL and port"
  793. )
  794. def test_login_with_refused_starttls_fails_cleanly(self, mock_ldap, monkeypatch):
  795. refusal = LDAPUnwillingToPerformResult(result=53, description="unwillingToPerform")
  796. monkeypatch.setattr("backend.app.services.ldap_service.Connection", self._refusing(refusal))
  797. assert authenticate_ldap_user(_base_config(server_url="ldap://x", security="starttls"), "u", "p") is None
  798. @pytest.mark.parametrize(
  799. "error",
  800. [LDAPStartTLSError("wrap socket error: certificate verify failed"), LDAPSocketOpenError("reset")],
  801. )
  802. def test_tls_failures_keep_their_own_message(self, mock_ldap, monkeypatch, error):
  803. """Only a refusal by the server is reworded; a certificate problem is not a missing feature."""
  804. monkeypatch.setattr("backend.app.services.ldap_service.Connection", self._refusing(error))
  805. ok, message = check_ldap_connection(_base_config(server_url="ldap://x", security="starttls"))
  806. assert ok is False
  807. assert message == f"LDAP connection failed: {error}"
  808. assert "refused StartTLS" not in message
  809. def test_ldaps_never_sends_starttls(self, mock_ldap, monkeypatch):
  810. refusal = LDAPUnwillingToPerformResult(result=53, description="unwillingToPerform")
  811. monkeypatch.setattr("backend.app.services.ldap_service.Connection", self._refusing(refusal))
  812. ok, _message = check_ldap_connection(_base_config(server_url="ldaps://x:636", security="starttls"))
  813. assert ok is True
  814. def test_server_info_is_not_read_before_the_bind(self, mock_ldap):
  815. """ldap3's default re-reads the schema right after StartTLS, before any
  816. bind; Active Directory and Samba AD refuse that anonymous read with
  817. operationsError, so StartTLS never worked against them. bind() reads it
  818. once authenticated."""
  819. mock_ldap._search_fixture = {"(uid=u)": [_MockEntry("uid=u,dc=test,dc=com", uid="u")]}
  820. authenticate_ldap_user(_base_config(server_url="ldap://ad:389", security="starttls"), "u", "p")
  821. service_conn, user_conn = _MockConnection._instances
  822. assert service_conn.start_tls_read_server_info is False
  823. assert user_conn.start_tls_read_server_info is False