test_ldap_provision.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. """Integration tests for the manual LDAP user provisioning routes (#1298).
  2. Reporter @Fuechslein noted that BamBuddy forced admins to leave auto-provision
  3. on because there was no UI path to create an LDAP user by hand. The new
  4. endpoints are GET /auth/ldap/search (admin types a partial name, picks a
  5. candidate) and POST /auth/ldap/provision (server re-resolves and creates the
  6. user).
  7. These tests cover:
  8. - Permission gating (only USERS_CREATE can search/provision)
  9. - LDAP-disabled and short-query rejections
  10. - Service-unreachable surfaces as 503, not 200 empty
  11. - Provision creates the user with auth_source=ldap, password_hash=None
  12. - Provision applies the same group mapping as the auto-provision login path
  13. - Duplicate-username protection (409 with explanation)
  14. """
  15. from unittest.mock import patch
  16. import pytest
  17. from httpx import AsyncClient
  18. from sqlalchemy import select
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
  21. from backend.app.models.settings import Settings
  22. from backend.app.models.user import User
  23. from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
  24. # ---------------------------------------------------------------------------
  25. # Fixtures
  26. # ---------------------------------------------------------------------------
  27. async def _seed_ldap_settings(db: AsyncSession, **overrides) -> None:
  28. """Write a minimal but valid LDAP config to the settings table."""
  29. defaults = {
  30. "ldap_enabled": "true",
  31. "ldap_server_url": "ldaps://ldap.test.example:636", # pragma: allowlist secret — test fixture
  32. "ldap_bind_dn": "cn=admin,dc=test,dc=com", # pragma: allowlist secret — test fixture
  33. "ldap_bind_password": "x", # pragma: allowlist secret — test fixture
  34. "ldap_search_base": "dc=test,dc=com",
  35. "ldap_user_filter": "(uid={username})",
  36. "ldap_security": "ldaps",
  37. "ldap_group_mapping": "{}",
  38. "ldap_auto_provision": "false",
  39. "ldap_ca_cert_path": "",
  40. "ldap_default_group": "",
  41. }
  42. defaults.update(overrides)
  43. for key, value in defaults.items():
  44. db.add(Settings(key=key, value=value))
  45. await db.commit()
  46. @pytest.fixture
  47. async def admin_token(async_client: AsyncClient) -> str:
  48. """Enable auth, create an admin, return a valid bearer token."""
  49. # pragma: allowlist secret — test fixture only, not a real credential
  50. test_password = "AdminPass1!" # noqa: S105
  51. await async_client.post(
  52. "/api/v1/auth/setup",
  53. json={
  54. "auth_enabled": True,
  55. "admin_username": "ldapadmin",
  56. "admin_password": test_password,
  57. },
  58. )
  59. login = await async_client.post(
  60. "/api/v1/auth/login",
  61. json={"username": "ldapadmin", "password": test_password},
  62. )
  63. return login.json()["access_token"]
  64. # ---------------------------------------------------------------------------
  65. # /auth/ldap/search
  66. # ---------------------------------------------------------------------------
  67. class TestLdapSearchRoute:
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_requires_auth(self, async_client: AsyncClient, db_session: AsyncSession):
  71. """Anonymous access is rejected when auth is enabled."""
  72. await async_client.post(
  73. "/api/v1/auth/setup",
  74. json={
  75. "auth_enabled": True,
  76. "admin_username": "x",
  77. "admin_password": "AdminPass1!",
  78. }, # pragma: allowlist secret — test fixture
  79. )
  80. response = await async_client.get("/api/v1/auth/ldap/search?q=jdoe")
  81. assert response.status_code == 401
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_rejects_short_query(self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession):
  85. """Single-char queries would be effectively unbounded against a large directory."""
  86. await _seed_ldap_settings(db_session)
  87. response = await async_client.get(
  88. "/api/v1/auth/ldap/search?q=j",
  89. headers={"Authorization": f"Bearer {admin_token}"},
  90. )
  91. assert response.status_code == 400
  92. assert "at least 2 characters" in response.json()["detail"]
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_rejects_when_ldap_disabled(
  96. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  97. ):
  98. """No LDAP config in settings → 400 with a clear message."""
  99. response = await async_client.get(
  100. "/api/v1/auth/ldap/search?q=jdoe",
  101. headers={"Authorization": f"Bearer {admin_token}"},
  102. )
  103. assert response.status_code == 400
  104. assert "LDAP is not enabled" in response.json()["detail"]
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_surfaces_unreachable_as_503(
  108. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  109. ):
  110. """When the underlying search fails (network/auth), the admin gets 503 — not
  111. a silent empty list (which would look like 'no matches')."""
  112. await _seed_ldap_settings(db_session)
  113. with patch(
  114. "backend.app.services.ldap_service.search_ldap_users",
  115. side_effect=RuntimeError("simulated outage"),
  116. ):
  117. response = await async_client.get(
  118. "/api/v1/auth/ldap/search?q=jdoe",
  119. headers={"Authorization": f"Bearer {admin_token}"},
  120. )
  121. assert response.status_code == 503
  122. # Detail now includes the underlying exception class + message so the
  123. # admin can see why (e.g. "LDAP search failed: RuntimeError: simulated outage").
  124. detail = response.json()["detail"].lower()
  125. assert "ldap search failed" in detail
  126. assert "simulated outage" in detail
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_returns_results_annotated_with_already_provisioned(
  130. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  131. ):
  132. """Results that match an existing local row must come back with the flag set."""
  133. await _seed_ldap_settings(db_session)
  134. # Seed an existing local user that shares a username with one LDAP result.
  135. db_session.add(User(username="existing", email="x@test.com", password_hash="$x$", role="user"))
  136. await db_session.commit()
  137. fake_results = [
  138. LDAPSearchResult(
  139. username="jdoe",
  140. email="jdoe@test.com",
  141. display_name="John Doe",
  142. dn="cn=John Doe,dc=test,dc=com",
  143. ),
  144. LDAPSearchResult(
  145. username="existing",
  146. email="existing@test.com",
  147. display_name="Already Provisioned",
  148. dn="cn=existing,dc=test,dc=com",
  149. ),
  150. ]
  151. with patch(
  152. "backend.app.services.ldap_service.search_ldap_users",
  153. return_value=fake_results,
  154. ):
  155. response = await async_client.get(
  156. "/api/v1/auth/ldap/search?q=jdoe",
  157. headers={"Authorization": f"Bearer {admin_token}"},
  158. )
  159. assert response.status_code == 200
  160. body = response.json()
  161. assert len(body) == 2
  162. by_user = {r["username"]: r for r in body}
  163. assert by_user["jdoe"]["already_provisioned"] is False
  164. assert by_user["existing"]["already_provisioned"] is True
  165. # ---------------------------------------------------------------------------
  166. # /auth/ldap/provision
  167. # ---------------------------------------------------------------------------
  168. class TestLdapProvisionRoute:
  169. @pytest.mark.asyncio
  170. @pytest.mark.integration
  171. async def test_requires_auth(self, async_client: AsyncClient):
  172. await async_client.post(
  173. "/api/v1/auth/setup",
  174. json={
  175. "auth_enabled": True,
  176. "admin_username": "x",
  177. "admin_password": "AdminPass1!",
  178. }, # pragma: allowlist secret — test fixture
  179. )
  180. response = await async_client.post(
  181. "/api/v1/auth/ldap/provision",
  182. json={"username": "jdoe"},
  183. )
  184. assert response.status_code == 401
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_404_when_directory_lookup_misses(
  188. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  189. ):
  190. await _seed_ldap_settings(db_session)
  191. with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=None):
  192. response = await async_client.post(
  193. "/api/v1/auth/ldap/provision",
  194. json={"username": "nobody"},
  195. headers={"Authorization": f"Bearer {admin_token}"},
  196. )
  197. assert response.status_code == 404
  198. assert "not found in LDAP directory" in response.json()["detail"]
  199. @pytest.mark.asyncio
  200. @pytest.mark.integration
  201. async def test_409_when_local_user_exists(
  202. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  203. ):
  204. """A local user with the same username must block provision — the admin has
  205. to resolve the collision manually rather than silently coexisting."""
  206. await _seed_ldap_settings(db_session)
  207. db_session.add(User(username="jdoe", password_hash="$x$", role="user", auth_source="local"))
  208. await db_session.commit()
  209. fake_ldap = LDAPUserInfo(username="jdoe", email="jdoe@test.com", display_name=None, groups=[])
  210. with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
  211. response = await async_client.post(
  212. "/api/v1/auth/ldap/provision",
  213. json={"username": "jdoe"},
  214. headers={"Authorization": f"Bearer {admin_token}"},
  215. )
  216. assert response.status_code == 409
  217. assert "local user" in response.json()["detail"].lower()
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_409_when_already_provisioned(
  221. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  222. ):
  223. """Re-provisioning an existing LDAP user must give a distinct error so the
  224. UI can suggest 'they exist already, just have them log in' rather than
  225. the more alarming 'local conflict' message."""
  226. await _seed_ldap_settings(db_session)
  227. db_session.add(User(username="alice", password_hash=None, role="user", auth_source="ldap"))
  228. await db_session.commit()
  229. fake_ldap = LDAPUserInfo(username="alice", email="alice@test.com", display_name=None, groups=[])
  230. with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
  231. response = await async_client.post(
  232. "/api/v1/auth/ldap/provision",
  233. json={"username": "alice"},
  234. headers={"Authorization": f"Bearer {admin_token}"},
  235. )
  236. assert response.status_code == 409
  237. assert "already provisioned" in response.json()["detail"].lower()
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_503_when_directory_unreachable(
  241. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  242. ):
  243. await _seed_ldap_settings(db_session)
  244. with patch(
  245. "backend.app.services.ldap_service.lookup_ldap_user",
  246. side_effect=RuntimeError("simulated outage"),
  247. ):
  248. response = await async_client.post(
  249. "/api/v1/auth/ldap/provision",
  250. json={"username": "jdoe"},
  251. headers={"Authorization": f"Bearer {admin_token}"},
  252. )
  253. assert response.status_code == 503
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_happy_path_creates_user_with_ldap_auth_source(
  257. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  258. ):
  259. """Verifies the full provision: response shape + DB state."""
  260. await _seed_ldap_settings(db_session)
  261. fake_ldap = LDAPUserInfo(
  262. username="newuser",
  263. email="newuser@test.com",
  264. display_name="New User",
  265. groups=[],
  266. )
  267. with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
  268. response = await async_client.post(
  269. "/api/v1/auth/ldap/provision",
  270. json={"username": "newuser"},
  271. headers={"Authorization": f"Bearer {admin_token}"},
  272. )
  273. assert response.status_code == 201
  274. body = response.json()
  275. assert body["username"] == "newuser"
  276. assert body["email"] == "newuser@test.com"
  277. assert body["auth_source"] == "ldap"
  278. # Verify DB state: password_hash MUST be None (LDAP has no local credential)
  279. from sqlalchemy import select
  280. row = (await db_session.execute(select(User).where(User.username == "newuser"))).scalar_one()
  281. assert row.auth_source == "ldap"
  282. assert row.password_hash is None
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_happy_path_applies_group_mapping(
  286. self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
  287. ):
  288. """Provision must run the same group-mapping logic as the auto-provision
  289. login path — so an admin who provisions Alice gets the exact same group
  290. memberships as if Alice had logged in herself with auto-provision on."""
  291. await _seed_ldap_settings(
  292. db_session,
  293. ldap_group_mapping='{"cn=staff,ou=groups,dc=test,dc=com": "Operators"}',
  294. )
  295. # Operators group is auto-seeded by the test harness — no need to create it.
  296. fake_ldap = LDAPUserInfo(
  297. username="alice",
  298. email="alice@test.com",
  299. display_name="Alice",
  300. groups=["cn=staff,ou=groups,dc=test,dc=com"],
  301. )
  302. with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
  303. response = await async_client.post(
  304. "/api/v1/auth/ldap/provision",
  305. json={"username": "alice"},
  306. headers={"Authorization": f"Bearer {admin_token}"},
  307. )
  308. assert response.status_code == 201
  309. body = response.json()
  310. group_names = {g["name"] for g in body["groups"]}
  311. assert "Operators" in group_names
  312. class TestLdapLoginFinanceDefaults:
  313. @pytest.mark.asyncio
  314. @pytest.mark.integration
  315. async def test_successful_ldap_login_backfills_finance_defaults(
  316. self, async_client: AsyncClient, db_session: AsyncSession
  317. ):
  318. """LDAP login should ensure wallet + private cost center defaults exist.
  319. Regression: LDAP users created before finance defaults were introduced can
  320. exist without wallet/private center. A successful LDAP login must backfill
  321. these defaults so billing-enabled flows have a valid personal cost center.
  322. """
  323. await async_client.post(
  324. "/api/v1/auth/setup",
  325. json={
  326. "auth_enabled": True,
  327. "admin_username": "ldapadmin",
  328. "admin_password": "AdminPass1!",
  329. },
  330. )
  331. await _seed_ldap_settings(db_session, ldap_auto_provision="false")
  332. legacy_user = User(
  333. username="legacyldap",
  334. email="legacyldap@test.com",
  335. password_hash=None,
  336. role="user",
  337. auth_source="ldap",
  338. is_active=True,
  339. )
  340. db_session.add(legacy_user)
  341. await db_session.commit()
  342. await db_session.refresh(legacy_user)
  343. # Precondition: legacy LDAP row has no finance defaults yet.
  344. wallet_before = (
  345. await db_session.execute(select(UserWallet).where(UserWallet.user_id == legacy_user.id))
  346. ).scalar_one_or_none()
  347. private_cc_before = (
  348. await db_session.execute(
  349. select(CostCenter).where(
  350. CostCenter.owner_user_id == legacy_user.id,
  351. CostCenter.is_private.is_(True),
  352. )
  353. )
  354. ).scalar_one_or_none()
  355. assert wallet_before is None
  356. assert private_cc_before is None
  357. fake_ldap = LDAPUserInfo(
  358. username="legacyldap",
  359. email="legacyldap@test.com",
  360. display_name="Legacy LDAP",
  361. groups=[],
  362. )
  363. with patch("backend.app.services.ldap_service.authenticate_ldap_user", return_value=fake_ldap):
  364. response = await async_client.post(
  365. "/api/v1/auth/login",
  366. json={"username": "legacyldap", "password": "irrelevant"},
  367. )
  368. assert response.status_code == 200
  369. assert response.json()["user"]["auth_source"] == "ldap"
  370. wallet_after = (
  371. await db_session.execute(select(UserWallet).where(UserWallet.user_id == legacy_user.id))
  372. ).scalar_one_or_none()
  373. assert wallet_after is not None
  374. private_cc_after = (
  375. await db_session.execute(
  376. select(CostCenter).where(
  377. CostCenter.owner_user_id == legacy_user.id,
  378. CostCenter.is_private.is_(True),
  379. )
  380. )
  381. ).scalar_one_or_none()
  382. assert private_cc_after is not None
  383. assert private_cc_after.name == "legacyldap"
  384. membership = (
  385. await db_session.execute(
  386. select(CostCenterMember).where(
  387. CostCenterMember.cost_center_id == private_cc_after.id,
  388. CostCenterMember.user_id == legacy_user.id,
  389. )
  390. )
  391. ).scalar_one_or_none()
  392. assert membership is not None
  393. assert membership.can_print is True