From a7a18b9db0331685189a00d9bcacc15b57920d3d Mon Sep 17 00:00:00 2001 From: Tessa Walsh Date: Wed, 8 Feb 2023 16:10:09 -0500 Subject: [PATCH] Add org-specific delete invite endpoint (#575) Adds POST /orgs/{oid}/invites/delete, which expects the invited email address in the POST body. This endpoint will also delete duplicate invites with the same email/oid combination if env var ALLOW_DUPE_INVITES allows dupes. --- backend/btrixcloud/invites.py | 9 +++++++ backend/btrixcloud/orgs.py | 20 +++++++++++++++ backend/btrixcloud/users.py | 5 ---- backend/test/test_org.py | 48 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index b3fc6ce9..25ed928b 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -118,6 +118,15 @@ class InviteOps: """remove invite from invite list""" await self.invites.delete_one({"_id": invite_token}) + async def remove_invite_by_email(self, email: str, oid: str = None): + """remove invite from invite list by email""" + query = {"email": email} + if oid: + query["oid"] = oid + # Use delete_many rather than delete_one to clean up any duplicate + # invites as well. + return await self.invites.delete_many(query) + def accept_user_invite(self, user, invite_token: str): """remove invite from user, if valid token, throw if not""" invite = user.invites.pop(invite_token, "") diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 6291447f..7f90ee68 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -3,6 +3,7 @@ Organization API handling """ import os import time +import urllib.parse import uuid from typing import Dict, Union, Literal, Optional @@ -39,6 +40,11 @@ class RemoveFromOrg(InviteRequest): """Remove this user from org""" +# ============================================================================ +class RemovePendingInvite(InviteRequest): + """Delete pending invite to org by email""" + + # ============================================================================ class RenameOrg(BaseModel): """Request to invite another user""" @@ -468,6 +474,20 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User): pending_invites = await user_manager.invites.get_pending_invites(org) return {"pending_invites": pending_invites} + @router.post("/invites/delete", tags=["invites"]) + async def delete_invite( + invite: RemovePendingInvite, org: Organization = Depends(org_owner_dep) + ): + # URL decode email just in case + email = urllib.parse.unquote(invite.email) + result = await user_manager.invites.remove_invite_by_email(email, org.id) + if result.deleted_count > 0: + return { + "removed": True, + "count": result.deleted_count, + } + raise HTTPException(status_code=404, detail="invite_not_found") + @router.post("/remove", tags=["invites"]) async def remove_user_from_org( remove: RemoveFromOrg, org: Organization = Depends(org_owner_dep) diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index f35f865a..2303ca34 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -483,11 +483,6 @@ def init_users_api(app, user_manager): return await user_manager.format_invite(invite) - @users_router.get("/invite-delete/{token}", tags=["invites"]) - async def delete_invite(token: str): - await user_manager.invites.remove_invite(token) - return {"removed": True} - @users_router.get("/invites", tags=["invites"]) async def get_pending_invites(user: User = Depends(current_active_user)): if not user.is_superuser: diff --git a/backend/test/test_org.py b/backend/test/test_org.py index 331a810c..af933b70 100644 --- a/backend/test/test_org.py +++ b/backend/test/test_org.py @@ -1,4 +1,5 @@ import requests +import uuid import pytest @@ -231,3 +232,50 @@ def test_send_and_accept_org_invite( user for user in users.values() if user["email"] == expected_stored_email ] assert len(users_with_invited_email) == 1 + + +def test_delete_invite_by_email(admin_auth_headers, non_default_org_id): + # Invite user to non-default org + INVITE_EMAIL = "new-non-default-org-invite-by-email@example.com" + r = requests.post( + f"{API_PREFIX}/orgs/{non_default_org_id}/invite", + headers=admin_auth_headers, + json={"email": INVITE_EMAIL, "role": 20}, + ) + assert r.status_code == 200 + data = r.json() + assert data["invited"] == "new_user" + + # Delete invite by email + r = requests.post( + f"{API_PREFIX}/orgs/{non_default_org_id}/invites/delete", + headers=admin_auth_headers, + json={"email": INVITE_EMAIL}, + ) + assert r.status_code == 200 + data = r.json() + assert data["removed"] + assert data["count"] == 1 + + # Verify invite no longer exists + r = requests.get( + f"{API_PREFIX}/orgs/{non_default_org_id}/invites", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + invites_matching_email = [ + invite for invite in data["pending_invites"] if invite["email"] == INVITE_EMAIL + ] + assert len(invites_matching_email) == 0 + + # Try to delete non-existent email and test we get 404 + bad_token = str(uuid.uuid4()) + r = requests.post( + f"{API_PREFIX}/orgs/{non_default_org_id}/invites/delete", + headers=admin_auth_headers, + json={"email": "not-a-valid-invite@example.com"}, + ) + assert r.status_code == 404 + data = r.json() + assert data["detail"] == "invite_not_found"