diff --git a/activate_account/factories.py b/activate_account/factories.py index 56c0e7f1bce2a354b1349946c4be0f0e712c63b6..29c43de73d844cf13e95b5324d771001d592b421 100644 --- a/activate_account/factories.py +++ b/activate_account/factories.py @@ -69,9 +69,24 @@ class AccountIdentifierFactory(factory.django.DjangoModelFactory): model = AccountIdentifier account_id = factory.SubFactory(AccountFactory) - last_name = factory.LazyAttribute(lambda n: factory.Faker._get_faker().last_name().upper()) date_of_birth = factory.Faker("date_of_birth") + @factory.lazy_attribute + def last_name(self): + # Ideally we'd duplicate what is in AccountDetails but not all Accounts have one + faker = factory.Faker._get_faker() + first_letter = self.account_id.crsid[0].upper() + second_letter = self.account_id.crsid[1].upper() + last_name = faker.last_name() + + attempts = 0 + max_attempts = 20 + while not last_name.startswith(second_letter) and attempts < max_attempts: + last_name = faker.last_name() + attempts += 1 + + return f"{last_name.upper()} {first_letter}" + @factory.lazy_attribute def code(self): code_type = random.choice(["staff", "postgraduate", "pgce", "undergraduate"]) diff --git a/authentication/serializers.py b/authentication/serializers.py index b8ff4b3da527118b66dd4e3de9132d7ff447d5ab..40dde903363883da9e8e8f887c7ada022ed3c457 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -123,6 +123,14 @@ class TokenRequestSerializer(serializers.Serializer): account_identifier__date_of_birth=data["date_of_birth"], account_identifier__code=data["code"], ) + except Account.MultipleObjectsReturned: + logger.warn( + "Multiple accounts found with normalised last name", + last_name=data.get("last_name"), + date_of_birth=data["date_of_birth"], + name_match=name_match, + ) + raise InvalidGrantError("Ambiguous token request") except Account.DoesNotExist: # then match last_name fields being just name_match account = Account.objects.get( diff --git a/authentication/tests/test_login_view.py b/authentication/tests/test_login_view.py index d5bc9d9eebb808e038ec96c15d26968e13cb2d2e..d953bc68ad24923b1b5d369f9aef22f2923618bb 100644 --- a/authentication/tests/test_login_view.py +++ b/authentication/tests/test_login_view.py @@ -1,3 +1,5 @@ +import random +from string import ascii_uppercase from unittest import mock from urllib.parse import urlencode @@ -6,6 +8,7 @@ from django.urls import reverse from knox.settings import knox_settings from rest_framework import exceptions, parsers, status +from activate_account.factories import AccountFactory, AccountIdentifierFactory from authentication.constants import SESSION_GRANT_TYPE from authentication.serializers import TokenRequestSerializer @@ -86,7 +89,7 @@ def test_valid_session_grant(client, valid_session_grant_body): @pytest.mark.parametrize("valid_session_grant_body", ["crsid"], indirect=True) -def test_valid_crsid_case_insensitive(request, client, valid_session_grant_body): +def test_valid_crsid_case_insensitive(client, valid_session_grant_body): """CRSId comparison is case insensitive""" field = "crsid" assert_is_valid_login( @@ -101,7 +104,7 @@ def test_valid_crsid_case_insensitive(request, client, valid_session_grant_body) @pytest.mark.parametrize("valid_session_grant_body", ["last_name"], indirect=True) -def test_valid_last_name_case_insensitive(request, client, valid_session_grant_body): +def test_valid_last_name_case_insensitive(client, valid_session_grant_body): """Last name comparison is case insensitive (due to normalisation)""" field = "last_name" assert_is_valid_login( @@ -116,7 +119,7 @@ def test_valid_last_name_case_insensitive(request, client, valid_session_grant_b @pytest.mark.parametrize("valid_session_grant_body", ["last_name"], indirect=True) -def test_valid_last_name_partial(request, client, valid_session_grant_body, faker): +def test_valid_last_name_partial(client, valid_session_grant_body, faker): """Last name comparison also matches partial (starting followed by space)""" field = "last_name" # Just the first part with random case @@ -136,6 +139,35 @@ def test_valid_last_name_partial(request, client, valid_session_grant_body, fake ) +@pytest.mark.parametrize("valid_session_grant_body", ["last_name"], indirect=True) +def test_valid_last_name_partial_multiple(client, valid_session_grant_body, account_identifier): + """Last name partial comparison could potentially match multiple accounts""" + just_first_part = account_identifier.last_name.split(" ")[0] + # Create another account identifier with the same date of birth, code and first part of last + # name (but different full last name) + while True: + alt_last_name = f"{just_first_part} {random.choice(ascii_uppercase)}" + if alt_last_name != account_identifier.last_name: + break + alt_account = AccountFactory() + AccountIdentifierFactory( + account_id=alt_account, + last_name=alt_last_name, + date_of_birth=account_identifier.date_of_birth, + code=account_identifier.code, + ) + assert_is_error( + post_login( + client, + { + **valid_session_grant_body, + "last_name": just_first_part, + }, + ), + "invalid_grant", + ) + + @pytest.mark.parametrize("valid_session_grant_body", ["crsid", "last_name"], indirect=True) def test_missing_field(client, request, valid_session_grant_body): """Missing a required field results in 'invalid_request'""" diff --git a/authentication/tests/test_token_request_serializer.py b/authentication/tests/test_token_request_serializer.py index e9144d26a4ba3df17a5c56fc8409af0fe1e52773..ae9fed8da1e851e7d5de77329d5821620600b83b 100644 --- a/authentication/tests/test_token_request_serializer.py +++ b/authentication/tests/test_token_request_serializer.py @@ -33,6 +33,7 @@ def test_missing_crsid_and_last_name(): } +@pytest.mark.django_db def test_empty_post_normalise_last_name(): data = { "grant_type": SESSION_GRANT_TYPE,