0
0
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:
Paolo D'Amico 2021-03-31 13:33:29 -07:00
parent 2f06b20ad0
commit 076ac32641
5 changed files with 935 additions and 918 deletions

View File

@ -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
View 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,
}
)

View File

@ -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

View 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

View File

@ -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),