Skip to content
Snippets Groups Projects
Commit 46641cdc authored by Henrich Neumann's avatar Henrich Neumann
Browse files

Disallow certain characters in first and last name on creation of invitation and creation of guest

When a sponsor creates a new guest invitation, and when a guest corrects their name
they must now use transcribed characters.
parent fc79a551
No related branches found
No related tags found
1 merge request!410Disallow certain characters in first and last name on creation of invitation and creation of guest
Pipeline #221109 failed
...@@ -6583,13 +6583,23 @@ ...@@ -6583,13 +6583,23 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001271", "version": "1.0.30001512",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz",
"integrity": "sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==", "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==",
"funding": { "funding": [
"type": "opencollective", {
"url": "https://opencollective.com/browserslist" "type": "opencollective",
} "url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
}, },
"node_modules/capture-exit": { "node_modules/capture-exit": {
"version": "2.0.0", "version": "2.0.0",
...@@ -28885,9 +28895,9 @@ ...@@ -28885,9 +28895,9 @@
} }
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001271", "version": "1.0.30001512",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz",
"integrity": "sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==" "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw=="
}, },
"capture-exit": { "capture-exit": {
"version": "2.0.0", "version": "2.0.0",
...@@ -125,7 +125,9 @@ ...@@ -125,7 +125,9 @@
"nationalIdNumber": "Norwegian national ID number", "nationalIdNumber": "Norwegian national ID number",
"validation": { "validation": {
"firstNameRequired": "First name is required", "firstNameRequired": "First name is required",
"firstNameContainsInvalidChars": "First name contains invalid characters",
"lastNameRequired": "Last name is required", "lastNameRequired": "Last name is required",
"lastNameContainsInvalidChars": "Last name contains invalid characters",
"dateOfBirthRequired": "Date of birth is required", "dateOfBirthRequired": "Date of birth is required",
"invalidIdNumber": "Invalid Norwegian national ID number", "invalidIdNumber": "Invalid Norwegian national ID number",
"nationalIdNumberRequired": "Norwegian national ID number required", "nationalIdNumberRequired": "Norwegian national ID number required",
......
...@@ -125,7 +125,9 @@ ...@@ -125,7 +125,9 @@
"nationalIdNumber": "Fødselsnummer/D-nummer", "nationalIdNumber": "Fødselsnummer/D-nummer",
"validation": { "validation": {
"firstNameRequired": "Fornavn er obligatorisk", "firstNameRequired": "Fornavn er obligatorisk",
"firstNameContainsInvalidChars": "Fornavn inneholder ugyldige karakterer",
"lastNameRequired": "Etternavn er obligatorisk", "lastNameRequired": "Etternavn er obligatorisk",
"lastNameContainsInvalidChars": "Etternavn inneholder ugyldige karakterer",
"dateOfBirthRequired": "Fødselsdato er obligatorisk", "dateOfBirthRequired": "Fødselsdato er obligatorisk",
"invalidIdNumber": "Ugyldig fødselsnummer/D-nummer", "invalidIdNumber": "Ugyldig fødselsnummer/D-nummer",
"nationalIdNumberRequired": "Fødselsnummer/D-nummer er obligatorisk", "nationalIdNumberRequired": "Fødselsnummer/D-nummer er obligatorisk",
......
...@@ -125,7 +125,9 @@ ...@@ -125,7 +125,9 @@
"nationalIdNumber": "Fødselsnummer/D-nummer", "nationalIdNumber": "Fødselsnummer/D-nummer",
"validation": { "validation": {
"firstNameRequired": "Fornamn er påkrevd", "firstNameRequired": "Fornamn er påkrevd",
"firstNameContainsInvalidChars": "Fornamn inneheld ugyldige karakterar",
"lastNameRequired": "Etternamn er påkrevd", "lastNameRequired": "Etternamn er påkrevd",
"lastNameContainsInvalidChars": "Etternamn inneheld ugyldige karakterar",
"dateOfBirthRequired": "Fødselsdato er påkrevd", "dateOfBirthRequired": "Fødselsdato er påkrevd",
"invalidIdNumber": "Ugyldig fødselsnummer/D-nummer", "invalidIdNumber": "Ugyldig fødselsnummer/D-nummer",
"nationalIdNumberRequired": "Fødselsnummer/D-nummer er påkrevd", "nationalIdNumberRequired": "Fødselsnummer/D-nummer er påkrevd",
...@@ -235,4 +237,4 @@ ...@@ -235,4 +237,4 @@
"update": { "update": {
"email": "E-postadressa ble endra" "email": "E-postadressa ble endra"
} }
} }
\ No newline at end of file
...@@ -28,7 +28,9 @@ import { getAlpha2Codes, getName } from 'i18n-iso-countries' ...@@ -28,7 +28,9 @@ import { getAlpha2Codes, getName } from 'i18n-iso-countries'
import { DatePicker } from '@mui/lab' import { DatePicker } from '@mui/lab'
import { subYears } from 'date-fns/fp' import { subYears } from 'date-fns/fp'
import { import {
isValidFirstName,
isValidFnr, isValidFnr,
isValidLastName,
isValidMobilePhoneNumber, isValidMobilePhoneNumber,
extractGenderOrBlank, extractGenderOrBlank,
extractBirthdateFromNationalId, extractBirthdateFromNationalId,
...@@ -320,7 +322,7 @@ const GuestRegisterStep = forwardRef( ...@@ -320,7 +322,7 @@ const GuestRegisterStep = forwardRef(
(initialGuestData.authentication_method === (initialGuestData.authentication_method ===
AuthenticationMethod.Feide || AuthenticationMethod.Feide ||
initialGuestData.authentication_method === initialGuestData.authentication_method ===
AuthenticationMethod.IdPorten) && AuthenticationMethod.IdPorten) &&
initialGuestData.fnr !== null && initialGuestData.fnr !== null &&
initialGuestData.fnr?.length !== 0, initialGuestData.fnr?.length !== 0,
} }
...@@ -372,7 +374,7 @@ const GuestRegisterStep = forwardRef( ...@@ -372,7 +374,7 @@ const GuestRegisterStep = forwardRef(
name="firstName" name="firstName"
control={control} control={control}
rules={{ rules={{
required: t('common:validation.firstNameRequired').toString(), validate: isValidFirstName,
}} }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <TextField
...@@ -394,7 +396,7 @@ const GuestRegisterStep = forwardRef( ...@@ -394,7 +396,7 @@ const GuestRegisterStep = forwardRef(
name="lastName" name="lastName"
control={control} control={control}
rules={{ rules={{
required: t('common:validation.lastNameRequired').toString(), validate: isValidLastName,
}} }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <TextField
......
...@@ -22,7 +22,7 @@ import useOus, { enSort, nbSort, OuData } from 'hooks/useOus' ...@@ -22,7 +22,7 @@ import useOus, { enSort, nbSort, OuData } from 'hooks/useOus'
import useRoleTypes, { RoleTypeData } from 'hooks/useRoleTypes' import useRoleTypes, { RoleTypeData } from 'hooks/useRoleTypes'
import Autocomplete from '@mui/material/Autocomplete' import Autocomplete from '@mui/material/Autocomplete'
import Loading from 'components/loading' import Loading from 'components/loading'
import { isValidEmail } from 'utils' import { isValidEmail, isValidFirstName, isValidLastName } from 'utils'
import { FeatureContext } from 'contexts' import { FeatureContext } from 'contexts'
import { RegisterFormData } from './formData' import { RegisterFormData } from './formData'
import { PersonFormMethods } from './personFormMethods' import { PersonFormMethods } from './personFormMethods'
...@@ -138,7 +138,7 @@ const StepPersonForm = forwardRef( ...@@ -138,7 +138,7 @@ const StepPersonForm = forwardRef(
const roleEnd = getValues('role_end') const roleEnd = getValues('role_end')
if (roleEnd && startDate > roleEnd) { if (roleEnd && startDate > roleEnd) {
// The role end date is set, but is is before the start date // The role end date is set, but is before the start date
return t('validation.startDateMustBeBeforeEndDate') return t('validation.startDateMustBeBeforeEndDate')
} }
return true return true
...@@ -147,20 +147,17 @@ const StepPersonForm = forwardRef( ...@@ -147,20 +147,17 @@ const StepPersonForm = forwardRef(
function getFullOptionLabel(ouData: OuData) { function getFullOptionLabel(ouData: OuData) {
switch (i18n.language) { switch (i18n.language) {
case 'en': case 'en':
return `${ouData.en}${ return `${ouData.en}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : ''
ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
}${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
case 'nn': case 'nn':
return `${ouData.nb}${ return `${ouData.nb}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : ''
ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
}${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
default: default:
// There should always be a Norwegian bokmaal acronym set // There should always be a Norwegian bokmaal acronym set
return `${ouData.nb}${ return `${ouData.nb}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : ''
ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
}${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}`
} }
} }
...@@ -198,7 +195,7 @@ const StepPersonForm = forwardRef( ...@@ -198,7 +195,7 @@ const StepPersonForm = forwardRef(
error={!!errors.first_name} error={!!errors.first_name}
helperText={errors.first_name && errors.first_name.message} helperText={errors.first_name && errors.first_name.message}
{...register(`first_name`, { {...register(`first_name`, {
required: t<string>('validation.firstNameRequired'), validate: isValidFirstName,
})} })}
/> />
<TextField <TextField
...@@ -207,7 +204,7 @@ const StepPersonForm = forwardRef( ...@@ -207,7 +204,7 @@ const StepPersonForm = forwardRef(
error={!!errors.last_name} error={!!errors.last_name}
helperText={errors.last_name && errors.last_name.message} helperText={errors.last_name && errors.last_name.message}
{...register(`last_name`, { {...register(`last_name`, {
required: t<string>('validation.lastNameRequired'), validate: isValidLastName,
})} })}
inputProps={{ 'data-testid': 'lastName-input-field' }} inputProps={{ 'data-testid': 'lastName-input-field' }}
/> />
......
...@@ -4,7 +4,9 @@ import { ...@@ -4,7 +4,9 @@ import {
deleteCookie, deleteCookie,
setCookie, setCookie,
isValidEmail, isValidEmail,
isValidFirstName,
isValidFnr, isValidFnr,
isValidLastName,
isValidMobilePhoneNumber, isValidMobilePhoneNumber,
maybeCsrfToken, maybeCsrfToken,
submitJsonOpts, submitJsonOpts,
...@@ -54,6 +56,26 @@ test('Invalid e-mail', async () => { ...@@ -54,6 +56,26 @@ test('Invalid e-mail', async () => {
}) })
}) })
test('Valid first name', async () => {
expect(isValidFirstName('Ååæž')).toEqual(true)
})
test('Invalid first name', async () => {
expect(isValidFirstName('')).toEqual('common:validation.firstNameRequired')
expect(isValidFirstName('aaƂåå')).toEqual('common:validation.firstNameContainsInvalidChars')
expect(isValidFirstName('汉字')).toEqual('common:validation.firstNameContainsInvalidChars')
})
test('Valid last name', async () => {
expect(isValidLastName('Ååæž')).toEqual(true)
})
test('Invalid last name', async () => {
expect(isValidLastName('')).toEqual('common:validation.lastNameRequired')
expect(isValidLastName('aaƂåå')).toEqual('common:validation.lastNameContainsInvalidChars')
expect(isValidLastName('汉字')).toEqual('common:validation.lastNameContainsInvalidChars')
})
test('Body has values', async () => { test('Body has values', async () => {
const data = { foo: 'bar' } const data = { foo: 'bar' }
expect(submitJsonOpts('POST', data)).toEqual({ expect(submitJsonOpts('POST', data)).toEqual({
......
...@@ -121,6 +121,32 @@ export async function isValidEmail(data: string | undefined) { ...@@ -121,6 +121,32 @@ export async function isValidEmail(data: string | undefined) {
return i18n.t<string>('common:validation.invalidEmail') return i18n.t<string>('common:validation.invalidEmail')
} }
function stringContainsIllegalChars(string: string): boolean {
// Only allow ISO-8859-1 and Latin Extended-A
// eslint-disable-next-line no-control-regex
return /[^\u0000-\u017F]/g.test(string)
}
export function isValidFirstName(data: string | undefined): string | true {
if (!data) {
return i18n.t<string>('common:validation.firstNameRequired')
}
if (stringContainsIllegalChars(data)) {
return i18n.t<string>('common:validation.firstNameContainsInvalidChars')
}
return true
}
export function isValidLastName(data: string | undefined): string | true {
if (!data) {
return i18n.t<string>('common:validation.lastNameRequired')
}
if (stringContainsIllegalChars(data)) {
return i18n.t<string>('common:validation.lastNameContainsInvalidChars')
}
return true
}
/** /**
* Splits a phone number into a country code and the national number. * Splits a phone number into a country code and the national number.
* *
...@@ -315,7 +341,7 @@ function extractBirthdateFromFnr(nationalId: string): Date { ...@@ -315,7 +341,7 @@ function extractBirthdateFromFnr(nationalId: string): Date {
function extractBirthdateFromDnumber(nationalId: string): Date { function extractBirthdateFromDnumber(nationalId: string): Date {
return suggestBirthDate( return suggestBirthDate(
(parseInt(nationalId.charAt(0), 10) - 4).toString(10) + (parseInt(nationalId.charAt(0), 10) - 4).toString(10) +
nationalId.substring(1, 6) nationalId.substring(1, 6)
) )
} }
......
import pytest
from django.conf import settings from django.conf import settings
from greg.utils import is_valid_id_number, is_valid_so_number
from greg.utils import (
is_valid_id_number,
is_valid_so_number,
string_contains_illegal_chars,
)
def test_so_number(): def test_so_number():
...@@ -21,3 +27,15 @@ def test_not_valid_so_number(): ...@@ -21,3 +27,15 @@ def test_not_valid_so_number():
settings.ALLOW_SO_NUMBERS = True settings.ALLOW_SO_NUMBERS = True
assert not is_valid_id_number(so_number) assert not is_valid_id_number(so_number)
assert not is_valid_so_number(so_number) assert not is_valid_so_number(so_number)
@pytest.mark.parametrize(
"string, expected_output",
[
("Ååæž", False),
("aaƂåå", True),
("汉字", True),
],
)
def test_string_contains_illegal_chars(string, expected_output):
assert string_contains_illegal_chars(string) == expected_output
...@@ -220,3 +220,8 @@ def role_invitation_date_validator( ...@@ -220,3 +220,8 @@ def role_invitation_date_validator(
raise serializers.ValidationError( raise serializers.ValidationError(
f"New end date too far into the future for this type. Must be before {max_date.strftime('%Y-%m-%d')}." f"New end date too far into the future for this type. Must be before {max_date.strftime('%Y-%m-%d')}."
) )
def string_contains_illegal_chars(string: str) -> bool:
# Only allow ISO-8859-1 and Latin Extended-A
return bool(re.search(r"[^\u0000-\u017F]", string))
...@@ -16,7 +16,7 @@ from greg.models import ( ...@@ -16,7 +16,7 @@ from greg.models import (
Person, Person,
InvitationLink, InvitationLink,
) )
from greg.utils import is_identity_duplicate from greg.utils import is_identity_duplicate, string_contains_illegal_chars
from gregui.api.serializers.identity import ( from gregui.api.serializers.identity import (
PartialIdentitySerializer, PartialIdentitySerializer,
IdentityDuplicateError, IdentityDuplicateError,
...@@ -144,6 +144,16 @@ class GuestRegisterSerializer(serializers.ModelSerializer): ...@@ -144,6 +144,16 @@ class GuestRegisterSerializer(serializers.ModelSerializer):
consent_instance.choice = choice consent_instance.choice = choice
consent_instance.save() consent_instance.save()
def validate_first_name(self, first_name):
if string_contains_illegal_chars(first_name):
raise serializers.ValidationError("First name contains illegal characters")
return first_name
def validate_last_name(self, last_name):
if string_contains_illegal_chars(last_name):
raise serializers.ValidationError("Last name contains illegal characters")
return last_name
def validate_date_of_birth(self, date_of_birth): def validate_date_of_birth(self, date_of_birth):
today = datetime.date.today() today = datetime.date.today()
......
from rest_framework import serializers from rest_framework import serializers
from greg.models import Person, Identity from greg.models import Person, Identity
from greg.utils import create_objects_for_invitation, is_identity_duplicate from greg.utils import (
create_objects_for_invitation,
is_identity_duplicate,
string_contains_illegal_chars,
)
from gregui.api.serializers.identity import IdentityDuplicateError from gregui.api.serializers.identity import IdentityDuplicateError
from gregui.api.serializers.role import InviteRoleSerializerUi from gregui.api.serializers.role import InviteRoleSerializerUi
from gregui.models import GregUserProfile from gregui.models import GregUserProfile
class InviteGuestSerializer(serializers.ModelSerializer): class InviteGuestSerializer(serializers.ModelSerializer):
first_name = serializers.CharField(required=True)
last_name = serializers.CharField(required=True)
email = serializers.EmailField(required=True) email = serializers.EmailField(required=True)
role = InviteRoleSerializerUi(required=True) role = InviteRoleSerializerUi(required=True)
uuid = serializers.UUIDField(read_only=True) uuid = serializers.UUIDField(read_only=True)
...@@ -29,6 +35,16 @@ class InviteGuestSerializer(serializers.ModelSerializer): ...@@ -29,6 +35,16 @@ class InviteGuestSerializer(serializers.ModelSerializer):
return person return person
def validate_first_name(self, first_name):
if string_contains_illegal_chars(first_name):
raise serializers.ValidationError("First name contains illegal characters")
return first_name
def validate_last_name(self, last_name):
if string_contains_illegal_chars(last_name):
raise serializers.ValidationError("Last name contains illegal characters")
return last_name
def validate_email(self, email): def validate_email(self, email):
# The e-mail in the invite is the private e-mail # The e-mail in the invite is the private e-mail
if is_identity_duplicate(Identity.IdentityType.PRIVATE_EMAIL, email): if is_identity_duplicate(Identity.IdentityType.PRIVATE_EMAIL, email):
......
...@@ -21,8 +21,8 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker): ...@@ -21,8 +21,8 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
role_end_date = datetime.datetime.today() + datetime.timedelta(days=10) role_end_date = datetime.datetime.today() + datetime.timedelta(days=10)
data = { data = {
"first_name": "foo木👍أ", "first_name": "ſfooþ",
"last_name": "غbar", "last_name": "barŮ",
"email": "test@example.com", "email": "test@example.com",
"role": { "role": {
"start_date": (role_start_date).strftime("%Y-%m-%d"), "start_date": (role_start_date).strftime("%Y-%m-%d"),
...@@ -47,8 +47,8 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker): ...@@ -47,8 +47,8 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
assert Person.objects.count() == 1 assert Person.objects.count() == 1
person = Person.objects.first() person = Person.objects.first()
assert person.first_name == "foo木👍أ" assert person.first_name == "ſfooþ"
assert person.last_name == "غbar" assert person.last_name == "barŮ"
assert Identity.objects.filter( assert Identity.objects.filter(
person=person, person=person,
...@@ -66,6 +66,39 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker): ...@@ -66,6 +66,39 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
send_invite_mock_function.assert_called() send_invite_mock_function.assert_called()
@pytest.mark.django_db
def test_invite_guest__illegal_name(client, user_sponsor, unit_foo, role_type_foo):
test_comment = "This is a test comment"
contact_person_unit = "This is a test contact person"
role_start_date = datetime.datetime.today() + datetime.timedelta(days=1)
role_end_date = datetime.datetime.today() + datetime.timedelta(days=10)
data = {
"first_name": "foo木👍أ",
"last_name": "غbar水",
"email": "test@example.com",
"role": {
"start_date": (role_start_date).strftime("%Y-%m-%d"),
"end_date": (role_end_date).strftime("%Y-%m-%d"),
"orgunit": unit_foo.id,
"type": role_type_foo.id,
"comments": test_comment,
"contact_person_unit": contact_person_unit,
},
}
assert len(Person.objects.all()) == 0
request = APIRequestFactory().post(
path=reverse("gregui-v1:invitation"), data=data, format="json"
)
force_authenticate(request, user=user_sponsor)
response = InvitationView.as_view()(request)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db @pytest.mark.django_db
def test_invite_cancel(client, invitation_link, invitation, role, log_in, user_sponsor): def test_invite_cancel(client, invitation_link, invitation, role, log_in, user_sponsor):
# TODO: Should all sponsors be allowed to delete arbitrary invitations? # TODO: Should all sponsors be allowed to delete arbitrary invitations?
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment