test_ldap_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  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. import pytest
  11. from backend.app.services.ldap_service import (
  12. LDAPConfig,
  13. LDAPSearchResult,
  14. LDAPUserInfo,
  15. _ldap_escape,
  16. authenticate_ldap_user,
  17. lookup_ldap_user,
  18. parse_ldap_config,
  19. resolve_group_mapping,
  20. search_ldap_users,
  21. )
  22. class TestParseConfig:
  23. """Verify parse_ldap_config builds LDAPConfig from settings dict."""
  24. def test_returns_none_when_disabled(self):
  25. settings = {"ldap_enabled": "false", "ldap_server_url": "ldaps://example.com"}
  26. assert parse_ldap_config(settings) is None
  27. def test_returns_none_when_missing_enabled(self):
  28. settings = {"ldap_server_url": "ldaps://example.com"}
  29. assert parse_ldap_config(settings) is None
  30. def test_returns_none_when_no_server_url(self):
  31. settings = {"ldap_enabled": "true", "ldap_server_url": ""}
  32. assert parse_ldap_config(settings) is None
  33. def test_returns_none_when_server_url_whitespace(self):
  34. settings = {"ldap_enabled": "true", "ldap_server_url": " "}
  35. assert parse_ldap_config(settings) is None
  36. def test_parses_minimal_config(self):
  37. settings = {
  38. "ldap_enabled": "true",
  39. "ldap_server_url": "ldaps://ldap.example.com:636",
  40. }
  41. config = parse_ldap_config(settings)
  42. assert config is not None
  43. assert config.server_url == "ldaps://ldap.example.com:636"
  44. assert config.bind_dn == ""
  45. assert config.search_base == ""
  46. assert config.user_filter == "(sAMAccountName={username})"
  47. assert config.security == "starttls"
  48. assert config.group_mapping == {}
  49. assert config.auto_provision is False
  50. assert config.ca_cert_path == ""
  51. assert config.default_group == ""
  52. def test_parses_full_config(self):
  53. settings = {
  54. "ldap_enabled": "true",
  55. "ldap_server_url": "ldaps://ldap.example.com:636",
  56. "ldap_bind_dn": "cn=admin,dc=example,dc=com",
  57. "ldap_bind_password": "secret",
  58. "ldap_search_base": "ou=users,dc=example,dc=com",
  59. "ldap_user_filter": "(uid={username})",
  60. "ldap_security": "ldaps",
  61. "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
  62. "ldap_auto_provision": "true",
  63. "ldap_ca_cert_path": "/path/to/ca.pem",
  64. "ldap_default_group": "Viewers",
  65. }
  66. config = parse_ldap_config(settings)
  67. assert config is not None
  68. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  69. assert config.bind_password == "secret"
  70. assert config.search_base == "ou=users,dc=example,dc=com"
  71. assert config.user_filter == "(uid={username})"
  72. assert config.security == "ldaps"
  73. assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
  74. assert config.auto_provision is True
  75. assert config.ca_cert_path == "/path/to/ca.pem"
  76. assert config.default_group == "Viewers"
  77. def test_handles_invalid_group_mapping_json(self):
  78. settings = {
  79. "ldap_enabled": "true",
  80. "ldap_server_url": "ldaps://ldap.example.com",
  81. "ldap_group_mapping": "not valid json",
  82. }
  83. config = parse_ldap_config(settings)
  84. assert config is not None
  85. assert config.group_mapping == {}
  86. def test_handles_non_dict_group_mapping(self):
  87. settings = {
  88. "ldap_enabled": "true",
  89. "ldap_server_url": "ldaps://ldap.example.com",
  90. "ldap_group_mapping": '["not", "a", "dict"]',
  91. }
  92. config = parse_ldap_config(settings)
  93. assert config is not None
  94. assert config.group_mapping == {}
  95. def test_enabled_case_insensitive(self):
  96. settings = {"ldap_enabled": "True", "ldap_server_url": "ldaps://ldap.example.com"}
  97. assert parse_ldap_config(settings) is not None
  98. settings = {"ldap_enabled": "TRUE", "ldap_server_url": "ldaps://ldap.example.com"}
  99. assert parse_ldap_config(settings) is not None
  100. def test_strips_whitespace(self):
  101. settings = {
  102. "ldap_enabled": "true",
  103. "ldap_server_url": " ldaps://ldap.example.com ",
  104. "ldap_bind_dn": " cn=admin,dc=example,dc=com ",
  105. "ldap_search_base": " dc=example,dc=com ",
  106. "ldap_default_group": " Viewers ",
  107. }
  108. config = parse_ldap_config(settings)
  109. assert config.server_url == "ldaps://ldap.example.com"
  110. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  111. assert config.search_base == "dc=example,dc=com"
  112. assert config.default_group == "Viewers"
  113. class TestLDAPEscape:
  114. """Verify RFC 4515 escaping for LDAP search filter values."""
  115. def test_plain_string(self):
  116. assert _ldap_escape("testuser") == "testuser"
  117. def test_escapes_backslash(self):
  118. assert _ldap_escape("test\\user") == "test\\5cuser"
  119. def test_escapes_asterisk(self):
  120. assert _ldap_escape("test*user") == "test\\2auser"
  121. def test_escapes_open_paren(self):
  122. assert _ldap_escape("test(user") == "test\\28user"
  123. def test_escapes_close_paren(self):
  124. assert _ldap_escape("test)user") == "test\\29user"
  125. def test_escapes_null(self):
  126. assert _ldap_escape("test\x00user") == "test\\00user"
  127. def test_escapes_multiple_chars(self):
  128. assert _ldap_escape("a*b(c)d\\e") == "a\\2ab\\28c\\29d\\5ce"
  129. def test_empty_string(self):
  130. assert _ldap_escape("") == ""
  131. class TestResolveGroupMapping:
  132. """Verify LDAP group DN to BamBuddy group name resolution."""
  133. def test_empty_mapping(self):
  134. assert resolve_group_mapping(["cn=admins,dc=example"], {}) == []
  135. def test_empty_groups(self):
  136. mapping = {"cn=admins,dc=example": "Administrators"}
  137. assert resolve_group_mapping([], mapping) == []
  138. def test_single_match(self):
  139. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  140. groups = ["cn=admins,dc=example,dc=com"]
  141. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  142. def test_multiple_matches(self):
  143. mapping = {
  144. "cn=admins,dc=example,dc=com": "Administrators",
  145. "cn=ops,dc=example,dc=com": "Operators",
  146. }
  147. groups = ["cn=admins,dc=example,dc=com", "cn=ops,dc=example,dc=com"]
  148. result = resolve_group_mapping(groups, mapping)
  149. assert set(result) == {"Administrators", "Operators"}
  150. def test_no_match(self):
  151. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  152. groups = ["cn=users,dc=example,dc=com"]
  153. assert resolve_group_mapping(groups, mapping) == []
  154. def test_case_insensitive_dn(self):
  155. mapping = {"CN=Admins,DC=Example,DC=Com": "Administrators"}
  156. groups = ["cn=admins,dc=example,dc=com"]
  157. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  158. def test_partial_match_not_matched(self):
  159. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  160. groups = ["cn=admins,dc=other,dc=com"]
  161. assert resolve_group_mapping(groups, mapping) == []
  162. def test_extra_groups_ignored(self):
  163. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  164. groups = ["cn=admins,dc=example,dc=com", "cn=users,dc=example,dc=com", "cn=devs,dc=example,dc=com"]
  165. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  166. class TestDataclasses:
  167. """Verify dataclass construction."""
  168. def test_ldap_user_info(self):
  169. info = LDAPUserInfo(
  170. username="testuser",
  171. email="test@example.com",
  172. display_name="Test User",
  173. groups=["cn=admins,dc=example,dc=com"],
  174. )
  175. assert info.username == "testuser"
  176. assert info.email == "test@example.com"
  177. assert info.display_name == "Test User"
  178. assert info.groups == ["cn=admins,dc=example,dc=com"]
  179. def test_ldap_user_info_none_fields(self):
  180. info = LDAPUserInfo(username="testuser", email=None, display_name=None, groups=[])
  181. assert info.email is None
  182. assert info.display_name is None
  183. assert info.groups == []
  184. def test_ldap_config(self):
  185. config = LDAPConfig(
  186. server_url="ldaps://ldap.example.com:636",
  187. bind_dn="cn=admin,dc=example,dc=com",
  188. bind_password="secret",
  189. search_base="dc=example,dc=com",
  190. user_filter="(uid={username})",
  191. security="ldaps",
  192. group_mapping={"cn=admins": "Administrators"},
  193. auto_provision=True,
  194. ca_cert_path="",
  195. default_group="Viewers",
  196. )
  197. assert config.server_url == "ldaps://ldap.example.com:636"
  198. assert config.auto_provision is True
  199. assert config.default_group == "Viewers"
  200. # ---------------------------------------------------------------------------
  201. # Mocked authenticate_ldap_user group-discovery tests
  202. # ---------------------------------------------------------------------------
  203. # These tests mock ldap3.Connection to exercise the group-discovery logic in
  204. # authenticate_ldap_user without a live LDAP server. Added after a bug where
  205. # POSIX primary-group membership (via gidNumber) was ignored — see CHANGELOG.
  206. class _MockAttr:
  207. """Minimal stand-in for ldap3 Attribute objects.
  208. Supports str(), bool(), .value, .values, and iteration — the operations
  209. used by ldap_service against user entry attributes.
  210. """
  211. def __init__(self, value):
  212. self._value = value
  213. @property
  214. def value(self):
  215. return self._value
  216. @property
  217. def values(self):
  218. return self._value if isinstance(self._value, list) else [self._value]
  219. def __str__(self):
  220. return str(self._value)
  221. def __bool__(self):
  222. return bool(self._value)
  223. def __iter__(self):
  224. if isinstance(self._value, list):
  225. return iter(self._value)
  226. return iter([self._value])
  227. class _MockEntry:
  228. """Minimal stand-in for ldap3 Entry. Only attributes passed at construction exist."""
  229. def __init__(self, dn, **attrs):
  230. self.entry_dn = dn
  231. for key, val in attrs.items():
  232. setattr(self, key, _MockAttr(val))
  233. class _MockConnection:
  234. """Mock ldap3 Connection that returns pre-configured entries based on filter substring match.
  235. Every Connection() instance shares a class-level fixture dict so the service-account
  236. connection and the user-bind connection both see the same fake directory.
  237. """
  238. _search_fixture: dict[str, list] = {}
  239. _instances: list["_MockConnection"] = []
  240. def __init__(self, *args, **kwargs):
  241. self.entries: list = []
  242. self.search_calls: list[str] = []
  243. self.last_attrs: list | None = None
  244. _MockConnection._instances.append(self)
  245. def open(self):
  246. pass
  247. def start_tls(self):
  248. pass
  249. def bind(self):
  250. return True
  251. def unbind(self):
  252. pass
  253. def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None, **kwargs):
  254. # **kwargs absorbs ldap3 options like size_limit that the real client supports
  255. self.search_calls.append(search_filter or "")
  256. self.last_attrs = list(attributes) if attributes is not None else None
  257. for needle, entries in _MockConnection._search_fixture.items():
  258. if needle in (search_filter or ""):
  259. self.entries = entries
  260. return True
  261. self.entries = []
  262. return True
  263. @pytest.fixture
  264. def mock_ldap(monkeypatch):
  265. """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
  266. _MockConnection._search_fixture = {}
  267. _MockConnection._instances = []
  268. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
  269. monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
  270. return _MockConnection
  271. def _base_config(**overrides):
  272. """Build a minimal LDAPConfig for mocked tests."""
  273. defaults = {
  274. "server_url": "ldaps://test.example.com:636",
  275. "bind_dn": "cn=admin,dc=test,dc=com",
  276. "bind_password": "x",
  277. "search_base": "dc=test,dc=com",
  278. "user_filter": "(uid={username})",
  279. "security": "ldaps",
  280. "group_mapping": {},
  281. "auto_provision": False,
  282. "ca_cert_path": "",
  283. "default_group": "",
  284. }
  285. defaults.update(overrides)
  286. return LDAPConfig(**defaults)
  287. class TestAuthenticateLdapUserGroups:
  288. """Group-discovery behaviour in authenticate_ldap_user.
  289. Covers the POSIX primary gidNumber lookup and case-insensitive dedupe added
  290. to fix a bug where users whose role came from their primary group were
  291. authenticated without the correct group membership.
  292. """
  293. def test_primary_gidnumber_group_found(self, mock_ldap):
  294. """Regression: POSIX primary group (gidNumber match) must be included in the result."""
  295. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  296. operators_group = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  297. mock_ldap._search_fixture = {
  298. "(uid=mz)": [user_entry],
  299. "memberUid=mz": [], # no supplementary memberships
  300. "gidNumber=10002": [operators_group],
  301. }
  302. info = authenticate_ldap_user(_base_config(), "mz", "password")
  303. assert info is not None
  304. assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
  305. def test_dedupes_group_found_via_both_memberuid_and_primary_gid(self, mock_ldap):
  306. """A user in the same group via BOTH memberUid and primary gidNumber should appear once."""
  307. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  308. group_entry = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  309. mock_ldap._search_fixture = {
  310. "(uid=mz)": [user_entry],
  311. "memberUid=mz": [group_entry], # supplementary membership
  312. "gidNumber=10002": [group_entry], # primary group — same DN
  313. }
  314. info = authenticate_ldap_user(_base_config(), "mz", "password")
  315. assert info.groups == ["cn=bambuddy-operators,ou=groups,dc=test,dc=com"]
  316. def test_case_insensitive_dedupe(self, mock_ldap):
  317. """DNs differing only in case should collapse to a single entry (LDAP DNs are case-insensitive)."""
  318. user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
  319. upper_dn = _MockEntry("CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com")
  320. lower_dn = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
  321. mock_ldap._search_fixture = {
  322. "(uid=mz)": [user_entry],
  323. "memberUid=mz": [upper_dn],
  324. "gidNumber=10002": [lower_dn],
  325. }
  326. info = authenticate_ldap_user(_base_config(), "mz", "password")
  327. assert len(info.groups) == 1
  328. # The first-seen casing (memberUid result) is kept.
  329. assert info.groups[0] == "CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com"
  330. def test_no_gidnumber_skips_primary_search(self, mock_ldap):
  331. """User entries without a gidNumber attribute should not crash and should not issue the primary-gid query."""
  332. user_entry = _MockEntry("cn=tester,dc=test,dc=com", uid="tester") # no gidNumber
  333. viewers_group = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
  334. mock_ldap._search_fixture = {
  335. "(uid=tester)": [user_entry],
  336. "memberUid=tester": [viewers_group],
  337. }
  338. info = authenticate_ldap_user(_base_config(), "tester", "password")
  339. assert info is not None
  340. assert info.groups == ["cn=bambuddy-viewers,ou=groups,dc=test,dc=com"]
  341. # Ensure the primary-gidNumber search was never issued — verifying the guard works.
  342. service_conn = _MockConnection._instances[0]
  343. gidnumber_searches = [call for call in service_conn.search_calls if "gidNumber=" in call]
  344. assert gidnumber_searches == []
  345. # ---------------------------------------------------------------------------
  346. # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
  347. # ---------------------------------------------------------------------------
  348. class TestSearchLdapUsers:
  349. """Admin directory search for the manual-provision flow."""
  350. def test_returns_empty_when_query_too_short(self, mock_ldap):
  351. """Queries under 2 chars must not hit the directory at all."""
  352. results = search_ldap_users(_base_config(), "a")
  353. assert results == []
  354. # No connection was opened — no Connection instance recorded.
  355. assert _MockConnection._instances == []
  356. def test_returns_empty_when_query_whitespace(self, mock_ldap):
  357. results = search_ldap_users(_base_config(), " ")
  358. assert results == []
  359. assert _MockConnection._instances == []
  360. def test_filter_covers_all_common_attributes(self, mock_ldap):
  361. """The fixed OR filter must cover sAMAccountName, uid, mail, displayName, cn."""
  362. _MockConnection._search_fixture = {} # any matching attr; empty result is fine
  363. search_ldap_users(_base_config(), "jdoe")
  364. assert len(_MockConnection._instances) == 1
  365. sent = _MockConnection._instances[0].search_calls[0]
  366. for attr in ("sAMAccountName=*jdoe*", "uid=*jdoe*", "mail=*jdoe*", "displayName=*jdoe*", "cn=*jdoe*"):
  367. assert attr in sent, f"filter missing {attr}: {sent}"
  368. def test_wildcard_in_query_is_escaped(self, mock_ldap):
  369. """A typed * in the query must not enumerate the whole directory."""
  370. _MockConnection._search_fixture = {}
  371. search_ldap_users(_base_config(), "j*")
  372. sent = _MockConnection._instances[0].search_calls[0]
  373. # _ldap_escape replaces * with \2a; the outer wildcards (from our filter)
  374. # must remain, but the user-supplied * must be escaped.
  375. assert "*j\\2a*" in sent
  376. def test_picks_samaccountname_first(self, mock_ldap):
  377. entry = _MockEntry(
  378. "cn=John Doe,dc=test,dc=com",
  379. sAMAccountName="jdoe",
  380. uid="jdoe-uid",
  381. mail="jdoe@test.com",
  382. displayName="John Doe",
  383. cn="John Doe",
  384. )
  385. _MockConnection._search_fixture = {"sAMAccountName=*jdoe*": [entry]}
  386. results = search_ldap_users(_base_config(), "jdoe")
  387. assert len(results) == 1
  388. assert isinstance(results[0], LDAPSearchResult)
  389. assert results[0].username == "jdoe" # sAMAccountName preferred
  390. assert results[0].email == "jdoe@test.com"
  391. assert results[0].display_name == "John Doe"
  392. assert results[0].dn == "cn=John Doe,dc=test,dc=com"
  393. def test_falls_back_to_uid_when_no_samaccountname(self, mock_ldap):
  394. entry = _MockEntry("uid=alice,ou=people,dc=test,dc=com", uid="alice", cn="Alice")
  395. _MockConnection._search_fixture = {"uid=*alice*": [entry]}
  396. results = search_ldap_users(_base_config(), "alice")
  397. assert len(results) == 1
  398. assert results[0].username == "alice"
  399. def test_falls_back_to_cn_when_neither_samaccountname_nor_uid(self, mock_ldap):
  400. """Some OpenLDAP layouts only have cn — make sure we still surface them."""
  401. entry = _MockEntry("cn=Bob,ou=people,dc=test,dc=com", cn="Bob")
  402. _MockConnection._search_fixture = {"cn=*Bob*": [entry]}
  403. results = search_ldap_users(_base_config(), "Bob")
  404. assert len(results) == 1
  405. assert results[0].username == "Bob"
  406. def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
  407. """Bind failures must propagate so the route can return 503 instead of [] (which
  408. would look indistinguishable from 'no matches found' to the admin)."""
  409. class _BindFailConn(_MockConnection):
  410. def bind(self):
  411. raise RuntimeError("simulated bind failure")
  412. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
  413. with pytest.raises(RuntimeError):
  414. search_ldap_users(_base_config(), "anyone")
  415. def test_connection_skips_client_side_attribute_validation(self, mock_ldap, monkeypatch):
  416. """OpenLDAP directories don't define sAMAccountName/displayName in their schema,
  417. so ldap3 would raise LDAPAttributeError client-side before sending the query
  418. — break the regression by asserting Connection is opened with check_names=False
  419. for directory search."""
  420. captured_kwargs: dict = {}
  421. class _CapturingConn(_MockConnection):
  422. def __init__(self, *args, **kwargs):
  423. captured_kwargs.update(kwargs)
  424. super().__init__(*args, **kwargs)
  425. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _CapturingConn)
  426. search_ldap_users(_base_config(), "anyone")
  427. assert captured_kwargs.get("check_names") is False, (
  428. "search_ldap_users must open the connection with check_names=False — "
  429. "otherwise ldap3 rejects sAMAccountName/displayName on OpenLDAP schemas"
  430. )
  431. def test_requests_all_user_attributes_to_bypass_schema_check(self, mock_ldap):
  432. """ldap3's `build_attribute_selection` validates each named attribute against
  433. the server schema regardless of check_names; only the `*` wildcard is in
  434. its hard-coded exclusion list. So search_ldap_users MUST request `["*"]`
  435. — not the explicit AD-flavoured names — or OpenLDAP servers raise
  436. `LDAPAttributeError: invalid attribute type in attribute list: sAMAccountName`."""
  437. _MockConnection._search_fixture = {}
  438. search_ldap_users(_base_config(), "anyone")
  439. # The mock's search() captures search_filter in search_calls but not
  440. # attributes — so monkeypatch its signature briefly to capture both.
  441. # Easier: re-grep ldap3 here. The mock's search() accepts kwargs via
  442. # **kwargs; we just need to verify the attributes arg was the wildcard.
  443. sent_attrs = _MockConnection._instances[0].last_attrs # set by patched search
  444. assert sent_attrs == ["*"], (
  445. f"Expected attributes=['*'] to bypass ldap3 schema validation; got {sent_attrs!r}. "
  446. "Explicit AD attribute names (sAMAccountName, displayName) make ldap3 throw on "
  447. "OpenLDAP directories whose schema doesn't define them."
  448. )
  449. class TestLookupLdapUser:
  450. """Service-bind lookup used by the manual-provision route."""
  451. def test_returns_none_when_user_missing(self, mock_ldap):
  452. _MockConnection._search_fixture = {} # nothing matches
  453. result = lookup_ldap_user(_base_config(), "nobody")
  454. assert result is None
  455. def test_returns_user_info_with_groups(self, mock_ldap):
  456. user_entry = _MockEntry(
  457. "cn=John Doe,dc=test,dc=com",
  458. uid="jdoe",
  459. mail="jdoe@test.com",
  460. displayName="John Doe",
  461. memberOf=["cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"],
  462. )
  463. _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
  464. info = lookup_ldap_user(_base_config(), "jdoe")
  465. assert info is not None
  466. assert info.username == "jdoe"
  467. assert info.email == "jdoe@test.com"
  468. assert info.display_name == "John Doe"
  469. assert set(info.groups) == {"cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"}
  470. def test_does_not_attempt_password_bind(self, mock_ldap):
  471. """lookup_ldap_user MUST NOT call the user-DN bind that authenticate_ldap_user
  472. does — admins are using their own session, not the LDAP user's password."""
  473. user_entry = _MockEntry("cn=jdoe,dc=test,dc=com", uid="jdoe")
  474. _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
  475. lookup_ldap_user(_base_config(), "jdoe")
  476. # authenticate_ldap_user creates TWO Connection objects (service + user-bind).
  477. # lookup_ldap_user must create only ONE.
  478. assert len(_MockConnection._instances) == 1
  479. def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
  480. class _BindFailConn(_MockConnection):
  481. def bind(self):
  482. raise RuntimeError("simulated bind failure")
  483. monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
  484. with pytest.raises(RuntimeError):
  485. lookup_ldap_user(_base_config(), "anyone")