From 65d28b788f85039853a21e45d66d2dc85a791b47 Mon Sep 17 00:00:00 2001 From: Blair Lunceford <74257063+blairlunceford@users.noreply.github.com> Date: Thu, 23 May 2024 12:56:54 -0600 Subject: [PATCH] Add methods and events for email verification and password reset --- tests/test_user_management.py | 55 ++++++++++++- .../utils/fixtures/mock_email_verification.py | 23 ++++++ tests/utils/fixtures/mock_invitation.py | 4 +- tests/utils/fixtures/mock_password_reset.py | 25 ++++++ workos/resources/user_management.py | 37 +++++++++ workos/user_management.py | 80 +++++++++++++++++++ 6 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 tests/utils/fixtures/mock_email_verification.py create mode 100644 tests/utils/fixtures/mock_password_reset.py diff --git a/tests/test_user_management.py b/tests/test_user_management.py index ece6b668..cc135f55 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -4,9 +4,11 @@ import workos from tests.utils.fixtures.mock_auth_factor_totp import MockAuthFactorTotp +from tests.utils.fixtures.mock_email_verification import MockEmailVerification from tests.utils.fixtures.mock_invitation import MockInvitation from tests.utils.fixtures.mock_magic_auth import MockMagicAuth from tests.utils.fixtures.mock_organization_membership import MockOrganizationMembership +from tests.utils.fixtures.mock_password_reset import MockPasswordReset from tests.utils.fixtures.mock_session import MockSession from tests.utils.fixtures.mock_user import MockUser from workos.user_management import UserManagement @@ -213,10 +215,18 @@ def mock_auth_factors(self): } return dict_response + @pytest.fixture + def mock_email_verification(self): + return MockEmailVerification("email_verification_ABCDE").to_dict() + @pytest.fixture def mock_magic_auth(self): return MockMagicAuth("magic_auth_ABCDE").to_dict() + @pytest.fixture + def mock_password_reset(self): + return MockPasswordReset("password_reset_ABCDE").to_dict() + @pytest.fixture def mock_invitation(self): return MockInvitation("invitation_ABCDE").to_dict() @@ -869,16 +879,37 @@ def test_get_logout_url(self): assert expected == result + def test_get_password_reset(self, mock_password_reset, capture_and_mock_request): + url, request_kwargs = capture_and_mock_request("get", mock_password_reset, 200) + + password_reset = self.user_management.get_password_reset("password_reset_ABCDE") + + assert url[0].endswith("user_management/password_reset/password_reset_ABCDE") + assert password_reset["id"] == "password_reset_ABCDE" + + def test_create_password_reset(self, capture_and_mock_request, mock_password_reset): + email = "marcelina@foo-corp.com" + url, _ = capture_and_mock_request("post", mock_password_reset, 201) + + password_reset = self.user_management.create_password_reset(email=email) + + assert url[0].endswith("user_management/password_reset") + assert password_reset["email"] == email + def test_send_password_reset_email(self, capture_and_mock_request): email = "marcelina@foo-corp.com" password_reset_url = "https://foo-corp.com/reset-password" url, request = capture_and_mock_request("post", None, 200) - response = self.user_management.send_password_reset_email( - email=email, - password_reset_url=password_reset_url, - ) + with pytest.warns( + DeprecationWarning, + match="'send_password_reset_email' is deprecated. Please use 'create_password_reset' instead. This method will be removed in a future major version.", + ): + response = self.user_management.send_password_reset_email( + email=email, + password_reset_url=password_reset_url, + ) assert url[0].endswith("user_management/password_reset/send") assert request["json"]["email"] == email @@ -901,6 +932,22 @@ def test_reset_password(self, capture_and_mock_request, mock_user): assert request["json"]["token"] == token assert request["json"]["new_password"] == new_password + def test_get_email_verification( + self, mock_email_verification, capture_and_mock_request + ): + url, request_kwargs = capture_and_mock_request( + "get", mock_email_verification, 200 + ) + + email_verification = self.user_management.get_email_verification( + "email_verification_ABCDE" + ) + + assert url[0].endswith( + "user_management/email_verification/email_verification_ABCDE" + ) + assert email_verification["id"] == "email_verification_ABCDE" + def test_send_verification_email(self, capture_and_mock_request, mock_user): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" diff --git a/tests/utils/fixtures/mock_email_verification.py b/tests/utils/fixtures/mock_email_verification.py new file mode 100644 index 00000000..35b68e09 --- /dev/null +++ b/tests/utils/fixtures/mock_email_verification.py @@ -0,0 +1,23 @@ +import datetime +from workos.resources.base import WorkOSBaseResource + + +class MockEmailVerification(WorkOSBaseResource): + def __init__(self, id): + self.id = id + self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" + self.email = "marcelina@foo-corp.com" + self.expires_at = datetime.datetime.now() + self.code = "123456" + self.created_at = datetime.datetime.now() + self.updated_at = datetime.datetime.now() + + OBJECT_FIELDS = [ + "id", + "user_id", + "email", + "expires_at", + "code", + "created_at", + "updated_at", + ] diff --git a/tests/utils/fixtures/mock_invitation.py b/tests/utils/fixtures/mock_invitation.py index b0397dd7..7e24533e 100644 --- a/tests/utils/fixtures/mock_invitation.py +++ b/tests/utils/fixtures/mock_invitation.py @@ -12,9 +12,10 @@ def __init__(self, id): self.expires_at = datetime.datetime.now() self.token = "Z1uX3RbwcIl5fIGJJJCXXisdI" self.accept_invitation_url = ( - "https://myauthkit.com/invite?invitation_token=Z1uX3RbwcIl5fIGJJJCXXisdI" + "https://your-app.com/invite?invitation_token=Z1uX3RbwcIl5fIGJJJCXXisdI" ) self.organization_id = "org_12345" + self.inviter_user_id = "user_123" self.created_at = datetime.datetime.now() self.updated_at = datetime.datetime.now() @@ -28,6 +29,7 @@ def __init__(self, id): "token", "accept_invitation_url", "organization_id", + "inviter_user_id", "created_at", "updated_at", ] diff --git a/tests/utils/fixtures/mock_password_reset.py b/tests/utils/fixtures/mock_password_reset.py new file mode 100644 index 00000000..12280966 --- /dev/null +++ b/tests/utils/fixtures/mock_password_reset.py @@ -0,0 +1,25 @@ +import datetime +from workos.resources.base import WorkOSBaseResource + + +class MockPasswordReset(WorkOSBaseResource): + def __init__(self, id): + self.id = id + self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" + self.email = "marcelina@foo-corp.com" + self.password_reset_token = "Z1uX3RbwcIl5fIGJJJCXXisdI" + self.password_reset_url = ( + "https://your-app.com/reset-password?token=Z1uX3RbwcIl5fIGJJJCXXisdI" + ) + self.expires_at = datetime.datetime.now() + self.created_at = datetime.datetime.now() + + OBJECT_FIELDS = [ + "id", + "user_id", + "email", + "password_reset_token", + "password_reset_url", + "expires_at", + "created_at", + ] diff --git a/workos/resources/user_management.py b/workos/resources/user_management.py index eda63b86..1d4d15f8 100644 --- a/workos/resources/user_management.py +++ b/workos/resources/user_management.py @@ -75,6 +75,24 @@ def to_dict(self): return authentication_response_dict +class WorkOSEmailVerification(WorkOSBaseResource): + """Representation of a EmailVerification object as returned by WorkOS through User Management features. + + Attributes: + OBJECT_FIELDS (list): List of fields a WorkOSEmailVerification comprises. + """ + + OBJECT_FIELDS = [ + "id", + "user_id", + "email", + "expires_at", + "code", + "created_at", + "updated_at", + ] + + class WorkOSInvitation(WorkOSBaseResource): """Representation of an Invitation as returned by WorkOS through User Management features. @@ -92,6 +110,7 @@ class WorkOSInvitation(WorkOSBaseResource): "token", "accept_invitation_url", "organization_id", + "inviter_user_id", "created_at", "updated_at", ] @@ -115,6 +134,24 @@ class WorkOSMagicAuth(WorkOSBaseResource): ] +class WorkOSPasswordReset(WorkOSBaseResource): + """Representation of a PasswordReset object as returned by WorkOS through User Management features. + + Attributes: + OBJECT_FIELDS (list): List of fields a WorkOSPasswordReset comprises. + """ + + OBJECT_FIELDS = [ + "id", + "user_id", + "email", + "password_reset_token", + "password_reset_url", + "expires_at", + "created_at", + ] + + class WorkOSOrganizationMembership(WorkOSBaseResource): """Representation of an Organization Membership as returned by WorkOS through User Management features. diff --git a/workos/user_management.py b/workos/user_management.py index e00a2bba..c93c6424 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -6,8 +6,10 @@ from workos.resources.user_management import ( WorkOSAuthenticationResponse, WorkOSRefreshTokenAuthenticationResponse, + WorkOSEmailVerification, WorkOSInvitation, WorkOSMagicAuth, + WorkOSPasswordReset, WorkOSOrganizationMembership, WorkOSPasswordChallengeResponse, WorkOSUser, @@ -44,9 +46,12 @@ MAGIC_AUTH_PATH = "user_management/magic_auth" USER_SEND_MAGIC_AUTH_PATH = "user_management/magic_auth/send" USER_AUTH_FACTORS_PATH = "user_management/users/{0}/auth_factors" +EMAIL_VERIFICATION_DETAIL_PATH = "user_management/email_verification/{0}" INVITATION_PATH = "user_management/invitations" INVITATION_DETAIL_PATH = "user_management/invitations/{0}" INVITATION_REVOKE_PATH = "user_management/invitations/{0}/revoke" +PASSWORD_RESET_PATH = "user_management/password_reset" +PASSWORD_RESET_DETAIL_PATH = "user_management/password_reset/{0}" RESPONSE_LIMIT = 10 @@ -829,6 +834,54 @@ def get_logout_url(self, session_id): session_id, ) + def get_password_reset(self, password_reset_id): + """Get the details of a password reset object. + + Args: + password_reset_id (str) - The unique ID of the password reset object. + + Returns: + dict: PasswordReset response from WorkOS. + """ + headers = {} + + response = self.request_helper.request( + PASSWORD_RESET_DETAIL_PATH.format(password_reset_id), + method=REQUEST_METHOD_GET, + headers=headers, + token=workos.api_key, + ) + + return WorkOSPasswordReset.construct_from_response(response).to_dict() + + def create_password_reset( + self, + email, + ): + """Creates a password reset token that can be sent to a user's email to reset the password. + + Args: + email: The email address of the user. + + Returns: + dict: PasswordReset response from WorkOS. + """ + headers = {} + + params = { + "email": email, + } + + response = self.request_helper.request( + PASSWORD_RESET_PATH, + method=REQUEST_METHOD_POST, + params=params, + headers=headers, + token=workos.api_key, + ) + + return WorkOSPasswordReset.construct_from_response(response).to_dict() + def send_password_reset_email( self, email, @@ -836,11 +889,18 @@ def send_password_reset_email( ): """Sends a password reset email to a user. + Deprecated: Please use `create_password_reset` instead. This method will be removed in a future major version. + Kwargs: email (str): The email of the user that wishes to reset their password. password_reset_url (str): The URL that will be linked to in the email. """ + warn( + "'send_password_reset_email' is deprecated. Please use 'create_password_reset' instead. This method will be removed in a future major version.", + DeprecationWarning, + ) + headers = {} payload = { @@ -888,6 +948,26 @@ def reset_password( return WorkOSUser.construct_from_response(response["user"]).to_dict() + def get_email_verification(self, email_verification_id): + """Get the details of an email verification object. + + Args: + email_verificationh_id (str) - The unique ID of the email verification object. + + Returns: + dict: EmailVerification response from WorkOS. + """ + headers = {} + + response = self.request_helper.request( + EMAIL_VERIFICATION_DETAIL_PATH.format(email_verification_id), + method=REQUEST_METHOD_GET, + headers=headers, + token=workos.api_key, + ) + + return WorkOSEmailVerification.construct_from_response(response).to_dict() + def send_verification_email( self, user_id,