Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@
"invite": "10/min",
"user": USER_THROTTLE_RATE,
"influx_query": "5/min",
"hubspot_chat_token": "10/min",
"usage_data": "30/min",
"warehouse_connection_write": "10/min",
"warehouse_connection_read": "60/min",
Expand Down
1 change: 1 addition & 0 deletions api/app/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"user": "100000/day",
"master_api_key": "100000/day",
"influx_query": "50/min",
"hubspot_chat_token": "50/min",
"usage_data": "50/min",
"warehouse_connection_write": "1000/min",
"warehouse_connection_read": "1000/min",
Expand Down
39 changes: 37 additions & 2 deletions api/custom_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from django.utils import timezone
from django.utils.decorators import method_decorator
from djoser.views import TokenCreateView, UserViewSet # type: ignore[import-untyped]
from drf_spectacular.utils import extend_schema
from rest_framework import status
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
Expand All @@ -31,6 +31,7 @@
from custom_auth.mfa.trench.serializers import CodeLoginSerializer
from custom_auth.mfa.trench.utils import user_token_generator
from custom_auth.serializers import CustomUserDelete
from integrations.lead_tracking.hubspot.client import HubspotClient
from integrations.lead_tracking.hubspot.services import (
register_hubspot_tracker_and_track_user,
)
Expand Down Expand Up @@ -117,6 +118,7 @@ class FFAdminUserViewSet(UserViewSet): # type: ignore[misc]
throttle_scopes_by_action = {
"create": "signup",
"resend_activation": "resend_activation",
"hubspot_chat_token": "hubspot_chat_token",
}

def perform_authentication(self, request: Request) -> None:
Expand Down Expand Up @@ -183,6 +185,39 @@ def patch_onboarding(self, request: Request, *args: Any, **kwargs: Any) -> Respo

return Response(status=status.HTTP_204_NO_CONTENT)

@extend_schema(
responses={
200: inline_serializer(
name="HubspotChatTokenResponse",
fields={"token": serializers.CharField()},
),
404: None,
502: None,
},
)
@action(
detail=False,
methods=["get"],
url_path="me/hubspot-chat-token",
permission_classes=[IsAuthenticated],
)
def hubspot_chat_token(
self, request: Request, *args: Any, **kwargs: Any
) -> Response:
"""
Return a token that identifies the user to the HubSpot chat widget.
"""
if not settings.HUBSPOT_ACCESS_TOKEN:
return Response(status=status.HTTP_404_NOT_FOUND)

user = request.user
assert isinstance(user, FFAdminUser)
token = HubspotClient().create_visitor_identification_token(user)
if token is None:
return Response(status=status.HTTP_502_BAD_GATEWAY)

return Response({"token": token})

@action(["post"], detail=False)
def reset_password(self, request: Request, *args: Any, **kwargs: Any) -> Response:
serializer = self.get_serializer(data=request.data)
Expand Down
31 changes: 31 additions & 0 deletions api/integrations/lead_tracking/hubspot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@

