test_organizations.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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 import security
  7. from app.main import app
  8. from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
  9. from app.tests.conftest import default_organization_id, default_organization_name
  10. pytestmark = pytest.mark.asyncio
  11. ALL_ORG_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
  12. class TestListOrganizations:
  13. async def test_requires_auth(self, client: AsyncClient):
  14. response = await client.get(app.url_path_for("list_organizations"))
  15. assert response.status_code == 401
  16. @pytest.mark.parametrize("role", ALL_ORG_ROLES)
  17. async def test_member_sees_own_org(
  18. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  19. ):
  20. _, headers = make_org_user(role=role)
  21. response = await client.get(app.url_path_for("list_organizations"), headers=headers)
  22. assert response.status_code == 200
  23. data = response.json()
  24. assert len(data) == 1
  25. assert data[0]["id"] == default_organization_id
  26. async def test_non_member_sees_nothing(
  27. self, client: AsyncClient, default_organization: Organization, make_org_user
  28. ):
  29. _, headers = make_org_user(role=None)
  30. response = await client.get(app.url_path_for("list_organizations"), headers=headers)
  31. assert response.status_code == 200
  32. assert response.json() == []
  33. async def test_super_admin_sees_all_orgs(
  34. self,
  35. client: AsyncClient,
  36. default_organization: Organization,
  37. make_org_user,
  38. session: Session,
  39. ):
  40. other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
  41. session.add(other_org)
  42. session.commit()
  43. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  44. response = await client.get(app.url_path_for("list_organizations"), headers=headers)
  45. assert response.status_code == 200
  46. ids = [o["id"] for o in response.json()]
  47. assert default_organization_id in ids
  48. assert other_org.id in ids
  49. class TestCreateOrganization:
  50. async def test_requires_auth(self, client: AsyncClient):
  51. response = await client.post(
  52. app.url_path_for("create_organization"), json={"name": "New Org"}
  53. )
  54. assert response.status_code == 401
  55. @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
  56. async def test_non_super_admin_forbidden(
  57. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  58. ):
  59. _, headers = make_org_user(role=role)
  60. response = await client.post(
  61. app.url_path_for("create_organization"), headers=headers, json={"name": "New Org"}
  62. )
  63. assert response.status_code == 403
  64. async def test_super_admin_creates_organization(
  65. self, client: AsyncClient, make_org_user, session: Session
  66. ):
  67. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  68. response = await client.post(
  69. app.url_path_for("create_organization"), headers=headers, json={"name": "New Org"}
  70. )
  71. assert response.status_code == 200
  72. data = response.json()
  73. assert data["name"] == "New Org"
  74. org = (
  75. session.execute(select(Organization).where(Organization.name == "New Org"))
  76. .scalars()
  77. .first()
  78. )
  79. assert org is not None
  80. async def test_duplicate_name_rejected(
  81. self, client: AsyncClient, default_organization: Organization, make_org_user
  82. ):
  83. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  84. response = await client.post(
  85. app.url_path_for("create_organization"),
  86. headers=headers,
  87. json={"name": default_organization_name},
  88. )
  89. assert response.status_code == 400
  90. async def test_missing_name_validation_error(self, client: AsyncClient, make_org_user):
  91. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  92. response = await client.post(
  93. app.url_path_for("create_organization"), headers=headers, json={}
  94. )
  95. assert response.status_code == 422
  96. class TestGetOrganization:
  97. async def test_requires_auth(self, client: AsyncClient, default_organization: Organization):
  98. response = await client.get(
  99. app.url_path_for("get_organization", organization_id=default_organization_id)
  100. )
  101. assert response.status_code == 401
  102. @pytest.mark.parametrize("role", ALL_ORG_ROLES)
  103. async def test_member_can_view(
  104. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  105. ):
  106. _, headers = make_org_user(role=role)
  107. response = await client.get(
  108. app.url_path_for("get_organization", organization_id=default_organization_id),
  109. headers=headers,
  110. )
  111. assert response.status_code == 200
  112. assert response.json()["id"] == default_organization_id
  113. async def test_non_member_forbidden(
  114. self, client: AsyncClient, default_organization: Organization, make_org_user
  115. ):
  116. _, headers = make_org_user(role=None)
  117. response = await client.get(
  118. app.url_path_for("get_organization", organization_id=default_organization_id),
  119. headers=headers,
  120. )
  121. assert response.status_code == 403
  122. async def test_super_admin_bypasses_membership(
  123. self, client: AsyncClient, default_organization: Organization, make_org_user
  124. ):
  125. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  126. response = await client.get(
  127. app.url_path_for("get_organization", organization_id=default_organization_id),
  128. headers=headers,
  129. )
  130. assert response.status_code == 200
  131. async def test_includes_memberships(
  132. self, client: AsyncClient, default_organization: Organization, make_org_user
  133. ):
  134. _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  135. response = await client.get(
  136. app.url_path_for("get_organization", organization_id=default_organization_id),
  137. headers=headers,
  138. )
  139. assert response.status_code == 200
  140. memberships = response.json()["memberships"]
  141. assert len(memberships) == 1
  142. assert memberships[0]["role"] == OrgRole.RESPO_BENEVOLE.value
  143. async def test_not_found(self, client: AsyncClient, make_org_user):
  144. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  145. response = await client.get(
  146. app.url_path_for("get_organization", organization_id=uuid.uuid4()), headers=headers
  147. )
  148. assert response.status_code == 404
  149. class TestUpdateOrganization:
  150. @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
  151. async def test_non_super_admin_forbidden(
  152. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  153. ):
  154. _, headers = make_org_user(role=role)
  155. response = await client.post(
  156. app.url_path_for("update_organization", organization_id=default_organization_id),
  157. headers=headers,
  158. json={"name": "Renamed"},
  159. )
  160. assert response.status_code == 403
  161. async def test_super_admin_renames(
  162. self,
  163. client: AsyncClient,
  164. default_organization: Organization,
  165. make_org_user,
  166. session: Session,
  167. ):
  168. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  169. response = await client.post(
  170. app.url_path_for("update_organization", organization_id=default_organization_id),
  171. headers=headers,
  172. json={"name": "Renamed"},
  173. )
  174. assert response.status_code == 200
  175. org = session.get(Organization, default_organization_id)
  176. assert org.name == "Renamed"
  177. async def test_not_found(self, client: AsyncClient, make_org_user):
  178. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  179. response = await client.post(
  180. app.url_path_for("update_organization", organization_id=uuid.uuid4()),
  181. headers=headers,
  182. json={"name": "Nope"},
  183. )
  184. assert response.status_code == 404
  185. class TestDeleteOrganization:
  186. @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
  187. async def test_non_super_admin_forbidden(
  188. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  189. ):
  190. _, headers = make_org_user(role=role)
  191. response = await client.delete(
  192. app.url_path_for("delete_organization", organization_id=default_organization_id),
  193. headers=headers,
  194. )
  195. assert response.status_code == 403
  196. async def test_super_admin_deletes_and_cascades_projects(
  197. self, client: AsyncClient, default_project, make_org_user, session: Session
  198. ):
  199. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  200. response = await client.delete(
  201. app.url_path_for("delete_organization", organization_id=default_organization_id),
  202. headers=headers,
  203. )
  204. assert response.status_code == 200
  205. assert session.get(Organization, default_organization_id) is None
  206. from app.models import Project
  207. assert session.get(Project, default_project.id) is None
  208. async def test_not_found(self, client: AsyncClient, make_org_user):
  209. _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  210. response = await client.delete(
  211. app.url_path_for("delete_organization", organization_id=uuid.uuid4()), headers=headers
  212. )
  213. assert response.status_code == 404
  214. class TestAddMember:
  215. @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
  216. async def test_non_super_admin_forbidden(
  217. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  218. ):
  219. _, headers = make_org_user(role=role)
  220. response = await client.post(
  221. app.url_path_for("add_member", organization_id=default_organization_id),
  222. headers=headers,
  223. json={"user_id": str(uuid.uuid4()), "role": OrgRole.RESPO_BENEVOLE.value},
  224. )
  225. assert response.status_code == 403
  226. async def test_super_admin_adds_existing_user(
  227. self,
  228. client: AsyncClient,
  229. default_organization: Organization,
  230. make_org_user,
  231. session: Session,
  232. ):
  233. target_user, _ = make_org_user(role=None) # exists, no membership yet
  234. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  235. response = await client.post(
  236. app.url_path_for("add_member", organization_id=default_organization_id),
  237. headers=admin_headers,
  238. json={"user_id": target_user.id, "role": OrgRole.RESPO_COMMISSION.value},
  239. )
  240. assert response.status_code == 200
  241. data = response.json()
  242. assert data["role"] == OrgRole.RESPO_COMMISSION.value
  243. assert data["user"]["email"] == target_user.email
  244. membership = session.get(UserOrganization, (target_user.id, default_organization_id))
  245. assert membership is not None
  246. assert membership.role == OrgRole.RESPO_COMMISSION
  247. async def test_user_can_belong_to_multiple_orgs(
  248. self,
  249. client: AsyncClient,
  250. default_organization: Organization,
  251. make_org_user,
  252. session: Session,
  253. ):
  254. """Confirms the multi-org membership model -- no unique(user_id) constraint."""
  255. target_user, _ = make_org_user(role=OrgRole.ORG_ADMIN) # already in default_organization
  256. other_org = Organization(id=uuid.uuid4(), name="Second Org")
  257. session.add(other_org)
  258. session.commit()
  259. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  260. response = await client.post(
  261. app.url_path_for("add_member", organization_id=other_org.id),
  262. headers=admin_headers,
  263. json={"user_id": target_user.id, "role": OrgRole.RESPO_BENEVOLE.value},
  264. )
  265. assert response.status_code == 200
  266. memberships = (
  267. session.execute(
  268. select(UserOrganization).where(UserOrganization.user_id == target_user.id)
  269. )
  270. .scalars()
  271. .all()
  272. )
  273. assert len(memberships) == 2
  274. async def test_unknown_user_id_not_found(
  275. self, client: AsyncClient, default_organization: Organization, make_org_user
  276. ):
  277. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  278. response = await client.post(
  279. app.url_path_for("add_member", organization_id=default_organization_id),
  280. headers=admin_headers,
  281. json={"user_id": str(uuid.uuid4()), "role": OrgRole.RESPO_BENEVOLE.value},
  282. )
  283. assert response.status_code == 404
  284. async def test_duplicate_membership_rejected(
  285. self, client: AsyncClient, default_organization: Organization, make_org_user
  286. ):
  287. target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE) # already a member
  288. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  289. response = await client.post(
  290. app.url_path_for("add_member", organization_id=default_organization_id),
  291. headers=admin_headers,
  292. json={"user_id": target_user.id, "role": OrgRole.ORG_ADMIN.value},
  293. )
  294. assert response.status_code == 400
  295. async def test_organization_not_found(self, client: AsyncClient, make_org_user):
  296. target_user, _ = make_org_user(role=None)
  297. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  298. response = await client.post(
  299. app.url_path_for("add_member", organization_id=uuid.uuid4()),
  300. headers=admin_headers,
  301. json={"user_id": target_user.id, "role": OrgRole.RESPO_BENEVOLE.value},
  302. )
  303. assert response.status_code == 404
  304. class TestUpdateMemberRole:
  305. @pytest.mark.parametrize("role", [OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION, None])
  306. async def test_insufficient_role_forbidden(
  307. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  308. ):
  309. target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
  310. _, headers = make_org_user(role=role)
  311. response = await client.post(
  312. app.url_path_for(
  313. "update_member_role",
  314. organization_id=default_organization_id,
  315. user_id=target_user.id,
  316. ),
  317. headers=headers,
  318. json={"role": OrgRole.ORG_ADMIN.value},
  319. )
  320. assert response.status_code == 403
  321. async def test_org_admin_of_same_org_can_change_role(
  322. self,
  323. client: AsyncClient,
  324. default_organization: Organization,
  325. make_org_user,
  326. session: Session,
  327. ):
  328. target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
  329. _, org_admin_headers = make_org_user(role=OrgRole.ORG_ADMIN)
  330. response = await client.post(
  331. app.url_path_for(
  332. "update_member_role",
  333. organization_id=default_organization_id,
  334. user_id=target_user.id,
  335. ),
  336. headers=org_admin_headers,
  337. json={"role": OrgRole.RESPO_BENEVOLE.value},
  338. )
  339. assert response.status_code == 200
  340. assert response.json()["role"] == OrgRole.RESPO_BENEVOLE.value
  341. membership = session.get(UserOrganization, (target_user.id, default_organization_id))
  342. assert membership.role == OrgRole.RESPO_BENEVOLE
  343. async def test_org_admin_of_different_org_forbidden(
  344. self,
  345. client: AsyncClient,
  346. default_organization: Organization,
  347. make_org_user,
  348. session: Session,
  349. ):
  350. """An org_admin has no authority over an organization they don't belong to."""
  351. other_org = Organization(id=uuid.uuid4(), name="Other Org")
  352. session.add(other_org)
  353. session.commit()
  354. target_user, _ = make_org_user(
  355. role=OrgRole.RESPO_COMMISSION
  356. ) # member of default_organization
  357. # org_admin, but of other_org, not default_organization
  358. outsider = User(
  359. id=uuid.uuid4(), email="outsider@test.com", hashed_password="x", name="Outsider"
  360. )
  361. session.add(outsider)
  362. session.commit()
  363. session.add(
  364. UserOrganization(
  365. user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN
  366. )
  367. )
  368. session.commit()
  369. outsider_token = security.create_jwt_token(str(outsider.id), 60 * 60 * 24, refresh=False)[0]
  370. outsider_headers = {"Authorization": f"Bearer {outsider_token}"}
  371. response = await client.post(
  372. app.url_path_for(
  373. "update_member_role",
  374. organization_id=default_organization_id,
  375. user_id=target_user.id,
  376. ),
  377. headers=outsider_headers,
  378. json={"role": OrgRole.ORG_ADMIN.value},
  379. )
  380. assert response.status_code == 403
  381. async def test_super_admin_changes_role(
  382. self,
  383. client: AsyncClient,
  384. default_organization: Organization,
  385. make_org_user,
  386. session: Session,
  387. ):
  388. target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
  389. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  390. response = await client.post(
  391. app.url_path_for(
  392. "update_member_role",
  393. organization_id=default_organization_id,
  394. user_id=target_user.id,
  395. ),
  396. headers=admin_headers,
  397. json={"role": OrgRole.ORG_ADMIN.value},
  398. )
  399. assert response.status_code == 200
  400. assert response.json()["role"] == OrgRole.ORG_ADMIN.value
  401. membership = session.get(UserOrganization, (target_user.id, default_organization_id))
  402. assert membership.role == OrgRole.ORG_ADMIN
  403. async def test_membership_not_found(
  404. self, client: AsyncClient, default_organization: Organization, make_org_user
  405. ):
  406. non_member, _ = make_org_user(role=None)
  407. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  408. response = await client.post(
  409. app.url_path_for(
  410. "update_member_role", organization_id=default_organization_id, user_id=non_member.id
  411. ),
  412. headers=admin_headers,
  413. json={"role": OrgRole.ORG_ADMIN.value},
  414. )
  415. assert response.status_code == 404
  416. class TestRemoveMember:
  417. @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
  418. async def test_non_super_admin_forbidden(
  419. self, client: AsyncClient, default_organization: Organization, make_org_user, role
  420. ):
  421. target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  422. _, headers = make_org_user(role=role)
  423. response = await client.delete(
  424. app.url_path_for(
  425. "remove_member", organization_id=default_organization_id, user_id=target_user.id
  426. ),
  427. headers=headers,
  428. )
  429. assert response.status_code == 403
  430. async def test_super_admin_removes_member(
  431. self,
  432. client: AsyncClient,
  433. default_organization: Organization,
  434. make_org_user,
  435. session: Session,
  436. ):
  437. target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
  438. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  439. response = await client.delete(
  440. app.url_path_for(
  441. "remove_member", organization_id=default_organization_id, user_id=target_user.id
  442. ),
  443. headers=admin_headers,
  444. )
  445. assert response.status_code == 200
  446. assert session.get(UserOrganization, (target_user.id, default_organization_id)) is None
  447. # user account itself is untouched
  448. assert session.get(User, target_user.id) is not None
  449. async def test_membership_not_found(
  450. self, client: AsyncClient, default_organization: Organization, make_org_user
  451. ):
  452. non_member, _ = make_org_user(role=None)
  453. _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
  454. response = await client.delete(
  455. app.url_path_for(
  456. "remove_member", organization_id=default_organization_id, user_id=non_member.id
  457. ),
  458. headers=admin_headers,
  459. )
  460. assert response.status_code == 404