From 076ac32641e1b000ba92ca8fcffdd7744ba8c536 Mon Sep 17 00:00:00 2001 From: Paolo D'Amico Date: Wed, 31 Mar 2021 13:33:29 -0700 Subject: [PATCH] refactor signup routes out of organization --- posthog/api/organization.py | 232 +-------- posthog/api/signup.py | 230 +++++++++ posthog/api/test/test_organization.py | 692 +------------------------- posthog/api/test/test_signup.py | 692 ++++++++++++++++++++++++++ posthog/urls.py | 7 +- 5 files changed, 935 insertions(+), 918 deletions(-) create mode 100644 posthog/api/signup.py create mode 100644 posthog/api/test/test_signup.py diff --git a/posthog/api/organization.py b/posthog/api/organization.py index 6e7c9a1c06f..0e9b3d0575f 100644 --- a/posthog/api/organization.py +++ b/posthog/api/organization.py @@ -1,32 +1,22 @@ -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union -import posthoganalytics from django.conf import settings -from django.contrib.auth import login, password_validation -from django.core.exceptions import ValidationError -from django.db import transaction from django.db.models import Model, QuerySet from django.shortcuts import get_object_or_404 -from django.urls.base import reverse -from rest_framework import exceptions, generics, permissions, response, serializers, validators, viewsets +from rest_framework import exceptions, permissions, response, serializers, viewsets from rest_framework.request import Request from posthog.api.routing import StructuredViewSetMixin -from posthog.api.user import UserSerializer -from posthog.demo import create_demo_team -from posthog.event_usage import report_onboarding_completed, report_user_joined_organization, report_user_signed_up +from posthog.event_usage import report_onboarding_completed from posthog.mixins import AnalyticsDestroyModelMixin -from posthog.models import Organization, Team, User -from posthog.models.organization import OrganizationInvite, OrganizationMembership +from posthog.models import Organization +from posthog.models.organization import OrganizationMembership from posthog.permissions import ( CREATE_METHODS, OrganizationAdminWritePermissions, OrganizationMemberPermissions, - UninitiatedOrCloudOnly, extract_organization, ) -from posthog.tasks import user_identify -from posthog.utils import mask_email_address class PremiumMultiorganizationPermissions(permissions.BasePermission): @@ -161,218 +151,6 @@ class OrganizationViewSet(AnalyticsDestroyModelMixin, viewsets.ModelViewSet): return organization -class OrganizationSignupSerializer(serializers.Serializer): - first_name: serializers.Field = serializers.CharField(max_length=128) - email: serializers.Field = serializers.EmailField( - validators=[ - validators.UniqueValidator( - queryset=User.objects.all(), message="There is already an account with this email address." - ) - ] - ) - password: serializers.Field = serializers.CharField(allow_null=True) - organization_name: serializers.Field = serializers.CharField(max_length=128, required=False, allow_blank=True) - email_opt_in: serializers.Field = serializers.BooleanField(default=True) - - def validate_password(self, value): - if value is not None: - password_validation.validate_password(value) - return value - - def create(self, validated_data, **kwargs): - is_instance_first_user: bool = not User.objects.exists() - - organization_name = validated_data.pop("organization_name", validated_data["first_name"]) - - self._organization, self._team, self._user = User.objects.bootstrap( - organization_name=organization_name, create_team=self.create_team, **validated_data, - ) - user = self._user - - # Temp (due to FF-release [`new-onboarding-2822`]): Activate the setup/onboarding process if applicable - if self.enable_new_onboarding(user): - self._organization.setup_section_2_completed = False - self._organization.save() - - login( - self.context["request"], user, backend="django.contrib.auth.backends.ModelBackend", - ) - - report_user_signed_up( - user.distinct_id, - is_instance_first_user=is_instance_first_user, - is_organization_first_user=True, - new_onboarding_enabled=(not self._organization.setup_section_2_completed), - backend_processor="OrganizationSignupSerializer", - ) - - return user - - def create_team(self, organization: Organization, user: User) -> Team: - if self.enable_new_onboarding(user): - return create_demo_team(user=user, organization=organization, request=self.context["request"]) - else: - return Team.objects.create_with_data(user=user, organization=organization) - - def to_representation(self, instance) -> Dict: - data = UserSerializer(instance=instance).data - data["redirect_url"] = "/personalization" if self.enable_new_onboarding() else "/ingestion" - return data - - def enable_new_onboarding(self, user: Optional[User] = None) -> bool: - if user is None: - user = self._user - return posthoganalytics.feature_enabled("new-onboarding-2822", user.distinct_id) or settings.DEBUG - - -class OrganizationSocialSignupSerializer(serializers.Serializer): - """ - Signup serializer when the account is created using social authentication. - Pre-processes information not obtained from SSO provider to create organization. - """ - - organization_name: serializers.Field = serializers.CharField(max_length=128) - email_opt_in: serializers.Field = serializers.BooleanField(default=True) - - def create(self, validated_data, **kwargs): - request = self.context["request"] - - if not request.session.get("backend"): - raise serializers.ValidationError( - "Inactive social login session. Go to /login and log in before continuing.", - ) - - request.session["organization_name"] = validated_data["organization_name"] - request.session["email_opt_in"] = validated_data["email_opt_in"] - request.session.set_expiry(3600) # 1 hour to complete process - return {"continue_url": reverse("social:complete", args=[request.session["backend"]])} - - def to_representation(self, instance: Any) -> Any: - return self.instance - - -class OrganizationSignupViewset(generics.CreateAPIView): - serializer_class = OrganizationSignupSerializer - # Enables E2E testing of signup flow - permission_classes = (permissions.AllowAny,) if settings.E2E_TESTING else (UninitiatedOrCloudOnly,) - - -class OrganizationSocialSignupViewset(generics.CreateAPIView): - serializer_class = OrganizationSocialSignupSerializer - permission_classes = (UninitiatedOrCloudOnly,) - - -class OrganizationInviteSignupSerializer(serializers.Serializer): - first_name: serializers.Field = serializers.CharField(max_length=128, required=False) - password: serializers.Field = serializers.CharField(required=False) - email_opt_in: serializers.Field = serializers.BooleanField(default=True) - - def validate_password(self, value): - password_validation.validate_password(value) - return value - - def to_representation(self, instance): - serializer = UserSerializer(instance=instance) - return serializer.data - - def validate(self, data: Dict[str, Any]) -> Dict[str, Any]: - - if "request" not in self.context or not self.context["request"].user.is_authenticated: - # If there's no authenticated user and we're creating a new one, attributes are required. - - for attr in ["first_name", "password"]: - if not data.get(attr): - raise serializers.ValidationError({attr: "This field is required."}, code="required") - - return data - - def create(self, validated_data, **kwargs): - if "view" not in self.context or not self.context["view"].kwargs.get("invite_id"): - raise serializers.ValidationError("Please provide an invite ID to continue.") - - user: Optional[User] = None - is_new_user: bool = False - - if self.context["request"].user.is_authenticated: - user = cast(User, self.context["request"].user) - - invite_id = self.context["view"].kwargs.get("invite_id") - - try: - invite: OrganizationInvite = OrganizationInvite.objects.select_related("organization").get(id=invite_id) - except (OrganizationInvite.DoesNotExist): - raise serializers.ValidationError("The provided invite ID is not valid.") - - with transaction.atomic(): - if not user: - is_new_user = True - user = User.objects.create_user( - invite.target_email, - validated_data.pop("password"), - validated_data.pop("first_name"), - **validated_data, - ) - - try: - invite.use(user) - except ValueError as e: - raise serializers.ValidationError(str(e)) - - if is_new_user: - login( - self.context["request"], user, backend="django.contrib.auth.backends.ModelBackend", - ) - - report_user_signed_up( - user.distinct_id, - is_instance_first_user=False, - is_organization_first_user=False, - new_onboarding_enabled=(not invite.organization.setup_section_2_completed), - backend_processor="OrganizationInviteSignupSerializer", - ) - - else: - report_user_joined_organization(organization=invite.organization, current_user=user) - - # Update user props - user_identify.identify_task.delay(user_id=user.id) - - return user - - -class OrganizationInviteSignupViewset(generics.CreateAPIView): - serializer_class = OrganizationInviteSignupSerializer - permission_classes = (permissions.AllowAny,) - - def get(self, request, *args, **kwargs): - """ - Pre-validates an invite code. - """ - - invite_id = kwargs.get("invite_id") - - if not invite_id: - raise exceptions.ValidationError("Please provide an invite ID to continue.") - - try: - invite: OrganizationInvite = OrganizationInvite.objects.get(id=invite_id) - except (OrganizationInvite.DoesNotExist, ValidationError): - raise serializers.ValidationError("The provided invite ID is not valid.") - - user = request.user if request.user.is_authenticated else None - - invite.validate(user=user) - - return response.Response( - { - "id": str(invite.id), - "target_email": mask_email_address(invite.target_email), - "first_name": invite.first_name, - "organization_name": invite.organization.name, - } - ) - - class OrganizationOnboardingViewset(StructuredViewSetMixin, viewsets.GenericViewSet): serializer_class = OrganizationSerializer diff --git a/posthog/api/signup.py b/posthog/api/signup.py new file mode 100644 index 00000000000..dadf46f2acd --- /dev/null +++ b/posthog/api/signup.py @@ -0,0 +1,230 @@ +from typing import Any, Dict, Optional, cast + +import posthoganalytics +from django.conf import settings +from django.contrib.auth import login, password_validation +from django.core.exceptions import ValidationError +from django.db import transaction +from django.urls.base import reverse +from rest_framework import exceptions, generics, permissions, response, serializers, validators + +from posthog.api.user import UserSerializer +from posthog.demo import create_demo_team +from posthog.event_usage import report_user_joined_organization, report_user_signed_up +from posthog.models import Organization, Team, User +from posthog.models.organization import OrganizationInvite +from posthog.permissions import UninitiatedOrCloudOnly +from posthog.tasks import user_identify +from posthog.utils import mask_email_address + + +class SignupSerializer(serializers.Serializer): + first_name: serializers.Field = serializers.CharField(max_length=128) + email: serializers.Field = serializers.EmailField( + validators=[ + validators.UniqueValidator( + queryset=User.objects.all(), message="There is already an account with this email address." + ) + ] + ) + password: serializers.Field = serializers.CharField(allow_null=True) + organization_name: serializers.Field = serializers.CharField(max_length=128, required=False, allow_blank=True) + email_opt_in: serializers.Field = serializers.BooleanField(default=True) + + def validate_password(self, value): + if value is not None: + password_validation.validate_password(value) + return value + + def create(self, validated_data, **kwargs): + is_instance_first_user: bool = not User.objects.exists() + + organization_name = validated_data.pop("organization_name", validated_data["first_name"]) + + self._organization, self._team, self._user = User.objects.bootstrap( + organization_name=organization_name, create_team=self.create_team, **validated_data, + ) + user = self._user + + # Temp (due to FF-release [`new-onboarding-2822`]): Activate the setup/onboarding process if applicable + if self.enable_new_onboarding(user): + self._organization.setup_section_2_completed = False + self._organization.save() + + login( + self.context["request"], user, backend="django.contrib.auth.backends.ModelBackend", + ) + + report_user_signed_up( + user.distinct_id, + is_instance_first_user=is_instance_first_user, + is_organization_first_user=True, + new_onboarding_enabled=(not self._organization.setup_section_2_completed), + backend_processor="OrganizationSignupSerializer", + ) + + return user + + def create_team(self, organization: Organization, user: User) -> Team: + if self.enable_new_onboarding(user): + return create_demo_team(user=user, organization=organization, request=self.context["request"]) + else: + return Team.objects.create_with_data(user=user, organization=organization) + + def to_representation(self, instance) -> Dict: + data = UserSerializer(instance=instance).data + data["redirect_url"] = "/personalization" if self.enable_new_onboarding() else "/ingestion" + return data + + def enable_new_onboarding(self, user: Optional[User] = None) -> bool: + if user is None: + user = self._user + return posthoganalytics.feature_enabled("new-onboarding-2822", user.distinct_id) or settings.DEBUG + + +class SocialSignupSerializer(serializers.Serializer): + """ + Signup serializer when the account is created using social authentication. + Pre-processes information not obtained from SSO provider to create organization. + """ + + organization_name: serializers.Field = serializers.CharField(max_length=128) + email_opt_in: serializers.Field = serializers.BooleanField(default=True) + + def create(self, validated_data, **kwargs): + request = self.context["request"] + + if not request.session.get("backend"): + raise serializers.ValidationError( + "Inactive social login session. Go to /login and log in before continuing.", + ) + + request.session["organization_name"] = validated_data["organization_name"] + request.session["email_opt_in"] = validated_data["email_opt_in"] + request.session.set_expiry(3600) # 1 hour to complete process + return {"continue_url": reverse("social:complete", args=[request.session["backend"]])} + + def to_representation(self, instance: Any) -> Any: + return self.instance + + +class InviteSignupSerializer(serializers.Serializer): + first_name: serializers.Field = serializers.CharField(max_length=128, required=False) + password: serializers.Field = serializers.CharField(required=False) + email_opt_in: serializers.Field = serializers.BooleanField(default=True) + + def validate_password(self, value): + password_validation.validate_password(value) + return value + + def to_representation(self, instance): + serializer = UserSerializer(instance=instance) + return serializer.data + + def validate(self, data: Dict[str, Any]) -> Dict[str, Any]: + + if "request" not in self.context or not self.context["request"].user.is_authenticated: + # If there's no authenticated user and we're creating a new one, attributes are required. + + for attr in ["first_name", "password"]: + if not data.get(attr): + raise serializers.ValidationError({attr: "This field is required."}, code="required") + + return data + + def create(self, validated_data, **kwargs): + if "view" not in self.context or not self.context["view"].kwargs.get("invite_id"): + raise serializers.ValidationError("Please provide an invite ID to continue.") + + user: Optional[User] = None + is_new_user: bool = False + + if self.context["request"].user.is_authenticated: + user = cast(User, self.context["request"].user) + + invite_id = self.context["view"].kwargs.get("invite_id") + + try: + invite: OrganizationInvite = OrganizationInvite.objects.select_related("organization").get(id=invite_id) + except (OrganizationInvite.DoesNotExist): + raise serializers.ValidationError("The provided invite ID is not valid.") + + with transaction.atomic(): + if not user: + is_new_user = True + user = User.objects.create_user( + invite.target_email, + validated_data.pop("password"), + validated_data.pop("first_name"), + **validated_data, + ) + + try: + invite.use(user) + except ValueError as e: + raise serializers.ValidationError(str(e)) + + if is_new_user: + login( + self.context["request"], user, backend="django.contrib.auth.backends.ModelBackend", + ) + + report_user_signed_up( + user.distinct_id, + is_instance_first_user=False, + is_organization_first_user=False, + new_onboarding_enabled=(not invite.organization.setup_section_2_completed), + backend_processor="OrganizationInviteSignupSerializer", + ) + + else: + report_user_joined_organization(organization=invite.organization, current_user=user) + + # Update user props + user_identify.identify_task.delay(user_id=user.id) + + return user + + +class SignupViewset(generics.CreateAPIView): + serializer_class = SignupSerializer + # Enables E2E testing of signup flow + permission_classes = (permissions.AllowAny,) if settings.E2E_TESTING else (UninitiatedOrCloudOnly,) + + +class SocialSignupViewset(generics.CreateAPIView): + serializer_class = SocialSignupSerializer + permission_classes = (UninitiatedOrCloudOnly,) + + +class InviteSignupViewset(generics.CreateAPIView): + serializer_class = InviteSignupSerializer + permission_classes = (permissions.AllowAny,) + + def get(self, request, *args, **kwargs): + """ + Pre-validates an invite code. + """ + + invite_id = kwargs.get("invite_id") + + if not invite_id: + raise exceptions.ValidationError("Please provide an invite ID to continue.") + + try: + invite: OrganizationInvite = OrganizationInvite.objects.get(id=invite_id) + except (OrganizationInvite.DoesNotExist, ValidationError): + raise serializers.ValidationError("The provided invite ID is not valid.") + + user = request.user if request.user.is_authenticated else None + + invite.validate(user=user) + + return response.Response( + { + "id": str(invite.id), + "target_email": mask_email_address(invite.target_email), + "first_name": invite.first_name, + "organization_name": invite.organization.name, + } + ) diff --git a/posthog/api/test/test_organization.py b/posthog/api/test/test_organization.py index 2ab6d7e36a3..37b13445469 100644 --- a/posthog/api/test/test_organization.py +++ b/posthog/api/test/test_organization.py @@ -1,14 +1,8 @@ -import datetime -import uuid -from typing import cast from unittest.mock import patch -import pytest -import pytz from rest_framework import status -from posthog.models import Dashboard, Organization, OrganizationMembership, Team, User -from posthog.models.organization import OrganizationInvite +from posthog.models import Organization, OrganizationMembership, Team, User from posthog.test.base import APIBaseTest @@ -17,7 +11,9 @@ class TestOrganizationAPI(APIBaseTest): # Retrieving organization def test_get_current_organization(self): - response_data = self.client.get("/api/organizations/@current").json() + response = self.client.get("/api/organizations/@current") + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() self.assertEqual(response_data["id"], str(self.organization.id)) # By default, setup state is marked as completed @@ -144,683 +140,3 @@ class TestOrganizationAPI(APIBaseTest): # Assert nothing was reported mock_capture.assert_not_called() - - -class TestSignup(APIBaseTest): - CONFIG_EMAIL = None - - @pytest.mark.skip_on_multitenancy - @patch("posthog.api.organization.settings.EE_AVAILABLE", False) - @patch("posthog.api.organization.posthoganalytics.capture") - def test_api_sign_up(self, mock_capture): - response = self.client.post( - "/api/signup/", - { - "first_name": "John", - "email": "hedgehog@posthog.com", - "password": "notsecure", - "organization_name": "Hedgehogs United, LLC", - "email_opt_in": False, - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - user = cast(User, User.objects.order_by("-pk")[0]) - team = cast(Team, user.team) - organization = cast(Organization, user.organization) - - self.assertEqual( - response.data, - { - "id": user.pk, - "distinct_id": user.distinct_id, - "first_name": "John", - "email": "hedgehog@posthog.com", - "redirect_url": "/ingestion", - }, - ) - - # Assert that the user was properly created - self.assertEqual(user.first_name, "John") - self.assertEqual(user.email, "hedgehog@posthog.com") - self.assertEqual(user.email_opt_in, False) - - # Assert that the team was properly created - self.assertEqual(team.name, "Default Project") - - # Assert that the org was properly created - self.assertEqual(organization.name, "Hedgehogs United, LLC") - - # Assert that the sign up event & identify calls were sent to PostHog analytics - mock_capture.assert_called_once_with( - user.distinct_id, - "user signed up", - properties={ - "is_first_user": True, - "is_organization_first_user": True, - "new_onboarding_enabled": False, - "signup_backend_processor": "OrganizationSignupSerializer", - "signup_social_provider": "", - }, - ) - - # Assert that the user is logged in - response = self.client.get("/api/user/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["email"], "hedgehog@posthog.com") - - # Assert that the password was correctly saved - self.assertTrue(user.check_password("notsecure")) - - @pytest.mark.skip_on_multitenancy - def test_signup_disallowed_on_initiated_self_hosted(self): - with self.settings(MULTI_TENANCY=False): - response = self.client.post( - "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - response = self.client.post( - "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "attr": None, - "code": "permission_denied", - "detail": "This endpoint is unavailable on initiated self-hosted instances of PostHog.", - "type": "authentication_error", - }, - ) - - @pytest.mark.skip_on_multitenancy - @patch("posthog.api.organization.posthoganalytics.capture") - @patch("posthoganalytics.identify") - def test_signup_minimum_attrs(self, mock_identify, mock_capture): - response = self.client.post( - "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - user = cast(User, User.objects.order_by("-pk").get()) - organization = cast(Organization, user.organization) - self.assertEqual( - response.data, - { - "id": user.pk, - "distinct_id": user.distinct_id, - "first_name": "Jane", - "email": "hedgehog2@posthog.com", - "redirect_url": "/ingestion", - }, - ) - - # Assert that the user & org were properly created - self.assertEqual(user.first_name, "Jane") - self.assertEqual(user.email, "hedgehog2@posthog.com") - self.assertEqual(user.email_opt_in, True) # Defaults to True - self.assertEqual(organization.name, "Jane") - - # Assert that the sign up event & identify calls were sent to PostHog analytics - mock_identify.assert_called_once() - mock_capture.assert_called_once_with( - user.distinct_id, - "user signed up", - properties={ - "is_first_user": True, - "is_organization_first_user": True, - "new_onboarding_enabled": False, - "signup_backend_processor": "OrganizationSignupSerializer", - "signup_social_provider": "", - }, - ) - - # Assert that the user is logged in - response = self.client.get("/api/user/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["email"], "hedgehog2@posthog.com") - - # Assert that the password was correctly saved - self.assertTrue(user.check_password("notsecure")) - - def test_cant_sign_up_without_required_attributes(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - org_count: int = Organization.objects.count() - - required_attributes = [ - "first_name", - "email", - "password", - ] - - for attribute in required_attributes: - body = { - "first_name": "Jane", - "email": "invalid@posthog.com", - "password": "notsecure", - } - body.pop(attribute) - - # Make sure the endpoint works with and without the trailing slash - response = self.client.post("/api/signup", body) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "required", - "detail": "This field is required.", - "attr": attribute, - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - self.assertEqual(Organization.objects.count(), org_count) - - def test_cant_sign_up_with_short_password(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - - response = self.client.post( - "/api/signup/", {"first_name": "Jane", "email": "failed@posthog.com", "password": "123"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "password_too_short", - "detail": "This password is too short. It must contain at least 8 characters.", - "attr": "password", - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - - @patch("posthoganalytics.feature_enabled") - def test_default_dashboard_is_created_on_signup(self, mock_feature_enabled): - """ - Tests that the default web app dashboard is created on signup. - Note: This feature is currently behind a feature flag. - """ - - response = self.client.post( - "/api/signup/", - { - "first_name": "Jane", - "email": "hedgehog75@posthog.com", - "password": "notsecure", - "redirect_url": "/ingestion", - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - user: User = User.objects.order_by("-pk").get() - - mock_feature_enabled.assert_any_call("new-onboarding-2822", user.distinct_id) - - self.assertEqual( - response.data, - { - "id": user.pk, - "distinct_id": user.distinct_id, - "first_name": "Jane", - "email": "hedgehog75@posthog.com", - "redirect_url": "/personalization", - }, - ) - - dashboard: Dashboard = Dashboard.objects.first() # type: ignore - self.assertEqual(dashboard.team, user.team) - self.assertEqual(dashboard.items.count(), 1) - self.assertEqual(dashboard.name, "Web Analytics") - self.assertEqual( - dashboard.items.all()[0].description, "Shows a conversion funnel from sign up to watching a movie." - ) - - # Particularly assert that the default dashboards are not created (because we create special demo dashboards) - self.assertEqual(Dashboard.objects.filter(team=user.team).count(), 3) # Web, app & revenue demo dashboards - - -class TestInviteSignup(APIBaseTest): - """ - Tests the sign up process for users with an invite (i.e. existing organization). - """ - - CONFIG_EMAIL = None - - # Invite pre-validation - - def test_api_invite_sign_up_prevalidate(self): - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+19@posthog.com", organization=self.organization, - ) - - response = self.client.get(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "id": str(invite.id), - "target_email": "t*****9@posthog.com", - "first_name": "", - "organization_name": self.CONFIG_ORGANIZATION_NAME, - }, - ) - - def test_api_invite_sign_up_with_first_nameprevalidate(self): - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+58@posthog.com", organization=self.organization, first_name="Jane" - ) - - response = self.client.get(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "id": str(invite.id), - "target_email": "t*****8@posthog.com", - "first_name": "Jane", - "organization_name": self.CONFIG_ORGANIZATION_NAME, - }, - ) - - def test_api_invite_sign_up_prevalidate_for_existing_user(self): - user = self._create_user("test+29@posthog.com", "test_password") - new_org = Organization.objects.create(name="Test, Inc") - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+29@posthog.com", organization=new_org, - ) - - self.client.force_login(user) - response = self.client.get(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "id": str(invite.id), - "target_email": "t*****9@posthog.com", - "first_name": "", - "organization_name": "Test, Inc", - }, - ) - - def test_api_invite_sign_up_prevalidate_invalid_invite(self): - - for invalid_invite in [uuid.uuid4(), "abc", "1234"]: - response = self.client.get(f"/api/signup/{invalid_invite}/") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "invalid_input", - "detail": "The provided invite ID is not valid.", - "attr": None, - }, - ) - - def test_existing_user_cant_claim_invite_if_it_doesnt_match_target_email(self): - user = self._create_user("test+39@posthog.com", "test_password") - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+49@posthog.com", organization=self.organization, - ) - - self.client.force_login(user) - response = self.client.get(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "invalid_recipient", - "detail": "This invite is intended for another email address: t*****9@posthog.com." - " You tried to sign up with test+39@posthog.com.", - "attr": None, - }, - ) - - def test_api_invite_sign_up_prevalidate_expired_invite(self): - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+59@posthog.com", organization=self.organization, - ) - invite.created_at = datetime.datetime(2020, 12, 1, tzinfo=pytz.UTC) - invite.save() - - response = self.client.get(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "expired", - "detail": "This invite has expired. Please ask your admin for a new one.", - "attr": None, - }, - ) - - # Signup (using invite) - - @patch("posthoganalytics.capture") - @patch("posthog.api.organization.settings.EE_AVAILABLE", True) - def test_api_invite_sign_up(self, mock_capture): - - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+99@posthog.com", organization=self.organization, - ) - - response = self.client.post( - f"/api/signup/{invite.id}/", {"first_name": "Alice", "password": "test_password", "email_opt_in": True}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - user = cast(User, User.objects.order_by("-pk")[0]) - self.assertEqual( - response.data, - {"id": user.pk, "distinct_id": user.distinct_id, "first_name": "Alice", "email": "test+99@posthog.com"}, - ) - - # User is now a member of the organization - self.assertEqual(user.organization_memberships.count(), 1) - self.assertEqual(user.organization_memberships.first().organization, self.organization) # type: ignore - - # Defaults are set correctly - self.assertEqual(user.organization, self.organization) - self.assertEqual(user.team, self.team) - - # Assert that the user was properly created - self.assertEqual(user.first_name, "Alice") - self.assertEqual(user.email, "test+99@posthog.com") - self.assertEqual(user.email_opt_in, True) - - # Assert that the sign up event & identify calls were sent to PostHog analytics - mock_capture.assert_called_once_with( - user.distinct_id, - "user signed up", - properties={ - "is_first_user": False, - "is_organization_first_user": False, - "new_onboarding_enabled": False, - "signup_backend_processor": "OrganizationInviteSignupSerializer", - "signup_social_provider": "", - }, - ) - - # Assert that the user is logged in - response = self.client.get("/api/user/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["email"], "test+99@posthog.com") - - # Assert that the password was correctly saved - self.assertTrue(user.check_password("test_password")) - - @patch("posthoganalytics.identify") - @patch("posthoganalytics.capture") - @patch("posthog.api.organization.settings.EE_AVAILABLE", False) - def test_existing_user_can_sign_up_to_a_new_organization(self, mock_capture, mock_identify): - user = self._create_user("test+159@posthog.com", "test_password") - new_org = Organization.objects.create(name="TestCo") - new_team = Team.objects.create(organization=new_org) - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+159@posthog.com", organization=new_org, - ) - - self.client.force_login(user) - - count = User.objects.count() - - with self.settings(MULTI_TENANCY=True): - response = self.client.post(f"/api/signup/{invite.id}/") - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual( - response.data, - {"id": user.pk, "distinct_id": user.distinct_id, "first_name": "", "email": "test+159@posthog.com"}, - ) - - # No new user is created - self.assertEqual(User.objects.count(), count) - - # User is now a member of the organization - user.refresh_from_db() - self.assertEqual(user.organization_memberships.count(), 2) - self.assertTrue(user.organization_memberships.filter(organization=new_org).exists()) - - # User is now changed to the new organization - self.assertEqual(user.organization, new_org) - self.assertEqual(user.team, new_team) - - # User is not changed - self.assertEqual(user.first_name, "") - self.assertEqual(user.email, "test+159@posthog.com") - - # Assert that the sign up event & identify calls were sent to PostHog analytics - mock_capture.assert_called_once_with( - user.distinct_id, - "user joined organization", - properties={ - "organization_id": str(new_org.id), - "user_number_of_org_membership": 2, - "org_current_invite_count": 0, - "org_current_project_count": 1, - "org_current_members_count": 1, - }, - ) - mock_identify.assert_called_once() - - # Assert that the user remains logged in - response = self.client.get("/api/user/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @patch("posthoganalytics.capture") - def test_cannot_use_claim_invite_endpoint_to_update_user(self, mock_capture): - """ - Tests that a user cannot use the claim invite endpoint to change their name or password - (as this endpoint does not do any checks that might be required). - """ - new_org = Organization.objects.create(name="TestCo") - user = self._create_user("test+189@posthog.com", "test_password") - user2 = self._create_user("test+949@posthog.com") - user2.join(organization=new_org) - - Team.objects.create(organization=new_org) - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+189@posthog.com", organization=new_org, - ) - - self.client.force_login(user) - - response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Bob", "password": "new_password"}) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual( - response.data, - { - "id": user.pk, - "distinct_id": user.distinct_id, - "first_name": "", - "email": "test+189@posthog.com", - }, # note the unchanged attributes - ) - - # User is subscribed to the new organization - user.refresh_from_db() - self.assertTrue(user.organization_memberships.filter(organization=new_org).exists()) - - # User is not changed - self.assertEqual(user.first_name, "") - self.assertFalse(user.check_password("new_password")) # Password is not updated - - # Assert that the sign up event & identify calls were sent to PostHog analytics - mock_capture.assert_called_once_with( - user.distinct_id, - "user joined organization", - properties={ - "organization_id": str(new_org.id), - "user_number_of_org_membership": 2, - "org_current_invite_count": 0, - "org_current_project_count": 1, - "org_current_members_count": 2, - }, - ) - - def test_cant_claim_sign_up_invite_without_required_attributes(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - org_count: int = Organization.objects.count() - - required_attributes = [ - "first_name", - "password", - ] - - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+799@posthog.com", organization=self.organization, - ) - - for attribute in required_attributes: - body = { - "first_name": "Charlie", - "password": "test_password", - } - body.pop(attribute) - - response = self.client.post(f"/api/signup/{invite.id}/", body) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "type": "validation_error", - "code": "required", - "detail": "This field is required.", - "attr": attribute, - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - self.assertEqual(Organization.objects.count(), org_count) - - def test_cant_claim_invite_sign_up_with_short_password(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - org_count: int = Organization.objects.count() - - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+799@posthog.com", organization=self.organization, - ) - - response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "123"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - { - "type": "validation_error", - "code": "password_too_short", - "detail": "This password is too short. It must contain at least 8 characters.", - "attr": "password", - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - self.assertEqual(Organization.objects.count(), org_count) - - def test_cant_claim_invalid_invite(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - org_count: int = Organization.objects.count() - - response = self.client.post( - f"/api/signup/{uuid.uuid4()}/", {"first_name": "Charlie", "password": "test_password"} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - { - "type": "validation_error", - "code": "invalid_input", - "detail": "The provided invite ID is not valid.", - "attr": None, - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - self.assertEqual(Organization.objects.count(), org_count) - - def test_cant_claim_expired_invite(self): - count: int = User.objects.count() - team_count: int = Team.objects.count() - org_count: int = Organization.objects.count() - - invite: OrganizationInvite = OrganizationInvite.objects.create( - target_email="test+799@posthog.com", organization=self.organization, - ) - invite.created_at = datetime.datetime(2020, 3, 3, tzinfo=pytz.UTC) - invite.save() - - response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "test_password"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - { - "type": "validation_error", - "code": "expired", - "detail": "This invite has expired. Please ask your admin for a new one.", - "attr": None, - }, - ) - - self.assertEqual(User.objects.count(), count) - self.assertEqual(Team.objects.count(), team_count) - self.assertEqual(Organization.objects.count(), org_count) - - # Social signup (use invite) - - def test_api_social_invite_sign_up(self): - - # simulate SSO process started - session = self.client.session - session.update({"backend": "google-oauth2"}) - session.save() - - response = self.client.post("/api/social_signup", {"organization_name": "Tech R Us", "email_opt_in": False}) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - self.assertEqual(response.json(), {"continue_url": "/complete/google-oauth2/"}) - - # Check the values were saved in the session - self.assertEqual(self.client.session.get("organization_name"), "Tech R Us") - self.assertEqual(self.client.session.get("email_opt_in"), False) - self.assertEqual(self.client.session.get_expiry_age(), 3600) - - def test_cannot_use_social_invite_sign_up_if_social_session_is_not_active(self): - - response = self.client.post("/api/social_signup", {"organization_name": "Tech R Us", "email_opt_in": False}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - { - "type": "validation_error", - "code": "invalid_input", - "detail": "Inactive social login session. Go to /login and log in before continuing.", - "attr": None, - }, - ) - self.assertEqual(len(self.client.session.keys()), 0) # Nothing is saved in the session - - def test_cannot_use_social_invite_sign_up_without_required_attributes(self): - - response = self.client.post("/api/social_signup", {"email_opt_in": False}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - { - "type": "validation_error", - "code": "required", - "detail": "This field is required.", - "attr": "organization_name", - }, - ) - self.assertEqual(len(self.client.session.keys()), 0) # Nothing is saved in the session diff --git a/posthog/api/test/test_signup.py b/posthog/api/test/test_signup.py new file mode 100644 index 00000000000..ca24706411e --- /dev/null +++ b/posthog/api/test/test_signup.py @@ -0,0 +1,692 @@ +import datetime +import uuid +from typing import cast +from unittest.mock import patch + +import pytest +import pytz +from rest_framework import status + +from posthog.models import Dashboard, Organization, Team, User +from posthog.models.organization import OrganizationInvite +from posthog.test.base import APIBaseTest + + +class TestSignup(APIBaseTest): + CONFIG_EMAIL = None + + @pytest.mark.skip_on_multitenancy + @patch("posthog.api.organization.settings.EE_AVAILABLE", False) + @patch("posthog.api.organization.posthoganalytics.capture") + def test_api_sign_up(self, mock_capture): + response = self.client.post( + "/api/signup/", + { + "first_name": "John", + "email": "hedgehog@posthog.com", + "password": "notsecure", + "organization_name": "Hedgehogs United, LLC", + "email_opt_in": False, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = cast(User, User.objects.order_by("-pk")[0]) + team = cast(Team, user.team) + organization = cast(Organization, user.organization) + + self.assertEqual( + response.data, + { + "id": user.pk, + "distinct_id": user.distinct_id, + "first_name": "John", + "email": "hedgehog@posthog.com", + "redirect_url": "/ingestion", + }, + ) + + # Assert that the user was properly created + self.assertEqual(user.first_name, "John") + self.assertEqual(user.email, "hedgehog@posthog.com") + self.assertEqual(user.email_opt_in, False) + + # Assert that the team was properly created + self.assertEqual(team.name, "Default Project") + + # Assert that the org was properly created + self.assertEqual(organization.name, "Hedgehogs United, LLC") + + # Assert that the sign up event & identify calls were sent to PostHog analytics + mock_capture.assert_called_once_with( + user.distinct_id, + "user signed up", + properties={ + "is_first_user": True, + "is_organization_first_user": True, + "new_onboarding_enabled": False, + "signup_backend_processor": "OrganizationSignupSerializer", + "signup_social_provider": "", + }, + ) + + # Assert that the user is logged in + response = self.client.get("/api/user/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["email"], "hedgehog@posthog.com") + + # Assert that the password was correctly saved + self.assertTrue(user.check_password("notsecure")) + + @pytest.mark.skip_on_multitenancy + def test_signup_disallowed_on_initiated_self_hosted(self): + with self.settings(MULTI_TENANCY=False): + response = self.client.post( + "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response = self.client.post( + "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data, + { + "attr": None, + "code": "permission_denied", + "detail": "This endpoint is unavailable on initiated self-hosted instances of PostHog.", + "type": "authentication_error", + }, + ) + + @pytest.mark.skip_on_multitenancy + @patch("posthog.api.organization.posthoganalytics.capture") + @patch("posthoganalytics.identify") + def test_signup_minimum_attrs(self, mock_identify, mock_capture): + response = self.client.post( + "/api/signup/", {"first_name": "Jane", "email": "hedgehog2@posthog.com", "password": "notsecure"}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = cast(User, User.objects.order_by("-pk").get()) + organization = cast(Organization, user.organization) + self.assertEqual( + response.data, + { + "id": user.pk, + "distinct_id": user.distinct_id, + "first_name": "Jane", + "email": "hedgehog2@posthog.com", + "redirect_url": "/ingestion", + }, + ) + + # Assert that the user & org were properly created + self.assertEqual(user.first_name, "Jane") + self.assertEqual(user.email, "hedgehog2@posthog.com") + self.assertEqual(user.email_opt_in, True) # Defaults to True + self.assertEqual(organization.name, "Jane") + + # Assert that the sign up event & identify calls were sent to PostHog analytics + mock_identify.assert_called_once() + mock_capture.assert_called_once_with( + user.distinct_id, + "user signed up", + properties={ + "is_first_user": True, + "is_organization_first_user": True, + "new_onboarding_enabled": False, + "signup_backend_processor": "OrganizationSignupSerializer", + "signup_social_provider": "", + }, + ) + + # Assert that the user is logged in + response = self.client.get("/api/user/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["email"], "hedgehog2@posthog.com") + + # Assert that the password was correctly saved + self.assertTrue(user.check_password("notsecure")) + + def test_cant_sign_up_without_required_attributes(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + org_count: int = Organization.objects.count() + + required_attributes = [ + "first_name", + "email", + "password", + ] + + for attribute in required_attributes: + body = { + "first_name": "Jane", + "email": "invalid@posthog.com", + "password": "notsecure", + } + body.pop(attribute) + + # Make sure the endpoint works with and without the trailing slash + response = self.client.post("/api/signup", body) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "required", + "detail": "This field is required.", + "attr": attribute, + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + self.assertEqual(Organization.objects.count(), org_count) + + def test_cant_sign_up_with_short_password(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + + response = self.client.post( + "/api/signup/", {"first_name": "Jane", "email": "failed@posthog.com", "password": "123"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "password_too_short", + "detail": "This password is too short. It must contain at least 8 characters.", + "attr": "password", + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + + @patch("posthoganalytics.feature_enabled") + def test_default_dashboard_is_created_on_signup(self, mock_feature_enabled): + """ + Tests that the default web app dashboard is created on signup. + Note: This feature is currently behind a feature flag. + """ + + response = self.client.post( + "/api/signup/", + { + "first_name": "Jane", + "email": "hedgehog75@posthog.com", + "password": "notsecure", + "redirect_url": "/ingestion", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user: User = User.objects.order_by("-pk").get() + + mock_feature_enabled.assert_any_call("new-onboarding-2822", user.distinct_id) + + self.assertEqual( + response.data, + { + "id": user.pk, + "distinct_id": user.distinct_id, + "first_name": "Jane", + "email": "hedgehog75@posthog.com", + "redirect_url": "/personalization", + }, + ) + + dashboard: Dashboard = Dashboard.objects.first() # type: ignore + self.assertEqual(dashboard.team, user.team) + self.assertEqual(dashboard.items.count(), 1) + self.assertEqual(dashboard.name, "Web Analytics") + self.assertEqual( + dashboard.items.all()[0].description, "Shows a conversion funnel from sign up to watching a movie." + ) + + # Particularly assert that the default dashboards are not created (because we create special demo dashboards) + self.assertEqual(Dashboard.objects.filter(team=user.team).count(), 3) # Web, app & revenue demo dashboards + + +class TestInviteSignup(APIBaseTest): + """ + Tests the sign up process for users with an invite (i.e. existing organization). + """ + + CONFIG_EMAIL = None + + # Invite pre-validation + + def test_api_invite_sign_up_prevalidate(self): + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+19@posthog.com", organization=self.organization, + ) + + response = self.client.get(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "id": str(invite.id), + "target_email": "t*****9@posthog.com", + "first_name": "", + "organization_name": self.CONFIG_ORGANIZATION_NAME, + }, + ) + + def test_api_invite_sign_up_with_first_nameprevalidate(self): + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+58@posthog.com", organization=self.organization, first_name="Jane" + ) + + response = self.client.get(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "id": str(invite.id), + "target_email": "t*****8@posthog.com", + "first_name": "Jane", + "organization_name": self.CONFIG_ORGANIZATION_NAME, + }, + ) + + def test_api_invite_sign_up_prevalidate_for_existing_user(self): + user = self._create_user("test+29@posthog.com", "test_password") + new_org = Organization.objects.create(name="Test, Inc") + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+29@posthog.com", organization=new_org, + ) + + self.client.force_login(user) + response = self.client.get(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "id": str(invite.id), + "target_email": "t*****9@posthog.com", + "first_name": "", + "organization_name": "Test, Inc", + }, + ) + + def test_api_invite_sign_up_prevalidate_invalid_invite(self): + + for invalid_invite in [uuid.uuid4(), "abc", "1234"]: + response = self.client.get(f"/api/signup/{invalid_invite}/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "invalid_input", + "detail": "The provided invite ID is not valid.", + "attr": None, + }, + ) + + def test_existing_user_cant_claim_invite_if_it_doesnt_match_target_email(self): + user = self._create_user("test+39@posthog.com", "test_password") + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+49@posthog.com", organization=self.organization, + ) + + self.client.force_login(user) + response = self.client.get(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "invalid_recipient", + "detail": "This invite is intended for another email address: t*****9@posthog.com." + " You tried to sign up with test+39@posthog.com.", + "attr": None, + }, + ) + + def test_api_invite_sign_up_prevalidate_expired_invite(self): + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+59@posthog.com", organization=self.organization, + ) + invite.created_at = datetime.datetime(2020, 12, 1, tzinfo=pytz.UTC) + invite.save() + + response = self.client.get(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "expired", + "detail": "This invite has expired. Please ask your admin for a new one.", + "attr": None, + }, + ) + + # Signup (using invite) + + @patch("posthoganalytics.capture") + @patch("posthog.api.organization.settings.EE_AVAILABLE", True) + def test_api_invite_sign_up(self, mock_capture): + + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+99@posthog.com", organization=self.organization, + ) + + response = self.client.post( + f"/api/signup/{invite.id}/", {"first_name": "Alice", "password": "test_password", "email_opt_in": True}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + user = cast(User, User.objects.order_by("-pk")[0]) + self.assertEqual( + response.data, + {"id": user.pk, "distinct_id": user.distinct_id, "first_name": "Alice", "email": "test+99@posthog.com"}, + ) + + # User is now a member of the organization + self.assertEqual(user.organization_memberships.count(), 1) + self.assertEqual(user.organization_memberships.first().organization, self.organization) # type: ignore + + # Defaults are set correctly + self.assertEqual(user.organization, self.organization) + self.assertEqual(user.team, self.team) + + # Assert that the user was properly created + self.assertEqual(user.first_name, "Alice") + self.assertEqual(user.email, "test+99@posthog.com") + self.assertEqual(user.email_opt_in, True) + + # Assert that the sign up event & identify calls were sent to PostHog analytics + mock_capture.assert_called_once_with( + user.distinct_id, + "user signed up", + properties={ + "is_first_user": False, + "is_organization_first_user": False, + "new_onboarding_enabled": False, + "signup_backend_processor": "OrganizationInviteSignupSerializer", + "signup_social_provider": "", + }, + ) + + # Assert that the user is logged in + response = self.client.get("/api/user/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["email"], "test+99@posthog.com") + + # Assert that the password was correctly saved + self.assertTrue(user.check_password("test_password")) + + @patch("posthoganalytics.identify") + @patch("posthoganalytics.capture") + @patch("posthog.api.organization.settings.EE_AVAILABLE", False) + def test_existing_user_can_sign_up_to_a_new_organization(self, mock_capture, mock_identify): + user = self._create_user("test+159@posthog.com", "test_password") + new_org = Organization.objects.create(name="TestCo") + new_team = Team.objects.create(organization=new_org) + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+159@posthog.com", organization=new_org, + ) + + self.client.force_login(user) + + count = User.objects.count() + + with self.settings(MULTI_TENANCY=True): + response = self.client.post(f"/api/signup/{invite.id}/") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.data, + {"id": user.pk, "distinct_id": user.distinct_id, "first_name": "", "email": "test+159@posthog.com"}, + ) + + # No new user is created + self.assertEqual(User.objects.count(), count) + + # User is now a member of the organization + user.refresh_from_db() + self.assertEqual(user.organization_memberships.count(), 2) + self.assertTrue(user.organization_memberships.filter(organization=new_org).exists()) + + # User is now changed to the new organization + self.assertEqual(user.organization, new_org) + self.assertEqual(user.team, new_team) + + # User is not changed + self.assertEqual(user.first_name, "") + self.assertEqual(user.email, "test+159@posthog.com") + + # Assert that the sign up event & identify calls were sent to PostHog analytics + mock_capture.assert_called_once_with( + user.distinct_id, + "user joined organization", + properties={ + "organization_id": str(new_org.id), + "user_number_of_org_membership": 2, + "org_current_invite_count": 0, + "org_current_project_count": 1, + "org_current_members_count": 1, + }, + ) + mock_identify.assert_called_once() + + # Assert that the user remains logged in + response = self.client.get("/api/user/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch("posthoganalytics.capture") + def test_cannot_use_claim_invite_endpoint_to_update_user(self, mock_capture): + """ + Tests that a user cannot use the claim invite endpoint to change their name or password + (as this endpoint does not do any checks that might be required). + """ + new_org = Organization.objects.create(name="TestCo") + user = self._create_user("test+189@posthog.com", "test_password") + user2 = self._create_user("test+949@posthog.com") + user2.join(organization=new_org) + + Team.objects.create(organization=new_org) + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+189@posthog.com", organization=new_org, + ) + + self.client.force_login(user) + + response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Bob", "password": "new_password"}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.data, + { + "id": user.pk, + "distinct_id": user.distinct_id, + "first_name": "", + "email": "test+189@posthog.com", + }, # note the unchanged attributes + ) + + # User is subscribed to the new organization + user.refresh_from_db() + self.assertTrue(user.organization_memberships.filter(organization=new_org).exists()) + + # User is not changed + self.assertEqual(user.first_name, "") + self.assertFalse(user.check_password("new_password")) # Password is not updated + + # Assert that the sign up event & identify calls were sent to PostHog analytics + mock_capture.assert_called_once_with( + user.distinct_id, + "user joined organization", + properties={ + "organization_id": str(new_org.id), + "user_number_of_org_membership": 2, + "org_current_invite_count": 0, + "org_current_project_count": 1, + "org_current_members_count": 2, + }, + ) + + def test_cant_claim_sign_up_invite_without_required_attributes(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + org_count: int = Organization.objects.count() + + required_attributes = [ + "first_name", + "password", + ] + + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+799@posthog.com", organization=self.organization, + ) + + for attribute in required_attributes: + body = { + "first_name": "Charlie", + "password": "test_password", + } + body.pop(attribute) + + response = self.client.post(f"/api/signup/{invite.id}/", body) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "required", + "detail": "This field is required.", + "attr": attribute, + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + self.assertEqual(Organization.objects.count(), org_count) + + def test_cant_claim_invite_sign_up_with_short_password(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + org_count: int = Organization.objects.count() + + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+799@posthog.com", organization=self.organization, + ) + + response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "123"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "type": "validation_error", + "code": "password_too_short", + "detail": "This password is too short. It must contain at least 8 characters.", + "attr": "password", + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + self.assertEqual(Organization.objects.count(), org_count) + + def test_cant_claim_invalid_invite(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + org_count: int = Organization.objects.count() + + response = self.client.post( + f"/api/signup/{uuid.uuid4()}/", {"first_name": "Charlie", "password": "test_password"} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "type": "validation_error", + "code": "invalid_input", + "detail": "The provided invite ID is not valid.", + "attr": None, + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + self.assertEqual(Organization.objects.count(), org_count) + + def test_cant_claim_expired_invite(self): + count: int = User.objects.count() + team_count: int = Team.objects.count() + org_count: int = Organization.objects.count() + + invite: OrganizationInvite = OrganizationInvite.objects.create( + target_email="test+799@posthog.com", organization=self.organization, + ) + invite.created_at = datetime.datetime(2020, 3, 3, tzinfo=pytz.UTC) + invite.save() + + response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "test_password"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "type": "validation_error", + "code": "expired", + "detail": "This invite has expired. Please ask your admin for a new one.", + "attr": None, + }, + ) + + self.assertEqual(User.objects.count(), count) + self.assertEqual(Team.objects.count(), team_count) + self.assertEqual(Organization.objects.count(), org_count) + + # Social signup (use invite) + + def test_api_social_invite_sign_up(self): + + # simulate SSO process started + session = self.client.session + session.update({"backend": "google-oauth2"}) + session.save() + + response = self.client.post("/api/social_signup", {"organization_name": "Tech R Us", "email_opt_in": False}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertEqual(response.json(), {"continue_url": "/complete/google-oauth2/"}) + + # Check the values were saved in the session + self.assertEqual(self.client.session.get("organization_name"), "Tech R Us") + self.assertEqual(self.client.session.get("email_opt_in"), False) + self.assertEqual(self.client.session.get_expiry_age(), 3600) + + def test_cannot_use_social_invite_sign_up_if_social_session_is_not_active(self): + + response = self.client.post("/api/social_signup", {"organization_name": "Tech R Us", "email_opt_in": False}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "type": "validation_error", + "code": "invalid_input", + "detail": "Inactive social login session. Go to /login and log in before continuing.", + "attr": None, + }, + ) + self.assertEqual(len(self.client.session.keys()), 0) # Nothing is saved in the session + + def test_cannot_use_social_invite_sign_up_without_required_attributes(self): + + response = self.client.post("/api/social_signup", {"email_opt_in": False}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "type": "validation_error", + "code": "required", + "detail": "This field is required.", + "attr": "organization_name", + }, + ) + self.assertEqual(len(self.client.session.keys()), 0) # Nothing is saved in the session diff --git a/posthog/urls.py b/posthog/urls.py index 307d2b5c732..59326bafccc 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -25,6 +25,7 @@ from posthog.api import ( organization, projects_router, router, + signup, user, ) from posthog.demo import demo @@ -210,9 +211,9 @@ urlpatterns = [ opt_slash_path("api/user/change_password", user.change_password), opt_slash_path("api/user/test_slack_webhook", user.test_slack_webhook), opt_slash_path("api/user", user.user), - opt_slash_path("api/signup", organization.OrganizationSignupViewset.as_view()), - opt_slash_path("api/social_signup", organization.OrganizationSocialSignupViewset.as_view()), - path("api/signup//", organization.OrganizationInviteSignupViewset.as_view()), + opt_slash_path("api/signup", signup.SignupViewset.as_view()), + opt_slash_path("api/social_signup", signup.SocialSignupViewset.as_view()), + path("api/signup//", signup.InviteSignupViewset.as_view()), re_path(r"^api.+", api_not_found), path("authorize_and_redirect/", login_required(authorize_and_redirect)), path("shared_dashboard/", dashboard.shared_dashboard),