import hubspot # type: ignore[import-untyped]
import requests
import urllib3
from django.conf import settings
from hubspot.conversations.visitor_identification import ( # type: ignore[import-untyped]
ApiException,
IdentificationTokenGenerationRequest,
)
from hubspot.crm.companies import ( # type: ignore[import-untyped]
PublicObjectSearchRequest,
SimplePublicObjectInput,
Expand All @@ -18,6 +23,7 @@
HUBSPOT_FORM_ID_SAAS,
HUBSPOT_PORTAL_ID,
HUBSPOT_ROOT_FORM_URL,
HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -54,6 +60,31 @@ def get_contact(self, user: "FFAdminUser") -> None | dict[str, Any]:

return results[0] # type: ignore[no-any-return]

def create_visitor_identification_token(self, user: "FFAdminUser") -> str | None:
"""
Create a token that identifies the user to the HubSpot chat widget.
HubSpot tokens are valid for 12 hours. Returns None if HubSpot fails.
"""
request = IdentificationTokenGenerationRequest(
email=user.email,
first_name=user.first_name or None,
last_name=user.last_name or None,
)
generate_api = self.client.conversations.visitor_identification.generate_api
try:
response = generate_api.generate_token(
identification_token_generation_request=request,
_request_timeout=HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS,
)
except (ApiException, urllib3.exceptions.HTTPError):
logger.exception(
"Failed to create HubSpot visitor identification token for user %d",
user.id,
)
return None

return response.token # type: ignore[no-any-return]

def create_lead_form(
self,
user: "FFAdminUser",
Expand Down
1 change: 1 addition & 0 deletions api/integrations/lead_tracking/hubspot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
HUBSPOT_FORM_ID_SAAS = "562ee023-fb3f-4645-a217-4d8c9b4e45be"
HUBSPOT_FORM_ID_SELF_HOSTED = "e79b8eca-a727-4188-a5ed-8f71c707ac50"
HUBSPOT_ROOT_FORM_URL = "https://api.hsforms.com/submissions/v3/integration/submit"
HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS = 5
HUBSPOT_API_LEAD_SOURCE_SELF_HOSTED = "self-hosted"
HUBSPOT_ACTIVE_SUBSCRIPTION_SELF_HOSTED = "free-opensource"

Expand Down
103 changes: 103 additions & 0 deletions api/tests/unit/custom_auth/test_unit_custom_auth_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from django.urls import reverse
from freezegun import freeze_time
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle

from users.models import FFAdminUser, HubspotTracker

Expand Down Expand Up @@ -335,3 +337,104 @@ def test_create_user__invalid_signup_anonymous_id__returns_400(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "signup_anonymous_id" in response.json()
assert not FFAdminUser.objects.filter(email="test@exemple.fr").exists()


def test_hubspot_chat_token__hubspot_configured__returns_token(
staff_user: FFAdminUser,
staff_client: APIClient,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
# Given
settings.HUBSPOT_ACCESS_TOKEN = "test-access-token"
mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient")
mock_create_token = (
mock_hubspot_client.return_value.create_visitor_identification_token
)
mock_create_token.return_value = "test-token"
url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token")

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"token": "test-token"}
mock_create_token.assert_called_once_with(staff_user)


def test_hubspot_chat_token__hubspot_not_configured__returns_404(
staff_client: APIClient,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
# Given
settings.HUBSPOT_ACCESS_TOKEN = None
mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient")
url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token")

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_hubspot_client.assert_not_called()


def test_hubspot_chat_token__hubspot_fails__returns_502(
staff_client: APIClient,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
# Given
settings.HUBSPOT_ACCESS_TOKEN = "test-access-token"
mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient")
mock_hubspot_client.return_value.create_visitor_identification_token.return_value = None
url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token")

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_502_BAD_GATEWAY


def test_hubspot_chat_token__unauthenticated__returns_401(
api_client: APIClient,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
# Given
settings.HUBSPOT_ACCESS_TOKEN = "test-access-token"
mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient")
url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token")

# When
response = api_client.get(url)

# Then
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_hubspot_client.assert_not_called()


def test_hubspot_chat_token__rate_exceeded__returns_429(
staff_client: APIClient,
reset_cache: None,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
# Given
settings.HUBSPOT_ACCESS_TOKEN = "test-access-token"
mocker.patch.dict(
ScopedRateThrottle.THROTTLE_RATES, {"hubspot_chat_token": "1/min"}
)
mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient")
mock_hubspot_client.return_value.create_visitor_identification_token.return_value = "test-token"
url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token")
staff_client.get(url)

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

import pytest
import responses
import urllib3
from hubspot.conversations.visitor_identification import ( # type: ignore[import-untyped]
ApiException,
)
from pytest_mock import MockerFixture
from rest_framework import status

Expand All @@ -11,6 +15,7 @@
HUBSPOT_FORM_ID_SAAS,
HUBSPOT_PORTAL_ID,
HUBSPOT_ROOT_FORM_URL,
HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS,
)
from tests.unit.integrations.lead_tracking.hubspot._hubspot_responses import (
generate_create_company_response,
Expand Down Expand Up @@ -265,3 +270,68 @@ def test_update_company__with_properties__calls_hubspot_api_with_expected_proper

simple_input = call_kwargs["simple_public_object_input"]
assert simple_input.properties == expected_properties


@pytest.mark.parametrize(
"first_name, last_name, expected_first_name, expected_last_name",
[
("Ada", "Lovelace", "Ada", "Lovelace"),
("", "", None, None),
],
)
def test_create_visitor_identification_token__hubspot_returns_token__returns_token(
db: None,
hubspot_client: HubspotClient,
first_name: str,
last_name: str,
expected_first_name: str | None,
expected_last_name: str | None,
) -> None:
# Given
user = FFAdminUser.objects.create(
email="chat@example.com", first_name=first_name, last_name=last_name
)
mock_generate_token = hubspot_client.client.conversations.visitor_identification.generate_api.generate_token
mock_generate_token.return_value.token = "test-token"

# When
token = hubspot_client.create_visitor_identification_token(user)

# Then
assert token == "test-token"
mock_generate_token.assert_called_once()
_, call_kwargs = mock_generate_token.call_args
request = call_kwargs["identification_token_generation_request"]
assert request.email == "chat@example.com"
assert request.first_name == expected_first_name
assert request.last_name == expected_last_name
assert (
call_kwargs["_request_timeout"]
== HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS
)


@pytest.mark.parametrize(
"error",
[
ApiException(status=403, reason="Forbidden"),
urllib3.exceptions.ReadTimeoutError(None, "", "Read timed out."), # type: ignore[arg-type]
],
)
def test_create_visitor_identification_token__hubspot_fails__logs_and_returns_none(
staff_user: FFAdminUser,
hubspot_client: HubspotClient,
caplog: pytest.LogCaptureFixture,
error: Exception,
) -> None:
# Given
hubspot_client.client.conversations.visitor_identification.generate_api.generate_token.side_effect = error

# When
token = hubspot_client.create_visitor_identification_token(staff_user)

# Then
assert token is None
assert caplog.messages == [
f"Failed to create HubSpot visitor identification token for user {staff_user.id}"
]
27 changes: 27 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,26 @@ paths:
- Master API Key: []
tags:
- Authentication
/api/v1/auth/users/me/hubspot-chat-token/:
get:
operationId: api_v1_auth_users_me_hubspot_chat_token_retrieve
description: Return a token that identifies the user to the HubSpot chat widget.
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/HubspotChatTokenResponse'
'404':
description: No response body
'502':
description: No response body
security:
- tokenAuth: []
- Master API Key: []
tags:
- Authentication
/api/v1/auth/users/me/onboarding/:
patch:
operationId: api_v1_auth_users_me_onboarding_partial_update
Expand Down Expand Up @@ -22983,6 +23003,13 @@ components:
maxLength: 200
required:
- api_key
HubspotChatTokenResponse:
type: object
properties:
token:
type: string
required:
- token
IdentifierOnlyIdentity:
type: object
properties:
Expand Down
Loading