diff --git a/tdrs-backend/docs/api/set_profile.md b/tdrs-backend/docs/api/set_profile.md index bf720dd06..e16c6dc5f 100644 --- a/tdrs-backend/docs/api/set_profile.md +++ b/tdrs-backend/docs/api/set_profile.md @@ -1,12 +1,12 @@ # Set Profile -Accepts a POST request from [authenticated](api/authentication.md) users to update the first name and last name on their profile. +Accepts a PATCH request from [authenticated](api/authentication.md) users to update the first name and last name on their profile. ---- **Request**: -`Post` `v1/set_profile/` +`PATCH` `v1/set_profile/` Parameters: @@ -15,14 +15,24 @@ Parameters: - JSON request body with the following **required** fields : ``` { - "first_name":"John", - "last_name":"Jones" + "first_name": "John", + "last_name": "Jones", + "stt": { + "id": 1 + } } ``` -*Note:* +*Notes:* -- Authorization Protected +### Fields +* first_name: string (first name of user) +* last_name: string (last name of user) +* stt: object + * id: integer (id of the State, Tribe or Terrritory) + + +Authorization Protected **Response**: @@ -31,12 +41,27 @@ Content-Type application/json 200 Ok { - "first_name":"John", - "last_name":"Jones" + "first_name": "John", + "last_name": "Jones", + "stt": { + "id": 1, + "type": "state", + "code": "AL", + "name": "Alabama" + } } ``` -This will return a JSON response with the authenticated users first name and last name as defined in their request JSON. +This will return a JSON response with the authenticated user's data as defined in their request JSON. + +### Fields +* first_name: string (first name of user) +* last_name: string (last name of user) +* stt: object + * id: integer (id of the State, Tribe or Terrritory) + * type: string (identifies it as a State, Tribe or Territory) + * code: string (abbreviation) + * string (name of State, Tribe or Territory) ---- **Failure to Authenticate Response:** @@ -63,4 +88,4 @@ Content-Type application/json "first_name":["This field is required."], "last_name":["This field is required."] } -``` \ No newline at end of file +``` diff --git a/tdrs-backend/tdpservice/conftest.py b/tdrs-backend/tdpservice/conftest.py index cd2ede2d5..ff44896fe 100644 --- a/tdrs-backend/tdpservice/conftest.py +++ b/tdrs-backend/tdpservice/conftest.py @@ -3,6 +3,7 @@ from rest_framework.test import APIClient from tdpservice.users.test.factories import UserFactory +from tdpservice.stts.test.factories import STTFactory, RegionFactory @pytest.fixture(scope="function") @@ -15,3 +16,15 @@ def api_client(): def user(): """Return a basic, non-admin user.""" return UserFactory.create() + + +@pytest.fixture +def stt(): + """Return an STT.""" + return STTFactory.create() + + +@pytest.fixture +def region(): + """Return a region.""" + return RegionFactory.create() diff --git a/tdrs-backend/tdpservice/stts/serializers.py b/tdrs-backend/tdpservice/stts/serializers.py index 05541b084..cbd311b1b 100644 --- a/tdrs-backend/tdpservice/stts/serializers.py +++ b/tdrs-backend/tdpservice/stts/serializers.py @@ -23,6 +23,21 @@ def get_code(self, obj): return obj.code +class STTUpdateSerializer(serializers.ModelSerializer): + """STT serializer.""" + + class Meta: + """Metadata.""" + + model = STT + fields = ["id"] + extra_kwargs = {"id": {"read_only": False}} + + def to_representation(self, instance): + """Allow update with only the ID field.""" + return STTSerializer(instance).data + + class RegionSerializer(serializers.ModelSerializer): """Region serializer.""" diff --git a/tdrs-backend/tdpservice/stts/test/factories.py b/tdrs-backend/tdpservice/stts/test/factories.py new file mode 100644 index 000000000..18f3fced6 --- /dev/null +++ b/tdrs-backend/tdpservice/stts/test/factories.py @@ -0,0 +1,38 @@ +"""Generate test data for stts.""" + +import factory +from ..models import STT, Region + + +class RegionFactory(factory.django.DjangoModelFactory): + """Generate test data for regions.""" + + class Meta: + """Metadata for regions.""" + + model = "stts.Region" + + id = factory.Sequence(int) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + return Region.objects.create(*args, **kwargs) + + +class STTFactory(factory.django.DjangoModelFactory): + """Generate test data for stts.""" + + class Meta: + """Hardcoded metata data for stts.""" + + model = "stts.STT" + + id = factory.Sequence(int) + name = factory.Sequence(lambda n: "teststt%d" % n) + code = "TT" + type = "STATE" + region = factory.SubFactory(RegionFactory) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + return STT.objects.create(*args, **kwargs) diff --git a/tdrs-backend/tdpservice/urls.py b/tdrs-backend/tdpservice/urls.py index 3e61d83c8..2629207d9 100755 --- a/tdrs-backend/tdpservice/urls.py +++ b/tdrs-backend/tdpservice/urls.py @@ -5,18 +5,11 @@ from django.contrib import admin from django.urls import include, path, re_path, reverse_lazy from django.views.generic.base import RedirectView - -from rest_framework.routers import DefaultRouter - from .users.api.authorization_check import AuthorizationCheck from .users.api.login import TokenAuthorizationOIDC from .users.api.login_redirect_oidc import LoginRedirectOIDC from .users.api.logout import LogoutUser from .users.api.logout_redirect_oidc import LogoutRedirectOIDC -from .users import views - -router = DefaultRouter() -router.register("users", views.UserViewSet) urlpatterns = [ path("admin/", admin.site.urls), @@ -25,13 +18,12 @@ path("logout", LogoutUser.as_view(), name="logout"), path("logout/oidc", LogoutRedirectOIDC.as_view(), name="oidc-logout"), path("auth_check", AuthorizationCheck.as_view(), name="authorization-check"), + path("users/", include("tdpservice.users.urls")), path("stts/", include("tdpservice.stts.urls")), # the 'api-root' from django rest-frameworks default router # http://www.django-rest-framework.org/api-guide/routers/#defaultrouter re_path(r"^$", RedirectView.as_view(url=reverse_lazy("api-root"), permanent=False)), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -urlpatterns += router.urls - # Add 'prefix' to all urlpatterns to make it easier to version/group endpoints urlpatterns = [path("v1/", include(urlpatterns))] diff --git a/tdrs-backend/tdpservice/users/api/authorization_check.py b/tdrs-backend/tdpservice/users/api/authorization_check.py index ce3ce3839..05172ee75 100644 --- a/tdrs-backend/tdpservice/users/api/authorization_check.py +++ b/tdrs-backend/tdpservice/users/api/authorization_check.py @@ -3,7 +3,10 @@ from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.permissions import AllowAny +from django.middleware import csrf from django.utils import timezone +from ..serializers import UserProfileSerializer logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -14,23 +17,24 @@ class AuthorizationCheck(APIView): query_string = False pattern_name = "authorization-check" + permission_classes = [AllowAny] def get(self, request, *args, **kwargs): """Handle get request and authenticate user.""" user = request.user + serializer = UserProfileSerializer(user) if user.is_authenticated: auth_params = { "authenticated": True, - "user": { - "email": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - }, + "user": serializer.data, + "csrf": csrf.get_token(request), } logger.info( "Auth check PASS for user: %s on %s", user.username, timezone.now() ) - return Response(auth_params) + res = Response(auth_params) + res['Access-Control-Allow-Headers'] = "X-CSRFToken" + return res else: logger.info("Auth check FAIL for user on %s", timezone.now()) return Response({"authenticated": False}) diff --git a/tdrs-backend/tdpservice/users/serializers.py b/tdrs-backend/tdpservice/users/serializers.py index 546aa7a8e..3168e1a02 100644 --- a/tdrs-backend/tdpservice/users/serializers.py +++ b/tdrs-backend/tdpservice/users/serializers.py @@ -1,8 +1,14 @@ """Serialize user data.""" +import logging from rest_framework import serializers from .models import User +from tdpservice.stts.serializers import STTUpdateSerializer + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) class UserSerializer(serializers.ModelSerializer): @@ -48,17 +54,29 @@ class Meta: extra_kwargs = {"password": {"write_only": True}} -class SetUserProfileSerializer(serializers.ModelSerializer): +class UserProfileSerializer(serializers.ModelSerializer): """Serializer used for setting a user's profile.""" + stt = STTUpdateSerializer(required=True) + email = serializers.SerializerMethodField("get_email") + class Meta: """Metadata.""" model = User - fields = ["first_name", "last_name"] + fields = ["first_name", "last_name", "stt", "email"] """Enforce first and last name to be in API call and not empty""" extra_kwargs = { "first_name": {"allow_blank": False, "required": True}, "last_name": {"allow_blank": False, "required": True}, } + + def update(self, instance, validated_data): + """Update the user with the STT.""" + instance.stt_id = validated_data.pop("stt")["id"] + return super().update(instance, validated_data) + + def get_email(self, obj): + """Return the user's email address.""" + return obj.username diff --git a/tdrs-backend/tdpservice/users/test/conftest.py b/tdrs-backend/tdpservice/users/test/conftest.py index e3aa49f1f..b2a153bdb 100644 --- a/tdrs-backend/tdpservice/users/test/conftest.py +++ b/tdrs-backend/tdpservice/users/test/conftest.py @@ -14,4 +14,5 @@ def user_data(): "last_name": "Smith", "password": "correcthorsebatterystaple", "auth_token": "xxx", + "stt": "Michigan", } diff --git a/tdrs-backend/tdpservice/users/test/factories.py b/tdrs-backend/tdpservice/users/test/factories.py index 5dcdb0d06..f4b797905 100644 --- a/tdrs-backend/tdpservice/users/test/factories.py +++ b/tdrs-backend/tdpservice/users/test/factories.py @@ -2,6 +2,8 @@ import factory +from tdpservice.stts.test.factories import STTFactory + class UserFactory(factory.django.DjangoModelFactory): """Generate test data for users.""" @@ -13,13 +15,14 @@ class Meta: django_get_or_create = ("username",) id = factory.Faker("uuid4") - username = factory.Sequence(lambda n: f"testuser{n}") + username = factory.Sequence(lambda n: "testuser%d" % n) password = "test_password" # Static password so we can login. email = factory.Faker("email") first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") is_active = True is_staff = False + stt = factory.SubFactory(STTFactory) @classmethod def _create(cls, model_class, *args, **kwargs): diff --git a/tdrs-backend/tdpservice/users/test/test_api.py b/tdrs-backend/tdpservice/users/test/test_api.py index a5f848c3f..e0fbb1657 100644 --- a/tdrs-backend/tdpservice/users/test/test_api.py +++ b/tdrs-backend/tdpservice/users/test/test_api.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model import pytest from rest_framework import status +from ...stts.models import STT User = get_user_model() @@ -43,52 +44,95 @@ def test_create_user(api_client, user_data): def test_set_profile_data(api_client, user): """Test profile data can be set.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Joe", "last_name": "Bloggs"}, + {"first_name": "Joe", "last_name": "Bloggs", "stt": {"id": stt.id}}, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Joe", "last_name": "Bloggs"} + assert response.data == { + "email": user.username, + "first_name": "Joe", + "last_name": "Bloggs", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Joe" assert user.last_name == "Bloggs" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_last_name_apostrophe(api_client, user): """Test profile data last name can be set with an apostrophe.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Mike", "last_name": "O'Hare"}, + {"first_name": "Mike", "last_name": "O'Hare", "stt": {"id": stt.id}}, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Mike", "last_name": "O'Hare"} + assert response.data == { + "email": user.username, + "first_name": "Mike", + "last_name": "O'Hare", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Mike" assert user.last_name == "O'Hare" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_first_name_apostrophe(api_client, user): """Test profile data first name can be set with an apostrophe.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Pat'Jack", "last_name": "Smith"}, + { + "first_name": "Pat'Jack", + "last_name": "Smith", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Pat'Jack", "last_name": "Smith"} + assert response.data == { + "email": user.username, + "first_name": "Pat'Jack", + "last_name": "Smith", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Pat'Jack" assert user.last_name == "Smith" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_empty_first_name(api_client, user): """Test profile data cannot be be set if first name is blank.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + response = api_client.patch( "/v1/users/set_profile/", {"first_name": "", "last_name": "Jones"}, ) @@ -99,7 +143,7 @@ def test_set_profile_data_empty_first_name(api_client, user): def test_set_profile_data_empty_last_name(api_client, user): """Test profile data cannot be set last name is blank.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + response = api_client.patch( "/v1/users/set_profile/", {"first_name": "John", "last_name": ""}, ) @@ -110,7 +154,7 @@ def test_set_profile_data_empty_last_name(api_client, user): def test_set_profile_data_empty_first_name_and_last_name(api_client, user): """Test profile data cannot be set if first and last name are blank.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + response = api_client.patch( "/v1/users/set_profile/", {"first_name": "", "last_name": ""}, ) @@ -121,90 +165,192 @@ def test_set_profile_data_empty_first_name_and_last_name(api_client, user): def test_set_profile_data_special_last_name(api_client, user): """Test profile data can be set if last name has multipe special characters.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "John", "last_name": "Smith-O'Hare"}, + { + "first_name": "John", + "last_name": "Smith-O'Hare", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "John", "last_name": "Smith-O'Hare"} + assert response.data == { + "email": user.username, + "first_name": "John", + "last_name": "Smith-O'Hare", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "John" assert user.last_name == "Smith-O'Hare" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_special_first_name(api_client, user): """Test profile data can be set if first name has multiple special characters.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "John-Tom'", "last_name": "Jacobs"}, + { + "first_name": "John-Tom'", + "last_name": "Jacobs", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "John-Tom'", "last_name": "Jacobs"} + assert response.data == { + "email": user.username, + "first_name": "John-Tom'", + "last_name": "Jacobs", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "John-Tom'" assert user.last_name == "Jacobs" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_spaced_last_name(api_client, user): """Test profile data can be set if last name has a space.""" + stt = STT.objects.first() api_client.login(username=user.username, password="test_password") - response = api_client.post( + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Joan", "last_name": "Mary Ann"}, + { + "first_name": "Joan", + "last_name": "Mary Ann", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Joan", "last_name": "Mary Ann"} + assert response.data == { + "email": user.username, + "first_name": "Joan", + "last_name": "Mary Ann", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Joan" assert user.last_name == "Mary Ann" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_spaced_first_name(api_client, user): """Test profile data can be set if first name has a space.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "John Jim", "last_name": "Smith"}, + { + "first_name": "John Jim", + "last_name": "Smith", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "John Jim", "last_name": "Smith"} + assert response.data == { + "email": user.username, + "first_name": "John Jim", + "last_name": "Smith", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "John Jim" assert user.last_name == "Smith" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_last_name_with_tilde_over_char(api_client, user): """Test profile data can be set if last name includes a tilde character.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Max", "last_name": "Grecheñ"}, + { + "first_name": "Max", + "last_name": "Grecheñ", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Max", "last_name": "Grecheñ"} + assert response.data == { + "email": user.username, + "first_name": "Max", + "last_name": "Grecheñ", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Max" assert user.last_name == "Grecheñ" + assert user.stt.name == stt.name @pytest.mark.django_db def test_set_profile_data_last_name_with_tilde(api_client, user): """Test profile data can be set if last name includes alternate tilde character.""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", - {"first_name": "Max", "last_name": "Glen~"}, + { + "first_name": "Max", + "last_name": "Glen~", + "stt": {"id": stt.id}, + }, + format="json", ) assert response.status_code == status.HTTP_200_OK - assert response.data == {"first_name": "Max", "last_name": "Glen~"} + assert response.data == { + "email": user.username, + "first_name": "Max", + "last_name": "Glen~", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Max" assert user.last_name == "Glen~" + assert user.stt.name == stt.name @pytest.mark.django_db @@ -213,20 +359,34 @@ def test_set_profile_data_extra_field_include_required(api_client, user): with pytest.raises(AttributeError): """This test will fail if it does not trigger an AttributeError exception""" api_client.login(username=user.username, password="test_password") - response = api_client.post( + stt = STT.objects.first() + response = api_client.patch( "/v1/users/set_profile/", { "first_name": "Heather", "last_name": "Class", "middle_initial": "Unknown", + "stt": {"id": stt.id}, }, + format="json", ) assert response.status_code == status.HTTP_200_OK """Test to ensure response data does not include unknown field""" - assert response.data == {"first_name": "Heather", "last_name": "Class"} + assert response.data == { + "email": user.username, + "first_name": "Heather", + "last_name": "Class", + "stt": { + "id": stt.id, + "type": stt.type, + "code": stt.code, + "name": stt.name, + }, + } user.refresh_from_db() assert user.first_name == "Heather" assert user.last_name == "Class" + assert user.stt.name == stt.name """Test fails if AttributeError exception isn't thrown""" assert user.middle_name == "Unknown" @@ -235,7 +395,12 @@ def test_set_profile_data_extra_field_include_required(api_client, user): def test_set_profile_data_missing_last_name_field(api_client, user): """Test profile data cannot be set if last name field is missing.""" api_client.login(username=user.username, password="test_password") - response = api_client.post("/v1/users/set_profile/", {"first_name": "Heather"}) + response = api_client.patch( + "/v1/users/set_profile/", + { + "first_name": "Heather", + }, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -243,5 +408,10 @@ def test_set_profile_data_missing_last_name_field(api_client, user): def test_set_profile_data_missing_first_name_field(api_client, user): """Test profile data cannot be set if first name field is missing.""" api_client.login(username=user.username, password="test_password") - response = api_client.post("/v1/users/set_profile/", {"last_name": "Heather"}) + response = api_client.patch( + "/v1/users/set_profile/", + { + "last_name": "Heather", + }, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/tdrs-backend/tdpservice/users/test/test_auth_check.py b/tdrs-backend/tdpservice/users/test/test_auth_check.py index 9fde7a388..cdc4fef18 100644 --- a/tdrs-backend/tdpservice/users/test/test_auth_check.py +++ b/tdrs-backend/tdpservice/users/test/test_auth_check.py @@ -3,13 +3,15 @@ import pytest from django.urls import reverse from rest_framework import status +from ..serializers import UserProfileSerializer @pytest.mark.django_db -def test_auth_check_endpoint_with_no_user(api_client, user): - """If there is no user auth_check should return FORBIDDEN.""" +def test_auth_check_endpoint_with_no_user(api_client): + """If there is no user auth_check should return 200 with unauthorized message.""" response = api_client.get(reverse("authorization-check")) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK + assert response.data["authenticated"] is False @pytest.mark.django_db @@ -18,6 +20,8 @@ def test_auth_check_endpoint_with_authenticated_user(api_client, user): api_client.login(username=user.username, password="test_password") response = api_client.get(reverse("authorization-check")) assert response.status_code == status.HTTP_200_OK + assert user.is_authenticated is True + assert response.data["authenticated"] is True @pytest.mark.django_db @@ -25,7 +29,8 @@ def test_auth_check_endpoint_with_bad_user(api_client): """If the user doesn't exist, auth_check should not authenticate.""" api_client.login(username="nonexistent", password="test_password") response = api_client.get(reverse("authorization-check")) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK + assert response.data["authenticated"] is False @pytest.mark.django_db @@ -33,7 +38,8 @@ def test_auth_check_endpoint_with_unauthorized_email(api_client): """If the user has an email address not in the system it should not authenticate.""" api_client.login(username="bademail@example.com", password="test_password") response = api_client.get(reverse("authorization-check")) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK + assert response.data["authenticated"] is False @pytest.mark.django_db @@ -51,3 +57,12 @@ def test_auth_check_returns_user_email(api_client, user): api_client.login(username=user.username, password="test_password") response = api_client.get(reverse("authorization-check")) assert response.data["user"]["email"] == user.username + + +@pytest.mark.django_db +def test_auth_check_returns_user_stt(api_client, user): + """If user is authenticated auth_check should return user data.""" + api_client.login(username=user.username, password="test_password") + serializer = UserProfileSerializer(user) + response = api_client.get(reverse("authorization-check")) + assert response.data["user"]["stt"] == serializer.data["stt"] diff --git a/tdrs-backend/tdpservice/users/urls.py b/tdrs-backend/tdpservice/users/urls.py new file mode 100644 index 000000000..61273758e --- /dev/null +++ b/tdrs-backend/tdpservice/users/urls.py @@ -0,0 +1,12 @@ +"""Routing for Users.""" + +from rest_framework.routers import DefaultRouter +from . import views + +router = DefaultRouter() + +router.register("", views.UserViewSet) + +urlpatterns = [] + +urlpatterns += router.urls diff --git a/tdrs-backend/tdpservice/users/views.py b/tdrs-backend/tdpservice/users/views.py index 29a8036b9..faaf52318 100644 --- a/tdrs-backend/tdpservice/users/views.py +++ b/tdrs-backend/tdpservice/users/views.py @@ -11,7 +11,7 @@ from django.utils import timezone from .serializers import ( CreateUserSerializer, - SetUserProfileSerializer, + UserProfileSerializer, UserSerializer, ) @@ -26,7 +26,7 @@ class UserViewSet( ): """User accounts viewset.""" - queryset = User.objects.all() + queryset = User.objects.select_related("stt") def get_permissions(self): """Get permissions for the viewset.""" @@ -40,10 +40,10 @@ def get_serializer_class(self): """Return the serializer class.""" return { "create": CreateUserSerializer, - "set_profile": SetUserProfileSerializer, + "set_profile": UserProfileSerializer, }.get(self.action, UserSerializer) - @action(methods=["POST"], detail=False) + @action(methods=["PATCH"], detail=False) def set_profile(self, request, pk=None): """Set a user's profile data.""" serializer = self.get_serializer(self.request.user, request.data) diff --git a/tdrs-frontend/package.json b/tdrs-frontend/package.json index 9fb3a5d73..7cf87738d 100755 --- a/tdrs-frontend/package.json +++ b/tdrs-frontend/package.json @@ -7,6 +7,7 @@ "@fortawesome/free-solid-svg-icons": "^5.14.0", "@fortawesome/react-fontawesome": "^0.1.11", "axios": "^0.20.0", + "axios-cookiejar-support": "^1.0.1", "babel-eslint": "10", "classnames": "^2.2.6", "concurrently": "^5.3.0", @@ -28,6 +29,7 @@ "redux-oidc": "^4.0.0-beta1", "redux-thunk": "^2.3.0", "seamless-immutable": "^7.1.3", + "tough-cookie": "^4.0.0", "typescript": "^3.9.7", "uswds": "^2.9.0" }, diff --git a/tdrs-frontend/src/App.js b/tdrs-frontend/src/App.js index adca6fad3..214d9cf03 100644 --- a/tdrs-frontend/src/App.js +++ b/tdrs-frontend/src/App.js @@ -17,7 +17,15 @@ import Footer from './components/Footer' function App() { return ( <> - + { + if (e.charCode === 32) { + window.location.href = '#main-content' + } + }} + > Skip to main content diff --git a/tdrs-frontend/src/App.test.js b/tdrs-frontend/src/App.test.js index ea83b7d13..df45dac16 100644 --- a/tdrs-frontend/src/App.test.js +++ b/tdrs-frontend/src/App.test.js @@ -2,12 +2,68 @@ import React from 'react' import { shallow } from 'enzyme' import GovBanner from './components/GovBanner' +import Header from './components/Header' +import { Alert } from './components/Alert' import App from './App' describe('App.js', () => { + afterEach(() => { + window.location.href = '' + }) + it('renders the Gov Banner', () => { const wrapper = shallow() expect(wrapper.find(GovBanner)).toExist() }) + + it('renders the Header', () => { + const wrapper = shallow() + expect(wrapper.find(Header)).toExist() + }) + + it('renders the Alert', () => { + const wrapper = shallow() + expect(wrapper.find(Alert)).toExist() + }) + + it('should redirect to #main-content when space bar is pressed on "skip links" element', () => { + const url = '#main-content' + + global.window = Object.create(window) + Object.defineProperty(window, 'location', { + value: { + href: url, + }, + }) + + const wrapper = shallow() + + const skipLink = wrapper.find('.usa-skipnav') + skipLink.simulate('keyPress', { + charCode: 32, + }) + + expect(window.location.href).toEqual(url) + }) + + it('should do nothing if any key besides space bar is pressed', () => { + const url = '' + + global.window = Object.create(window) + Object.defineProperty(window, 'location', { + value: { + href: url, + }, + }) + + const wrapper = shallow() + + const skipLink = wrapper.find('.usa-skipnav') + skipLink.simulate('keyPress', { + charCode: 25, + }) + + expect(window.location.href).toEqual(url) + }) }) diff --git a/tdrs-frontend/src/__mocks__/axios.js b/tdrs-frontend/src/__mocks__/axios.js index 6ce7201e2..5ab0ebdc6 100644 --- a/tdrs-frontend/src/__mocks__/axios.js +++ b/tdrs-frontend/src/__mocks__/axios.js @@ -1,10 +1,13 @@ /** * Mocks axios GET requests. */ -export default { - get: jest.fn(() => - Promise.resolve({ - data: {}, - }) - ), -} + +const mockAxios = jest.genMockFromModule('axios') +mockAxios.create = jest.fn(() => mockAxios) +mockAxios.get = jest.fn(() => + Promise.resolve({ + data: {}, + }) +) + +export default mockAxios diff --git a/tdrs-frontend/src/actions/auth.js b/tdrs-frontend/src/actions/auth.js index 677581992..c9dbfdd01 100644 --- a/tdrs-frontend/src/actions/auth.js +++ b/tdrs-frontend/src/actions/auth.js @@ -1,4 +1,4 @@ -import axios from 'axios' +import utils from '../utils' export const FETCH_AUTH = 'FETCH_AUTH' export const SET_AUTH = 'SET_AUTH' @@ -35,15 +35,20 @@ export const CLEAR_AUTH = 'CLEAR_AUTH' * the authentication data in the Redux store is cleared, and the user * is considered 'logged out', and can no longer access private routes. */ + export const fetchAuth = () => async (dispatch) => { dispatch({ type: FETCH_AUTH }) try { const URL = `${process.env.REACT_APP_BACKEND_URL}/auth_check` + const instance = utils.axiosInstance const { - data: { user }, - } = await axios.get(URL, { + data: { user, csrf }, + } = await instance.get(URL, { withCredentials: true, }) + + instance.defaults.headers['X-CSRFToken'] = csrf + if (user) { dispatch({ type: SET_AUTH, payload: { user } }) } else { diff --git a/tdrs-frontend/src/actions/requestAccess.js b/tdrs-frontend/src/actions/requestAccess.js new file mode 100644 index 000000000..648b6afb7 --- /dev/null +++ b/tdrs-frontend/src/actions/requestAccess.js @@ -0,0 +1,33 @@ +// import axios from 'axios' +import { SET_AUTH } from './auth' +import utils from '../utils' + +export const PATCH_REQUEST_ACCESS = 'PATCH_REQUEST_ACCESS' +export const SET_REQUEST_ACCESS = 'SET_REQUEST_ACCESS' +export const SET_REQUEST_ACCESS_ERROR = 'SET_REQUEST_ACCESS_ERROR' +export const CLEAR_REQUEST_ACCESS = 'CLEAR_REQUEST_ACCESS' + +export const requestAccess = ({ firstName, lastName, stt: { id } }) => async ( + dispatch +) => { + dispatch({ type: PATCH_REQUEST_ACCESS }) + try { + const URL = `${process.env.REACT_APP_BACKEND_URL}/users/set_profile/` + const user = { first_name: firstName, last_name: lastName, stt: { id } } + const { data } = await (await utils.axiosInstance).patch(URL, user, { + withCredentials: true, + }) + + if (data) { + dispatch({ type: SET_REQUEST_ACCESS }) + dispatch({ + type: SET_AUTH, + payload: { user: data }, + }) + } else { + dispatch({ type: CLEAR_REQUEST_ACCESS }) + } + } catch (error) { + dispatch({ type: SET_REQUEST_ACCESS_ERROR, payload: { error } }) + } +} diff --git a/tdrs-frontend/src/actions/requestAccess.test.js b/tdrs-frontend/src/actions/requestAccess.test.js new file mode 100644 index 000000000..215e8211d --- /dev/null +++ b/tdrs-frontend/src/actions/requestAccess.test.js @@ -0,0 +1,77 @@ +import axios from 'axios' +import thunk from 'redux-thunk' +import configureStore from 'redux-mock-store' + +import { + requestAccess, + PATCH_REQUEST_ACCESS, + SET_REQUEST_ACCESS, + SET_REQUEST_ACCESS_ERROR, + CLEAR_REQUEST_ACCESS, +} from './requestAccess' + +describe('actions/requestAccess.js', () => { + const mockStore = configureStore([thunk]) + + it('sends a PATCH request when requestAccess is called', async () => { + axios.patch = jest.fn().mockResolvedValue({ + data: { + first_name: 'harry', + last_name: 'potter', + stt: { + code: 'AK', + id: 2, + name: 'Alaska', + type: 'state', + }, + }, + }) + const profileInfo = { + firstName: 'harry', + lastName: 'potter', + stt: { id: 1 }, + } + + const store = mockStore() + + await store.dispatch(requestAccess(profileInfo)) + + const actions = store.getActions() + expect(actions[0].type).toBe(PATCH_REQUEST_ACCESS) + expect(actions[1].type).toBe(SET_REQUEST_ACCESS) + }) + + it('clears the request access state if there is no data returned from the API', async () => { + axios.patch = jest.fn().mockResolvedValue({}) + const profileInfo = { + firstName: 'harry', + lastName: 'potter', + stt: { id: 1 }, + } + + const store = mockStore() + + await store.dispatch(requestAccess(profileInfo)) + + const actions = store.getActions() + expect(actions[0].type).toBe(PATCH_REQUEST_ACCESS) + expect(actions[1].type).toBe(CLEAR_REQUEST_ACCESS) + }) + + it('dispatches an error to the store if the API errors', async () => { + axios.patch = jest.fn().mockRejectedValue(new Error('threw an error')) + const profileInfo = { + firstName: 'harry', + lastName: 'potter', + stt: { id: 1 }, + } + + const store = mockStore() + + await store.dispatch(requestAccess(profileInfo)) + + const actions = store.getActions() + expect(actions[0].type).toBe(PATCH_REQUEST_ACCESS) + expect(actions[1].type).toBe(SET_REQUEST_ACCESS_ERROR) + }) +}) diff --git a/tdrs-frontend/src/actions/stts.js b/tdrs-frontend/src/actions/sttList.js similarity index 87% rename from tdrs-frontend/src/actions/stts.js rename to tdrs-frontend/src/actions/sttList.js index d1181bb45..6c162cf1a 100644 --- a/tdrs-frontend/src/actions/stts.js +++ b/tdrs-frontend/src/actions/sttList.js @@ -29,7 +29,7 @@ export const CLEAR_STTS = 'CLEAR_STTS' * If an API error occurs, SET_STTS_ERROR is dispatched * and the error is set in the Redux store. */ -export const fetchStts = () => async (dispatch) => { +export const fetchSttList = () => async (dispatch) => { dispatch({ type: FETCH_STTS }) try { const URL = `${process.env.REACT_APP_BACKEND_URL}/stts/alpha` @@ -38,6 +38,12 @@ export const fetchStts = () => async (dispatch) => { }) if (data) { + data.forEach((item, i) => { + if (item.name === 'Federal Government') { + data.splice(i, 1) + data.unshift(item) + } + }) dispatch({ type: SET_STTS, payload: { data } }) } else { dispatch({ type: CLEAR_STTS }) diff --git a/tdrs-frontend/src/actions/stts.test.js b/tdrs-frontend/src/actions/sttList.test.js similarity index 57% rename from tdrs-frontend/src/actions/stts.test.js rename to tdrs-frontend/src/actions/sttList.test.js index 25ff23af8..c7db739f0 100644 --- a/tdrs-frontend/src/actions/stts.test.js +++ b/tdrs-frontend/src/actions/sttList.test.js @@ -3,12 +3,12 @@ import thunk from 'redux-thunk' import configureStore from 'redux-mock-store' import { - fetchStts, + fetchSttList, FETCH_STTS, SET_STTS, SET_STTS_ERROR, CLEAR_STTS, -} from './stts' +} from './sttList' describe('actions/stts.js', () => { const mockStore = configureStore([thunk]) @@ -21,7 +21,7 @@ describe('actions/stts.js', () => { ) const store = mockStore() - await store.dispatch(fetchStts()) + await store.dispatch(fetchSttList()) const actions = store.getActions() expect(actions[0].type).toBe(FETCH_STTS) @@ -31,11 +31,43 @@ describe('actions/stts.js', () => { ]) }) + it('fetches a list of stts and puts "Federal Government" as the first option if it exists, when the user is authenticated', async () => { + axios.get.mockImplementationOnce(() => + Promise.resolve({ + data: [ + { id: 1, type: 'state', code: 'AL', name: 'Alabama' }, + { + id: 57, + type: 'territory', + code: 'US', + name: 'Federal Government', + }, + ], + }) + ) + const store = mockStore() + + await store.dispatch(fetchSttList()) + + const actions = store.getActions() + expect(actions[0].type).toBe(FETCH_STTS) + expect(actions[1].type).toBe(SET_STTS) + expect(actions[1].payload.data).toStrictEqual([ + { + id: 57, + type: 'territory', + code: 'US', + name: 'Federal Government', + }, + { id: 1, type: 'state', code: 'AL', name: 'Alabama' }, + ]) + }) + it('clears the stt state, if user is not authenticated', async () => { axios.get.mockImplementationOnce(() => Promise.resolve({ test: {} })) const store = mockStore() - await store.dispatch(fetchStts()) + await store.dispatch(fetchSttList()) const actions = store.getActions() expect(actions[0].type).toBe(FETCH_STTS) @@ -48,7 +80,7 @@ describe('actions/stts.js', () => { ) const store = mockStore() - await store.dispatch(fetchStts()) + await store.dispatch(fetchSttList()) const actions = store.getActions() expect(actions[0].type).toBe(FETCH_STTS) diff --git a/tdrs-frontend/src/components/EditProfile/EditProfile.jsx b/tdrs-frontend/src/components/EditProfile/EditProfile.jsx index da5627245..4b7229321 100644 --- a/tdrs-frontend/src/components/EditProfile/EditProfile.jsx +++ b/tdrs-frontend/src/components/EditProfile/EditProfile.jsx @@ -1,7 +1,14 @@ import React, { useState, useEffect, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { fetchStts } from '../../actions/stts' +import { Redirect } from 'react-router-dom' + +import { fetchSttList } from '../../actions/sttList' +import { requestAccess } from '../../actions/requestAccess' +import { setAlert } from '../../actions/alert' +import { ALERT_ERROR } from '../Alert' + import Button from '../Button' +import FormGroup from '../FormGroup' import ComboBox from '../ComboBox' /** @@ -44,7 +51,13 @@ export const validation = (fieldName, fieldValue) => { */ function EditProfile() { const errorRef = useRef(null) - const stts = useSelector((state) => state.stts.stts) + const sttList = useSelector((state) => state.stts.sttList) + const requestedAccess = useSelector( + (state) => state.requestAccess.requestAccess + ) + const requestAccessError = useSelector((state) => state.requestAccess.error) + const sttAssigned = useSelector((state) => state.auth.user.stt) + const dispatch = useDispatch() const [profileInfo, setProfileInfo] = useState({ @@ -58,15 +71,24 @@ function EditProfile() { const [touched, setTouched] = useState({}) useEffect(() => { - dispatch(fetchStts()) - }, [dispatch]) + if (requestAccessError) { + dispatch( + setAlert({ heading: requestAccessError.message, type: ALERT_ERROR }) + ) + } + dispatch(fetchSttList()) + }, [dispatch, requestAccessError]) const setStt = (sttName) => { - let selectedStt = stts.find((stt) => sttName === stt.name.toLowerCase()) + let selectedStt = sttList.find((stt) => sttName === stt.name.toLowerCase()) if (!selectedStt) selectedStt = '' setProfileInfo({ ...profileInfo, stt: selectedStt }) } + const handleChange = ({ name, value }) => { + setProfileInfo({ ...profileInfo, [name]: value }) + } + const handleBlur = (evt) => { const { name, value } = evt.target @@ -79,7 +101,6 @@ function EditProfile() { ...(error && { [name]: touched[name] && error }), }) } - const handleSubmit = (evt) => { evt.preventDefault() @@ -106,13 +127,23 @@ function EditProfile() { ) setErrors(formValidation.errors) setTouched(formValidation.touched) - setTimeout(() => errorRef.current.focus(), 0) + + if (!Object.values(formValidation.errors).length) { + return dispatch(requestAccess(profileInfo)) + } + return setTimeout(() => errorRef.current.focus(), 0) + } + + if (requestedAccess && sttAssigned) { + return } return (
-

Request Access

-

+

+ Request Access +

+

Please enter your information to request access from an OFA administrator

@@ -129,70 +160,22 @@ function EditProfile() { > There are {Object.keys(errors).length} errors in this form
-
- -
-
- -
+ +
- {stts.map((stt) => ( + {sttList.map((stt) => (