diff --git a/backend/kesaseteli/applications/tests/test_application_validation.py b/backend/kesaseteli/applications/tests/test_application_validation.py index d2727c6a0b..840a5928e2 100644 --- a/backend/kesaseteli/applications/tests/test_application_validation.py +++ b/backend/kesaseteli/applications/tests/test_application_validation.py @@ -9,10 +9,11 @@ from applications.enums import AttachmentType, EmployerApplicationStatus from applications.models import School, validate_name, YouthApplication from applications.tests.test_applications_api import get_detail_url +from shared.common.tests.names import INVALID_NAMES, VALID_NAMES @pytest.mark.django_db -def test_validate_name_with_all_listed_schools(): +def test_validate_name_with_all_listed_schools(school_list): for school in School.objects.all(): validate_name(school.name) @@ -20,9 +21,13 @@ def test_validate_name_with_all_listed_schools(): @pytest.mark.django_db @pytest.mark.parametrize( "name", - [ + VALID_NAMES + + [ "Jokin muu koulu", "Testikoulu", + "Testikoulu 1", + "Testikoulu: Arabian yläaste", + "Yläaste (Arabia)", ], ) def test_validate_name_with_valid_unlisted_school(name): @@ -74,14 +79,7 @@ def clean_vtj_json_field(): @pytest.mark.django_db -@pytest.mark.parametrize( - "name", - [ - "Testikoulu 1", # Number is not allowed after the first character - "Testikoulu: Arabian yläaste", # Colon is not allowed - "Yläaste (Arabia)", # Parentheses are not allowed - ], -) +@pytest.mark.parametrize("name", INVALID_NAMES) def test_validate_name_with_invalid_unlisted_school(name): with pytest.raises(ValidationError): validate_name(name) diff --git a/backend/kesaseteli/applications/tests/test_youth_applications_api.py b/backend/kesaseteli/applications/tests/test_youth_applications_api.py index 70c9c8205b..ee5efddfe0 100644 --- a/backend/kesaseteli/applications/tests/test_youth_applications_api.py +++ b/backend/kesaseteli/applications/tests/test_youth_applications_api.py @@ -65,9 +65,13 @@ superuser_client, ) from shared.common.tests.factories import UserFactory +from shared.common.tests.names import INVALID_NAMES, VALID_NAMES from shared.common.tests.test_validators import get_invalid_postcode_values from shared.common.tests.utils import normalize_whitespace +# YouthApplication's fields that are validated as names +YOUTH_APPLICATION_NAME_FIELDS = ["first_name", "last_name", "school"] + # Mandatory fields of YouthSummerVoucher in youth summer voucher email MANDATORY_YOUTH_SUMMER_VOUCHER_FIELDS_IN_VOUCHER_EMAIL = [ "employer_summer_voucher_application_end_date_localized_string", @@ -1306,6 +1310,57 @@ def test_youth_application_post_valid_random_data( # noqa: C901 ), f"{read_only_field} created youth application attribute incorrect" +@override_settings( + NEXT_PUBLIC_MOCK_FLAG=False, + NEXT_PUBLIC_DISABLE_VTJ=True, +) +@pytest.mark.django_db +@pytest.mark.parametrize("name_field", YOUTH_APPLICATION_NAME_FIELDS) +@pytest.mark.parametrize("name", VALID_NAMES) +def test_youth_application_post_valid_non_ascii_names(api_client, name_field, name): + youth_application = YouthApplicationFactory.build() + data = YouthApplicationSerializer(youth_application).data + data[name_field] = name + response = api_client.post(get_list_url(), data) + + assert response.status_code == status.HTTP_201_CREATED + assert "id" in response.data + created_youth_application = YouthApplication.objects.get(pk=response.data["id"]) + assert getattr(created_youth_application, name_field) == name + + +@override_settings( + NEXT_PUBLIC_MOCK_FLAG=False, + NEXT_PUBLIC_DISABLE_VTJ=True, +) +@pytest.mark.django_db +@pytest.mark.parametrize("name_field", YOUTH_APPLICATION_NAME_FIELDS) +@pytest.mark.parametrize("name", VALID_NAMES) +def test_youth_application_post_names_with_whitespace(api_client, name_field, name): + youth_application = YouthApplicationFactory.build() + data = YouthApplicationSerializer(youth_application).data + whitespace = "\t\r\n " + data[name_field] = whitespace + name + whitespace + response = api_client.post(get_list_url(), data) + + assert response.status_code == status.HTTP_201_CREATED + assert "id" in response.data + created_youth_application = YouthApplication.objects.get(pk=response.data["id"]) + assert getattr(created_youth_application, name_field) == name.strip() + + +@pytest.mark.django_db +@pytest.mark.parametrize("name_field", YOUTH_APPLICATION_NAME_FIELDS) +@pytest.mark.parametrize("name", INVALID_NAMES) +def test_youth_application_post_invalid_names(api_client, name_field, name): + youth_application = YouthApplicationFactory.build() + data = YouthApplicationSerializer(youth_application).data + data[name_field] = name + response = api_client.post(get_list_url(), data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, NEXT_PUBLIC_DISABLE_VTJ=True, @@ -2528,6 +2583,7 @@ def test_youth_applications_set_excess_additional_info( [AdditionalInfoUserReason.OTHER], " \tLeading\n and trailing whitespace should get removed \n\t ", ), + *[([AdditionalInfoUserReason.OTHER], name) for name in VALID_NAMES], ] ], ) diff --git a/backend/shared/shared/common/tests/names.py b/backend/shared/shared/common/tests/names.py new file mode 100644 index 0000000000..df6fff89c2 --- /dev/null +++ b/backend/shared/shared/common/tests/names.py @@ -0,0 +1,59 @@ +ARABIC_NAME = "حَسَّان" # Ḥassān (benefactor) in Arabic +CHINESE_NAME = "慧芬" # Huì Fēn (wise scent) in Mandarin Chinese +ESTONIAN_NAME = "Õras" +FINNISH_NAME = "Matti Meikäläinen" +GERMAN_NAME = "Strauß Jünemann" +HEBREW_NAME = "אברהם" # Abraham (father of many) in Hebrew +ICELANDIC_FEMALE_NAME = "María Kristín Þorkelsdóttir" +ICELANDIC_MALE_NAME = "Ingólfur Álfheiður" +RUSSIAN_NAME = "Мельник" # Melnik (miller) in Russian +SHORT_CHINESE_NAME = "王" # Wáng (king) in Mandarin Chinese +SPANISH_NAME = "Peña" +SWEDISH_NAME = "Åse-Marie Öllegård" +THAI_NAME = "อาทิตย์" # Arthit (sun) in Thai +TURKISH_NAME = "Ümit" # Ümit (hope) in Turkish + +VALID_NAMES = [ + # should match Finnish first names, last names and full names + "Helinä", + "Aalto", + "Kalle Väyrynen", + "Janne Ö", + # should match Swedish first names, last names and full names + "Gun-Britt", + "Lindén", + "Ögge Ekström", + # should match English first names, last names and full names + "Eric", + "Bradtke", + "Daniela O'Brian", + # should match special characters + "!@#$%^&*()_+-=[]{}|;':\",./<>?", + # should match digits + "1234567890", + # should match more languages than just Finnish, Swedish, English + ARABIC_NAME, + CHINESE_NAME, + ESTONIAN_NAME, + FINNISH_NAME, + GERMAN_NAME, + HEBREW_NAME, + ICELANDIC_FEMALE_NAME, + ICELANDIC_MALE_NAME, + RUSSIAN_NAME, + SHORT_CHINESE_NAME, + SPANISH_NAME, + SWEDISH_NAME, + THAI_NAME, + TURKISH_NAME, +] + +INVALID_NAMES = [ + "", + " ", + "\t", + "\r", + "\n", + "\r\n", + " \t\r\n ", +] diff --git a/backend/shared/shared/common/tests/test_validators.py b/backend/shared/shared/common/tests/test_validators.py index 9bace36af8..2ce2442f85 100644 --- a/backend/shared/shared/common/tests/test_validators.py +++ b/backend/shared/shared/common/tests/test_validators.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.db import models +from shared.common.tests.names import INVALID_NAMES, VALID_NAMES from shared.common.validators import ( validate_json, validate_name, @@ -136,24 +137,7 @@ def test_validate_phone_number_with_invalid_input(value): # Based on frontend/shared/src/__tests__/constants.test.ts -@pytest.mark.parametrize( - "value", - [ - # should match Finnish first names, last names and full names - "Helinä", - "Aalto", - "Kalle Väyrynen", - "Janne Ö", - # should match Swedish first names, last names and full names - "Gun-Britt", - "Lindén", - "Ögge Ekström", - # should match English first names, last names and full names - "Eric", - "Bradtke", - "Daniela O'Brian", - ], -) +@pytest.mark.parametrize("value", VALID_NAMES) def test_validate_name_with_valid_input(value): validate_name(value) @@ -161,12 +145,10 @@ def test_validate_name_with_valid_input(value): # Based on frontend/shared/src/__tests__/constants.test.ts @pytest.mark.parametrize( "value", - [ - # should fail to match invalid characters - "!@#$%^&*()_+-=[]{}|;':\",./<>?", - # should fail to match digits - "1234567890", - ], + INVALID_NAMES + + [" " + name for name in VALID_NAMES] + + [name + " " for name in VALID_NAMES] + + [" " + name + " " for name in VALID_NAMES], ) def test_validate_name_with_invalid_input(value): with pytest.raises(ValidationError): diff --git a/backend/shared/shared/common/validators.py b/backend/shared/shared/common/validators.py index 6f8623c309..c31f1ca703 100644 --- a/backend/shared/shared/common/validators.py +++ b/backend/shared/shared/common/validators.py @@ -18,14 +18,10 @@ # \d in Javascript matches [0-9] and has been replaced: POSTAL_CODE_REGEX = r"^[0-9]{5}$" -# \w in Javascript matches [A-Za-z0-9_] and has been replaced: -NAMES_REGEX = r"^[A-Za-z0-9_',.ÄÅÖäåö-][^\d!#$%&()*+/:;<=>?@[\\\]_{|}~¡¿÷ˆ]+$" - # Please note that using a RegexValidator in a Django model field hardcodes the used # regular expression into the model and its migration but using a function does not. PHONE_NUMBER_REGEX_VALIDATOR = RegexValidator(PHONE_NUMBER_REGEX) POSTAL_CODE_REGEX_VALIDATOR = RegexValidator(POSTAL_CODE_REGEX) -NAMES_REGEX_VALIDATOR = RegexValidator(NAMES_REGEX) def validate_phone_number(phone_number) -> None: @@ -52,13 +48,15 @@ def validate_postcode(postcode) -> None: def validate_name(name) -> None: """ - Function wrapper for NAMES_REGEX_VALIDATOR. If used as a validator in a - Django model's field this does not hardcode the underlying regular expression into - the migration nor into the model. + Validates name to be a non-empty string with no trailing or leading whitespace. - Raise ValidationError if the given value doesn't pass NAMES_REGEX_VALIDATOR. + Raise ValidationError if the given value is not a non-empty string with no trailing + or leading whitespace. """ - NAMES_REGEX_VALIDATOR(name) + if not (isinstance(name, str) and name == name.strip() and name.strip()): + raise ValidationError( + "Name must be a non-empty string with no trailing or leading whitespace" + ) def validate_json(value) -> None: