| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- import uuid
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.orm import Session
- from app.core.session import session as session_maker
- from app.main import app
- from app.models import Commission, Organization, OrgRole, Project, User, UserOrganization
- from app.tests.shared_access import SharedProjectAccessTests
- pytestmark = pytest.mark.asyncio
- MANAGE_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE]
- FORBIDDEN_MANAGE_ROLES = [OrgRole.RESPO_COMMISSION]
- READ_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
- @pytest.fixture
- def two_users() -> tuple[User, User]:
- with session_maker() as db:
- u1 = User(
- email="u1@test.com",
- hashed_password="hashed1",
- name="Arthur Pendragon",
- phone_number="0601010101",
- )
- u2 = User(
- email="u2@test.com",
- hashed_password="hashed2",
- name="Merlin Enchanteur",
- phone_number="0602020202",
- )
- db.add_all([u1, u2])
- db.commit()
- db.refresh(u1)
- db.refresh(u2)
- return u1, u2
- @pytest.fixture
- def default_commission(default_project: Project) -> Commission:
- with session_maker() as db:
- commission = Commission(project_id=default_project.id, name="Logistique")
- db.add(commission)
- db.commit()
- db.refresh(commission)
- return commission
- route_kwarg = {"project_id": "PROJECT", "commission_id": "COMMISSION"}
- COMMISSION_ROUTES = [
- ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
- ("POST", "create_commission", {"project_id": "PROJECT"}, {"name": "Test"}),
- ("GET", "get_commission", route_kwarg, None),
- ("POST", "update_commission", route_kwarg, {"name": "Updated"}),
- ("DELETE", "delete_commission", route_kwarg, None),
- ("POST", "add_members_to_commission", route_kwarg, {"user_ids": ["USER"]}),
- ("DELETE", "remove_member_from_commission", {**route_kwarg, "user_id": "USER"}, None),
- (
- "POST",
- "invite_commission_member",
- route_kwarg,
- {"email": "crosscut@test.com", "name": "Cross Cut", "password": "x"},
- ),
- ]
- class TestCommissionCrossCutting(SharedProjectAccessTests):
- @pytest.fixture(params=COMMISSION_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
- def resolved_route(self, request, default_project, default_commission):
- method, endpoint, kwargs, payload = request.param
- resolved_kwargs = {
- k: (
- default_project.id
- if v == "PROJECT"
- else default_commission.id
- if v == "COMMISSION"
- else v
- )
- for k, v in kwargs.items()
- }
- return method, endpoint, resolved_kwargs, payload
- class TestListCommissions:
- @pytest.mark.parametrize("role", READ_ROLES)
- async def test_role_access(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- role,
- ):
- _, headers = make_org_user(role=role)
- response = await client.get(
- app.url_path_for("list_project_commissions", project_id=default_project.id),
- headers=headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data) == 1
- assert data[0]["name"] == "Logistique"
- class TestCreateCommission:
- @pytest.mark.parametrize("role", MANAGE_ROLES)
- async def test_role_access(
- self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
- ):
- _, headers = make_org_user(role=role)
- response = await client.post(
- app.url_path_for("create_commission", project_id=default_project.id),
- headers=headers,
- json={"name": "Accueil"},
- )
- assert response.status_code == 200
- commission = (
- session.execute(select(Commission).where(Commission.name == "Accueil"))
- .scalars()
- .first()
- )
- assert commission is not None
- assert commission.project_id == str(default_project.id)
- @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
- async def test_forbidden_manage_roles(
- self, client: AsyncClient, default_project: Project, make_org_user, role
- ):
- _, headers = make_org_user(role=role)
- response = await client.post(
- app.url_path_for("create_commission", project_id=default_project.id),
- headers=headers,
- json={"name": "Nope"},
- )
- assert response.status_code == 403
- class TestGetCommission:
- async def test_commission_not_found(
- self, client: AsyncClient, make_org_user, default_project: Project
- ):
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.get(
- app.url_path_for(
- "get_commission", project_id=default_project.id, commission_id=uuid.uuid4()
- ),
- headers=headers,
- )
- assert response.status_code == 404
- async def test_commission_from_other_project_not_found(
- self, client: AsyncClient, default_commission: Commission, make_org_user, session: Session
- ):
- other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
- session.add(other_org)
- session.commit()
- other_project = Project(name="Other Project", is_public=False, organization_id=other_org.id)
- session.add(other_project)
- session.commit()
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.get(
- app.url_path_for(
- "get_commission", project_id=other_project.id, commission_id=default_commission.id
- ),
- headers=headers,
- )
- # Even if they have the org admin role, the project_id URL mismatch means 403 or 404
- assert response.status_code in (403, 404)
- class TestUpdateCommission:
- async def test_updates_name(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- session: Session,
- ):
- _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
- response = await client.post(
- app.url_path_for(
- "update_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={"name": "Securite Renamed"},
- )
- assert response.status_code == 200
- commission = session.get(Commission, default_commission.id)
- assert commission.name == "Securite Renamed"
- class TestDeleteCommission:
- async def test_deletes_commission_and_members_links_but_not_users(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- session: Session,
- ):
- u1, _ = two_users
- commission = session.get(Commission, default_commission.id)
- commission.members.append(u1)
- session.commit()
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.delete(
- app.url_path_for(
- "delete_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- )
- assert response.status_code == 200
- assert session.get(Commission, default_commission.id) is None
- # User should still exist
- assert session.get(User, u1.id) is not None
- class TestCommissionMembership:
- async def test_add_users_to_commission(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- ):
- u1, u2 = two_users
- _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
- response = await client.post(
- app.url_path_for(
- "add_members_to_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={"user_ids": [str(u1.id), str(u2.id)]},
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["members_id"]) == 2
- assert str(u1.id) in data["members_id"]
- assert str(u2.id) in data["members_id"]
- async def test_add_is_idempotent(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- ):
- u1, _ = two_users
- _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
- await client.post(
- app.url_path_for(
- "add_members_to_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={"user_ids": [str(u1.id)]},
- )
- response = await client.post(
- app.url_path_for(
- "add_members_to_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={"user_ids": [str(u1.id)]},
- )
- assert response.status_code == 200
- assert len(response.json()["members_id"]) == 1
- async def test_remove_member_from_commission(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- session: Session,
- ):
- u1, u2 = two_users
- commission = session.get(Commission, default_commission.id)
- assert commission is not None
- commission.members.append(u1)
- commission.members.append(u2)
- session.commit()
- _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
- response = await client.delete(
- app.url_path_for(
- "remove_member_from_commission",
- project_id=default_project.id,
- commission_id=default_commission.id,
- user_id=u1.id,
- ),
- headers=headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["members_id"]) == 1
- assert data["members_id"][0] == str(u2.id)
- class TestInviteCommissionMember:
- @pytest.mark.parametrize("role", MANAGE_ROLES)
- async def test_creates_account_and_adds_to_commission(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- session: Session,
- role,
- ):
- _, headers = make_org_user(role=role)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={
- "email": "lancelot@test.com",
- "name": "Lancelot du Lac",
- "password": "TempPass123!",
- "org_role": "respo_commission",
- },
- )
- assert response.status_code == 200
- data = response.json()
- user = session.scalar(select(User).where(User.email == "lancelot@test.com"))
- assert user is not None
- assert user.name == "Lancelot du Lac"
- assert user.must_change_password is True
- assert str(user.id) in data["members_id"]
- membership = session.get(UserOrganization, (user.id, default_project.organization_id))
- assert membership is not None
- assert membership.role == OrgRole.RESPO_COMMISSION
- @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
- async def test_forbidden_manage_roles(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- role,
- ):
- _, headers = make_org_user(role=role)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={"email": "nope@test.com", "name": "Nope", "password": "x"},
- )
- assert response.status_code == 403
- async def test_existing_user_not_recreated_and_password_untouched(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- session: Session,
- ):
- u1, _ = two_users
- original_hash = u1.hashed_password
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={
- "email": u1.email,
- "name": "Ignored Name Change",
- "password": "AttackerChosenPassword!",
- "org_role": "respo_commission",
- },
- )
- assert response.status_code == 200
- refreshed = session.get(User, u1.id)
- assert refreshed.hashed_password == original_hash # untouched
- assert refreshed.name == "Arthur Pendragon" # untouched
- assert refreshed.must_change_password is False # never set on existing account
- assert str(u1.id) in response.json()["members_id"]
- async def test_existing_org_membership_role_not_overwritten(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- two_users,
- make_org_user,
- session: Session,
- ):
- u1, _ = two_users
- session.add(
- UserOrganization(
- user_id=u1.id,
- organization_id=default_project.organization_id,
- role=OrgRole.RESPO_BENEVOLE,
- )
- )
- session.commit()
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- # requesting respo_commission, but they're already respo_benevole -- must not downgrade
- json={
- "email": u1.email,
- "name": u1.name,
- "password": "x",
- "org_role": "respo_commission",
- },
- )
- assert response.status_code == 200
- membership = session.get(UserOrganization, (u1.id, default_project.organization_id))
- assert membership.role == OrgRole.RESPO_BENEVOLE # unchanged
- async def test_respo_benevole_cannot_grant_org_admin(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- ):
- _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={
- "email": "wouldbe@test.com",
- "name": "Would Be Admin",
- "password": "x",
- "org_role": "org_admin",
- },
- )
- assert response.status_code == 403
- async def test_org_admin_can_grant_org_admin(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- session: Session,
- ):
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json={
- "email": "newadmin@test.com",
- "name": "New Admin",
- "password": "x",
- "org_role": "org_admin",
- },
- )
- assert response.status_code == 200
- user = session.scalar(select(User).where(User.email == "newadmin@test.com"))
- membership = session.get(UserOrganization, (user.id, default_project.organization_id))
- assert membership.role == OrgRole.ORG_ADMIN
- async def test_invite_is_idempotent_on_commission_membership(
- self,
- client: AsyncClient,
- default_project: Project,
- default_commission: Commission,
- make_org_user,
- ):
- _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
- payload = {
- "email": "guenievre@test.com",
- "name": "Guenièvre",
- "password": "x",
- "org_role": "respo_commission",
- }
- for _ in range(2):
- response = await client.post(
- app.url_path_for(
- "invite_commission_member",
- project_id=default_project.id,
- commission_id=default_commission.id,
- ),
- headers=headers,
- json=payload,
- )
- assert response.status_code == 200
- assert len(response.json()["members_id"]) == 1
|