mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-27 16:26:50 +01:00
refactor signup routes out of organization
This commit is contained in:
parent
2f06b20ad0
commit
076ac32641
@ -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
|
||||
|
230
posthog/api/signup.py
Normal file
230
posthog/api/signup.py
Normal file
@ -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,
|
||||
}
|
||||
)
|
@ -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
|
||||
|
692
posthog/api/test/test_signup.py
Normal file
692
posthog/api/test/test_signup.py
Normal file
@ -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
|
@ -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/<str:invite_id>/", 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/<str:invite_id>/", signup.InviteSignupViewset.as_view()),
|
||||
re_path(r"^api.+", api_not_found),
|
||||
path("authorize_and_redirect/", login_required(authorize_and_redirect)),
|
||||
path("shared_dashboard/<str:share_token>", dashboard.shared_dashboard),
|
||||
|
Loading…
Reference in New Issue
Block a user