test_commissions.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import uuid
  2. import pytest
  3. from httpx import AsyncClient
  4. from sqlalchemy import select
  5. from sqlalchemy.orm import Session
  6. from app.core.session import session as session_maker
  7. from app.main import app
  8. from app.models import Commission, Organization, OrgRole, Project, User, UserOrganization
  9. from app.tests.shared_access import SharedProjectAccessTests
  10. pytestmark = pytest.mark.asyncio
  11. MANAGE_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE]
  12. FORBIDDEN_MANAGE_ROLES = [OrgRole.RESPO_COMMISSION]
  13. READ_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
  14. @pytest.fixture
  15. def two_users() -> tuple[User, User]:
  16. with session_maker() as db:
  17. u1 = User(
  18. email="u1@test.com",
  19. hashed_password="hashed1",
  20. name="Arthur Pendragon",
  21. phone_number="0601010101",
  22. )
  23. u2 = User(
  24. email="u2@test.com",
  25. hashed_password="hashed2",
  26. name="Merlin Enchanteur",
  27. phone_number="0602020202",
  28. )
  29. db.add_all([u1, u2])
  30. db.commit()
  31. db.refresh(u1)
  32. db.refresh(u2)
  33. return u1, u2
  34. @pytest.fixture
  35. def default_commission(default_project: Project) -> Commission:
  36. with session_maker() as db:
  37. commission = Commission(project_id=default_project.id, name="Logistique")
  38. db.add(commission)
  39. db.commit()
  40. db.refresh(commission)
  41. return commission
  42. route_kwarg = {"project_id": "PROJECT", "commission_id": "COMMISSION"}
  43. COMMISSION_ROUTES = [
  44. ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
  45. ("POST", "create_commission", {"project_id": "PROJECT"}, {"name": "Test"}),
  46. ("GET", "get_commission", route_kwarg, None),
  47. ("POST", "update_commission", route_kwarg, {"name": "Updated"}),
  48. ("DELETE", "delete_commission", route_kwarg, None),
  49. ("POST", "add_members_to_commission", route_kwarg, {"user_ids": ["USER"]}),
  50. ("DELETE", "remove_member_from_commission", {**route_kwarg, "user_id": "USER"}, None),
  51. (
  52. "POST",
  53. "invite_commission_member",
  54. route_kwarg,
  55. {"email": "crosscut@test.com", "name": "Cross Cut", "password": "x"},
  56. ),
  57. ]
  58. class TestCommissionCrossCutting(SharedProjectAccessTests):
  59. @pytest.fixture(params=COMMISSION_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
  60. def resolved_route(self, request, default_project, default_commission):
  61. method, endpoint, kwargs, payload = request.param
  62. resolved_kwargs = {
  63. k: (
  64. default_project.id
  65. if v == "PROJECT"
  66. else default_commission.id
  67. if v == "COMMISSION"
  68. else v
  69. )
  70. for k, v in kwargs.items()
  71. }
  72. return method, endpoint, resolved_kwargs, payload
  73. class TestListCommissions:
  74. @pytest.mark.parametrize("role", READ_ROLES)
  75. async def test_role_access(
  76. self,
  77. client: AsyncClient,
  78. default_project: Project,
  79. default_commission: Commission,
  80. make_org_user,
  81. role,
  82. ):
  83. _, headers = make_org_user(role=role)
  84. response = await client.get(
  85. app.url_path_for("list_project_commissions", project_id=default_project.id),
  86. headers=headers,
  87. )
  88. assert response.status_code == 200
  89. data = response.json()
  90. assert len(data) == 1
  91. assert data[0]["name"] == "Logistique"
  92. class TestCreateCommission:
  93. @pytest.mark.parametrize("role", MANAGE_ROLES)
  94. async def test_role_access(
  95. self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
  96. ):
  97. _, headers = make_org_user(role=role)
  98. response = await client.post(
  99. app.url_path_for("create_commission", project_id=default_project.id),
  100. headers=headers,
  101. json={"name": "Accueil"},
  102. )
  103. assert response.status_code == 200
  104. commission = (
  105. session.execute(select(Commission).where(Commission.name == "Accueil"))
  106. .scalars()
  107. .first()
  108. )
  109. assert commission is not None
  110. assert commission.project_id == str(default_project.id)
  111. @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
  112. async def test_forbidden_manage_roles(
  113. self, client: AsyncClient, default_project: Project, make_org_user, role
  114. ):
  115. _, headers = make_org_user(role=role)
  116. response = await client.post(
  117. app.url_path_for("create_commission", project_id=default_project.id),
  118. headers=headers,
  119. json={"name": "Nope"},
  120. )
  121. assert response.status_code == 403
  122. class TestGetCommission:
  123. async def test_commission_not_found(
  124. self, client: AsyncClient, make_org_user, default_project: Project
  125. ):
  126. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  127. response = await client.get(
  128. app.url_path_for(
  129. "get_commission", project_id=default_project.id, commission_id=uuid.uuid4()
  130. ),
  131. headers=headers,
  132. )
  133. assert response.status_code == 404
  134. async def test_commission_from_other_project_not_found(
  135. self, client: AsyncClient, default_commission: Commission, make_org_user, session: Session
  136. ):
  137. other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
  138. session.add(other_org)
  139. session.commit()
  140. other_project = Project(name="Other Project", is_public=False, organization_id=other_org.id)
  141. session.add(other_project)
  142. session.commit()
  143. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  144. response = await client.get(
  145. app.url_path_for(
  146. "get_commission", project_id=other_project.id, commission_id=default_commission.id
  147. ),
  148. headers=headers,
  149. )
  150. # Even if they have the org admin role, the project_id URL mismatch means 403 or 404
  151. assert response.status_code in (403, 404)
  152. class TestUpdateCommission:
  153. async def test_updates_name(
  154. self,
  155. client: AsyncClient,
  156. default_project: Project,
  157. default_commission: Commission,
  158. make_org_user,
  159. session: Session,
  160. ):
  161. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  162. response = await client.post(
  163. app.url_path_for(
  164. "update_commission",
  165. project_id=default_project.id,
  166. commission_id=default_commission.id,
  167. ),
  168. headers=headers,
  169. json={"name": "Securite Renamed"},
  170. )
  171. assert response.status_code == 200
  172. commission = session.get(Commission, default_commission.id)
  173. assert commission.name == "Securite Renamed"
  174. class TestDeleteCommission:
  175. async def test_deletes_commission_and_members_links_but_not_users(
  176. self,
  177. client: AsyncClient,
  178. default_project: Project,
  179. default_commission: Commission,
  180. two_users,
  181. make_org_user,
  182. session: Session,
  183. ):
  184. u1, _ = two_users
  185. commission = session.get(Commission, default_commission.id)
  186. commission.members.append(u1)
  187. session.commit()
  188. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  189. response = await client.delete(
  190. app.url_path_for(
  191. "delete_commission",
  192. project_id=default_project.id,
  193. commission_id=default_commission.id,
  194. ),
  195. headers=headers,
  196. )
  197. assert response.status_code == 200
  198. assert session.get(Commission, default_commission.id) is None
  199. # User should still exist
  200. assert session.get(User, u1.id) is not None
  201. class TestCommissionMembership:
  202. async def test_add_users_to_commission(
  203. self,
  204. client: AsyncClient,
  205. default_project: Project,
  206. default_commission: Commission,
  207. two_users,
  208. make_org_user,
  209. ):
  210. u1, u2 = two_users
  211. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  212. response = await client.post(
  213. app.url_path_for(
  214. "add_members_to_commission",
  215. project_id=default_project.id,
  216. commission_id=default_commission.id,
  217. ),
  218. headers=headers,
  219. json={"user_ids": [str(u1.id), str(u2.id)]},
  220. )
  221. assert response.status_code == 200
  222. data = response.json()
  223. assert len(data["members_id"]) == 2
  224. assert str(u1.id) in data["members_id"]
  225. assert str(u2.id) in data["members_id"]
  226. async def test_add_is_idempotent(
  227. self,
  228. client: AsyncClient,
  229. default_project: Project,
  230. default_commission: Commission,
  231. two_users,
  232. make_org_user,
  233. ):
  234. u1, _ = two_users
  235. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  236. await client.post(
  237. app.url_path_for(
  238. "add_members_to_commission",
  239. project_id=default_project.id,
  240. commission_id=default_commission.id,
  241. ),
  242. headers=headers,
  243. json={"user_ids": [str(u1.id)]},
  244. )
  245. response = await client.post(
  246. app.url_path_for(
  247. "add_members_to_commission",
  248. project_id=default_project.id,
  249. commission_id=default_commission.id,
  250. ),
  251. headers=headers,
  252. json={"user_ids": [str(u1.id)]},
  253. )
  254. assert response.status_code == 200
  255. assert len(response.json()["members_id"]) == 1
  256. async def test_remove_member_from_commission(
  257. self,
  258. client: AsyncClient,
  259. default_project: Project,
  260. default_commission: Commission,
  261. two_users,
  262. make_org_user,
  263. session: Session,
  264. ):
  265. u1, u2 = two_users
  266. commission = session.get(Commission, default_commission.id)
  267. assert commission is not None
  268. commission.members.append(u1)
  269. commission.members.append(u2)
  270. session.commit()
  271. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  272. response = await client.delete(
  273. app.url_path_for(
  274. "remove_member_from_commission",
  275. project_id=default_project.id,
  276. commission_id=default_commission.id,
  277. user_id=u1.id,
  278. ),
  279. headers=headers,
  280. )
  281. assert response.status_code == 200
  282. data = response.json()
  283. assert len(data["members_id"]) == 1
  284. assert data["members_id"][0] == str(u2.id)
  285. class TestInviteCommissionMember:
  286. @pytest.mark.parametrize("role", MANAGE_ROLES)
  287. async def test_creates_account_and_adds_to_commission(
  288. self,
  289. client: AsyncClient,
  290. default_project: Project,
  291. default_commission: Commission,
  292. make_org_user,
  293. session: Session,
  294. role,
  295. ):
  296. _, headers = make_org_user(role=role)
  297. response = await client.post(
  298. app.url_path_for(
  299. "invite_commission_member",
  300. project_id=default_project.id,
  301. commission_id=default_commission.id,
  302. ),
  303. headers=headers,
  304. json={
  305. "email": "lancelot@test.com",
  306. "name": "Lancelot du Lac",
  307. "password": "TempPass123!",
  308. "org_role": "respo_commission",
  309. },
  310. )
  311. assert response.status_code == 200
  312. data = response.json()
  313. user = session.scalar(select(User).where(User.email == "lancelot@test.com"))
  314. assert user is not None
  315. assert user.name == "Lancelot du Lac"
  316. assert user.must_change_password is True
  317. assert str(user.id) in data["members_id"]
  318. membership = session.get(UserOrganization, (user.id, default_project.organization_id))
  319. assert membership is not None
  320. assert membership.role == OrgRole.RESPO_COMMISSION
  321. @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
  322. async def test_forbidden_manage_roles(
  323. self,
  324. client: AsyncClient,
  325. default_project: Project,
  326. default_commission: Commission,
  327. make_org_user,
  328. role,
  329. ):
  330. _, headers = make_org_user(role=role)
  331. response = await client.post(
  332. app.url_path_for(
  333. "invite_commission_member",
  334. project_id=default_project.id,
  335. commission_id=default_commission.id,
  336. ),
  337. headers=headers,
  338. json={"email": "nope@test.com", "name": "Nope", "password": "x"},
  339. )
  340. assert response.status_code == 403
  341. async def test_existing_user_not_recreated_and_password_untouched(
  342. self,
  343. client: AsyncClient,
  344. default_project: Project,
  345. default_commission: Commission,
  346. two_users,
  347. make_org_user,
  348. session: Session,
  349. ):
  350. u1, _ = two_users
  351. original_hash = u1.hashed_password
  352. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  353. response = await client.post(
  354. app.url_path_for(
  355. "invite_commission_member",
  356. project_id=default_project.id,
  357. commission_id=default_commission.id,
  358. ),
  359. headers=headers,
  360. json={
  361. "email": u1.email,
  362. "name": "Ignored Name Change",
  363. "password": "AttackerChosenPassword!",
  364. "org_role": "respo_commission",
  365. },
  366. )
  367. assert response.status_code == 200
  368. refreshed = session.get(User, u1.id)
  369. assert refreshed.hashed_password == original_hash # untouched
  370. assert refreshed.name == "Arthur Pendragon" # untouched
  371. assert refreshed.must_change_password is False # never set on existing account
  372. assert str(u1.id) in response.json()["members_id"]
  373. async def test_existing_org_membership_role_not_overwritten(
  374. self,
  375. client: AsyncClient,
  376. default_project: Project,
  377. default_commission: Commission,
  378. two_users,
  379. make_org_user,
  380. session: Session,
  381. ):
  382. u1, _ = two_users
  383. session.add(
  384. UserOrganization(
  385. user_id=u1.id,
  386. organization_id=default_project.organization_id,
  387. role=OrgRole.RESPO_BENEVOLE,
  388. )
  389. )
  390. session.commit()
  391. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  392. response = await client.post(
  393. app.url_path_for(
  394. "invite_commission_member",
  395. project_id=default_project.id,
  396. commission_id=default_commission.id,
  397. ),
  398. headers=headers,
  399. # requesting respo_commission, but they're already respo_benevole -- must not downgrade
  400. json={
  401. "email": u1.email,
  402. "name": u1.name,
  403. "password": "x",
  404. "org_role": "respo_commission",
  405. },
  406. )
  407. assert response.status_code == 200
  408. membership = session.get(UserOrganization, (u1.id, default_project.organization_id))
  409. assert membership.role == OrgRole.RESPO_BENEVOLE # unchanged
  410. async def test_respo_benevole_cannot_grant_org_admin(
  411. self,
  412. client: AsyncClient,
  413. default_project: Project,
  414. default_commission: Commission,
  415. make_org_user,
  416. ):
  417. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  418. response = await client.post(
  419. app.url_path_for(
  420. "invite_commission_member",
  421. project_id=default_project.id,
  422. commission_id=default_commission.id,
  423. ),
  424. headers=headers,
  425. json={
  426. "email": "wouldbe@test.com",
  427. "name": "Would Be Admin",
  428. "password": "x",
  429. "org_role": "org_admin",
  430. },
  431. )
  432. assert response.status_code == 403
  433. async def test_org_admin_can_grant_org_admin(
  434. self,
  435. client: AsyncClient,
  436. default_project: Project,
  437. default_commission: Commission,
  438. make_org_user,
  439. session: Session,
  440. ):
  441. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  442. response = await client.post(
  443. app.url_path_for(
  444. "invite_commission_member",
  445. project_id=default_project.id,
  446. commission_id=default_commission.id,
  447. ),
  448. headers=headers,
  449. json={
  450. "email": "newadmin@test.com",
  451. "name": "New Admin",
  452. "password": "x",
  453. "org_role": "org_admin",
  454. },
  455. )
  456. assert response.status_code == 200
  457. user = session.scalar(select(User).where(User.email == "newadmin@test.com"))
  458. membership = session.get(UserOrganization, (user.id, default_project.organization_id))
  459. assert membership.role == OrgRole.ORG_ADMIN
  460. async def test_invite_is_idempotent_on_commission_membership(
  461. self,
  462. client: AsyncClient,
  463. default_project: Project,
  464. default_commission: Commission,
  465. make_org_user,
  466. ):
  467. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  468. payload = {
  469. "email": "guenievre@test.com",
  470. "name": "Guenièvre",
  471. "password": "x",
  472. "org_role": "respo_commission",
  473. }
  474. for _ in range(2):
  475. response = await client.post(
  476. app.url_path_for(
  477. "invite_commission_member",
  478. project_id=default_project.id,
  479. commission_id=default_commission.id,
  480. ),
  481. headers=headers,
  482. json=payload,
  483. )
  484. assert response.status_code == 200
  485. assert len(response.json()["members_id"]) == 1