test_organizations.py 20 KB

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