test_ldap_service.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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. LDAPUserInfo,
  14. _ldap_escape,
  15. parse_ldap_config,
  16. resolve_group_mapping,
  17. )
  18. class TestParseConfig:
  19. """Verify parse_ldap_config builds LDAPConfig from settings dict."""
  20. def test_returns_none_when_disabled(self):
  21. settings = {"ldap_enabled": "false", "ldap_server_url": "ldaps://example.com"}
  22. assert parse_ldap_config(settings) is None
  23. def test_returns_none_when_missing_enabled(self):
  24. settings = {"ldap_server_url": "ldaps://example.com"}
  25. assert parse_ldap_config(settings) is None
  26. def test_returns_none_when_no_server_url(self):
  27. settings = {"ldap_enabled": "true", "ldap_server_url": ""}
  28. assert parse_ldap_config(settings) is None
  29. def test_returns_none_when_server_url_whitespace(self):
  30. settings = {"ldap_enabled": "true", "ldap_server_url": " "}
  31. assert parse_ldap_config(settings) is None
  32. def test_parses_minimal_config(self):
  33. settings = {
  34. "ldap_enabled": "true",
  35. "ldap_server_url": "ldaps://ldap.example.com:636",
  36. }
  37. config = parse_ldap_config(settings)
  38. assert config is not None
  39. assert config.server_url == "ldaps://ldap.example.com:636"
  40. assert config.bind_dn == ""
  41. assert config.search_base == ""
  42. assert config.user_filter == "(sAMAccountName={username})"
  43. assert config.security == "starttls"
  44. assert config.group_mapping == {}
  45. assert config.auto_provision is False
  46. assert config.ca_cert_path == ""
  47. def test_parses_full_config(self):
  48. settings = {
  49. "ldap_enabled": "true",
  50. "ldap_server_url": "ldaps://ldap.example.com:636",
  51. "ldap_bind_dn": "cn=admin,dc=example,dc=com",
  52. "ldap_bind_password": "secret",
  53. "ldap_search_base": "ou=users,dc=example,dc=com",
  54. "ldap_user_filter": "(uid={username})",
  55. "ldap_security": "ldaps",
  56. "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
  57. "ldap_auto_provision": "true",
  58. "ldap_ca_cert_path": "/path/to/ca.pem",
  59. }
  60. config = parse_ldap_config(settings)
  61. assert config is not None
  62. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  63. assert config.bind_password == "secret"
  64. assert config.search_base == "ou=users,dc=example,dc=com"
  65. assert config.user_filter == "(uid={username})"
  66. assert config.security == "ldaps"
  67. assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
  68. assert config.auto_provision is True
  69. assert config.ca_cert_path == "/path/to/ca.pem"
  70. def test_handles_invalid_group_mapping_json(self):
  71. settings = {
  72. "ldap_enabled": "true",
  73. "ldap_server_url": "ldaps://ldap.example.com",
  74. "ldap_group_mapping": "not valid json",
  75. }
  76. config = parse_ldap_config(settings)
  77. assert config is not None
  78. assert config.group_mapping == {}
  79. def test_handles_non_dict_group_mapping(self):
  80. settings = {
  81. "ldap_enabled": "true",
  82. "ldap_server_url": "ldaps://ldap.example.com",
  83. "ldap_group_mapping": '["not", "a", "dict"]',
  84. }
  85. config = parse_ldap_config(settings)
  86. assert config is not None
  87. assert config.group_mapping == {}
  88. def test_enabled_case_insensitive(self):
  89. settings = {"ldap_enabled": "True", "ldap_server_url": "ldaps://ldap.example.com"}
  90. assert parse_ldap_config(settings) is not None
  91. settings = {"ldap_enabled": "TRUE", "ldap_server_url": "ldaps://ldap.example.com"}
  92. assert parse_ldap_config(settings) is not None
  93. def test_strips_whitespace(self):
  94. settings = {
  95. "ldap_enabled": "true",
  96. "ldap_server_url": " ldaps://ldap.example.com ",
  97. "ldap_bind_dn": " cn=admin,dc=example,dc=com ",
  98. "ldap_search_base": " dc=example,dc=com ",
  99. }
  100. config = parse_ldap_config(settings)
  101. assert config.server_url == "ldaps://ldap.example.com"
  102. assert config.bind_dn == "cn=admin,dc=example,dc=com"
  103. assert config.search_base == "dc=example,dc=com"
  104. class TestLDAPEscape:
  105. """Verify RFC 4515 escaping for LDAP search filter values."""
  106. def test_plain_string(self):
  107. assert _ldap_escape("testuser") == "testuser"
  108. def test_escapes_backslash(self):
  109. assert _ldap_escape("test\\user") == "test\\5cuser"
  110. def test_escapes_asterisk(self):
  111. assert _ldap_escape("test*user") == "test\\2auser"
  112. def test_escapes_open_paren(self):
  113. assert _ldap_escape("test(user") == "test\\28user"
  114. def test_escapes_close_paren(self):
  115. assert _ldap_escape("test)user") == "test\\29user"
  116. def test_escapes_null(self):
  117. assert _ldap_escape("test\x00user") == "test\\00user"
  118. def test_escapes_multiple_chars(self):
  119. assert _ldap_escape("a*b(c)d\\e") == "a\\2ab\\28c\\29d\\5ce"
  120. def test_empty_string(self):
  121. assert _ldap_escape("") == ""
  122. class TestResolveGroupMapping:
  123. """Verify LDAP group DN to BamBuddy group name resolution."""
  124. def test_empty_mapping(self):
  125. assert resolve_group_mapping(["cn=admins,dc=example"], {}) == []
  126. def test_empty_groups(self):
  127. mapping = {"cn=admins,dc=example": "Administrators"}
  128. assert resolve_group_mapping([], mapping) == []
  129. def test_single_match(self):
  130. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  131. groups = ["cn=admins,dc=example,dc=com"]
  132. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  133. def test_multiple_matches(self):
  134. mapping = {
  135. "cn=admins,dc=example,dc=com": "Administrators",
  136. "cn=ops,dc=example,dc=com": "Operators",
  137. }
  138. groups = ["cn=admins,dc=example,dc=com", "cn=ops,dc=example,dc=com"]
  139. result = resolve_group_mapping(groups, mapping)
  140. assert set(result) == {"Administrators", "Operators"}
  141. def test_no_match(self):
  142. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  143. groups = ["cn=users,dc=example,dc=com"]
  144. assert resolve_group_mapping(groups, mapping) == []
  145. def test_case_insensitive_dn(self):
  146. mapping = {"CN=Admins,DC=Example,DC=Com": "Administrators"}
  147. groups = ["cn=admins,dc=example,dc=com"]
  148. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  149. def test_partial_match_not_matched(self):
  150. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  151. groups = ["cn=admins,dc=other,dc=com"]
  152. assert resolve_group_mapping(groups, mapping) == []
  153. def test_extra_groups_ignored(self):
  154. mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
  155. groups = ["cn=admins,dc=example,dc=com", "cn=users,dc=example,dc=com", "cn=devs,dc=example,dc=com"]
  156. assert resolve_group_mapping(groups, mapping) == ["Administrators"]
  157. class TestDataclasses:
  158. """Verify dataclass construction."""
  159. def test_ldap_user_info(self):
  160. info = LDAPUserInfo(
  161. username="testuser",
  162. email="test@example.com",
  163. display_name="Test User",
  164. groups=["cn=admins,dc=example,dc=com"],
  165. )
  166. assert info.username == "testuser"
  167. assert info.email == "test@example.com"
  168. assert info.display_name == "Test User"
  169. assert info.groups == ["cn=admins,dc=example,dc=com"]
  170. def test_ldap_user_info_none_fields(self):
  171. info = LDAPUserInfo(username="testuser", email=None, display_name=None, groups=[])
  172. assert info.email is None
  173. assert info.display_name is None
  174. assert info.groups == []
  175. def test_ldap_config(self):
  176. config = LDAPConfig(
  177. server_url="ldaps://ldap.example.com:636",
  178. bind_dn="cn=admin,dc=example,dc=com",
  179. bind_password="secret",
  180. search_base="dc=example,dc=com",
  181. user_filter="(uid={username})",
  182. security="ldaps",
  183. group_mapping={"cn=admins": "Administrators"},
  184. auto_provision=True,
  185. ca_cert_path="",
  186. )
  187. assert config.server_url == "ldaps://ldap.example.com:636"
  188. assert config.auto_provision is True