diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c5066b34f00d5603035839ff6d8bb88032c13174..37ddd870e6fe5c04af3623ece944136c53b530e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6583,13 +6583,23 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001271", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz", - "integrity": "sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "version": "1.0.30001512", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz", + "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==", + "funding": [ + { + "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": { "version": "2.0.0", @@ -28885,9 +28895,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001271", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz", - "integrity": "sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==" + "version": "1.0.30001512", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz", + "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==" }, "capture-exit": { "version": "2.0.0", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index e89574e0620232d852cd2af5f41838240756ed7b..63be9f8e67b31261c6d341b4a5e80ce877198fef 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -125,7 +125,9 @@ "nationalIdNumber": "Norwegian national ID number", "validation": { "firstNameRequired": "First name is required", + "firstNameContainsInvalidChars": "First name contains invalid characters", "lastNameRequired": "Last name is required", + "lastNameContainsInvalidChars": "Last name contains invalid characters", "dateOfBirthRequired": "Date of birth is required", "invalidIdNumber": "Invalid Norwegian national ID number", "nationalIdNumberRequired": "Norwegian national ID number required", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 5e9091fe936b4bf6a4242f15f0ce21d28b35ab55..a9fa27f4502231e81c4e90efcc7514921aac29d0 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -125,7 +125,9 @@ "nationalIdNumber": "Fødselsnummer/D-nummer", "validation": { "firstNameRequired": "Fornavn er obligatorisk", + "firstNameContainsInvalidChars": "Fornavn inneholder ugyldige karakterer", "lastNameRequired": "Etternavn er obligatorisk", + "lastNameContainsInvalidChars": "Etternavn inneholder ugyldige karakterer", "dateOfBirthRequired": "Fødselsdato er obligatorisk", "invalidIdNumber": "Ugyldig fødselsnummer/D-nummer", "nationalIdNumberRequired": "Fødselsnummer/D-nummer er obligatorisk", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 6a93167351aa5b53a0d18736922ab66578740aaf..d8e96c9976b9828a15ba22e7471217a1e5c610f5 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -125,7 +125,9 @@ "nationalIdNumber": "Fødselsnummer/D-nummer", "validation": { "firstNameRequired": "Fornamn er pÃ¥krevd", + "firstNameContainsInvalidChars": "Fornamn inneheld ugyldige karakterar", "lastNameRequired": "Etternamn er pÃ¥krevd", + "lastNameContainsInvalidChars": "Etternamn inneheld ugyldige karakterar", "dateOfBirthRequired": "Fødselsdato er pÃ¥krevd", "invalidIdNumber": "Ugyldig fødselsnummer/D-nummer", "nationalIdNumberRequired": "Fødselsnummer/D-nummer er pÃ¥krevd", @@ -235,4 +237,4 @@ "update": { "email": "E-postadressa ble endra" } -} +} \ No newline at end of file diff --git a/frontend/src/routes/guest/register/index.test.tsx b/frontend/src/routes/guest/register/index.test.tsx index 4969ed4dddb82c1351300f2353f9d1b356072c4a..624a3841cfad396b3fb05a8832f7fdbc938ffd1a 100644 --- a/frontend/src/routes/guest/register/index.test.tsx +++ b/frontend/src/routes/guest/register/index.test.tsx @@ -4,17 +4,15 @@ import AdapterDateFns from '@mui/lab/AdapterDateFns' import { LocalizationProvider } from '@mui/lab' import { BrowserRouter } from 'react-router-dom' -import { render, screen } from 'test-utils' +import { render, screen, act, waitFor, fireEvent } from 'test-utils' import { FeatureContext } from 'contexts' -import { fireEvent } from '@testing-library/react' import GuestRegister from './index' -import { waitFor } from '../../../test-utils' enableFetchMocks() const testData = { person: { - first_name: 'Test20', + first_name: 'TestTwenty', last_name: 'Tester', private_mobile: '+4797543910', private_email: 'test@example.org', @@ -176,10 +174,10 @@ test('Gender is remembered when going back', async () => { // Go forward and then back in wizard const nextButton = screen.getByTestId('button-next') - nextButton.click() + act(() => nextButton.click()) await screen.findByTestId('button-back') const backButton = screen.getByTestId('button-back') - backButton.click() + act(() => backButton.click()) // The selection should still be male await waitFor(() => screen.getByText('input.male')) @@ -218,10 +216,10 @@ test('Gender not required when gender field is not shown', async () => { // No gender is set, but it should still be possible // to go forward and back in wizard let nextButton = screen.getByTestId('button-next') - nextButton.click() + act(() => nextButton.click()) await screen.findByTestId('button-back') const backButton = screen.getByTestId('button-back') - backButton.click() + act(() => backButton.click()) nextButton = screen.getByTestId('button-next') - nextButton.click() + act(() => nextButton.click()) }) diff --git a/frontend/src/routes/guest/register/steps/register.test.tsx b/frontend/src/routes/guest/register/steps/register.test.tsx index ad034c1186e352217b3804e7a13ebbc227f4384f..f6669fe5490015759ed08b264afa17d2c001aa34 100644 --- a/frontend/src/routes/guest/register/steps/register.test.tsx +++ b/frontend/src/routes/guest/register/steps/register.test.tsx @@ -194,7 +194,7 @@ test('Gender required to be set if gender field is showing', async () => { const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: 'NO', mobilePhone: '97543980', nationalIdNumber: '', @@ -249,7 +249,7 @@ test('Gender not required to be set if gender field is not showing', async () => const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: 'NO', mobilePhone: '97543980', nationalIdNumber: '', @@ -297,7 +297,7 @@ test('Guest not allowed to proceed in wizard if phone number is not valid', asyn const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: 'NO', mobilePhone: '50', nationalIdNumber: '', @@ -341,7 +341,7 @@ test('Guest allowed to proceed in wizard if data is valid', async () => { const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: 'NO', mobilePhone: '97543992', nationalIdNumber: '', @@ -391,7 +391,7 @@ test('Default country code gets set in form values', async () => { // be populated with a default value const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: '', mobilePhone: '97543992', nationalIdNumber: '', @@ -441,7 +441,7 @@ test('Foreign country code gets set in form values', async () => { const formData: GuestRegisterData = { firstName: 'Test', - lastName: 'Test2', + lastName: 'TestTwo', mobilePhoneCountry: '', mobilePhone: '3892778472', nationalIdNumber: '', diff --git a/frontend/src/routes/guest/register/steps/register.tsx b/frontend/src/routes/guest/register/steps/register.tsx index 576b9b2aafd985649b5eeb5eccec99bfbac82950..86f52bd241962c59cc61df154f82ce69a3af1430 100644 --- a/frontend/src/routes/guest/register/steps/register.tsx +++ b/frontend/src/routes/guest/register/steps/register.tsx @@ -28,7 +28,9 @@ import { getAlpha2Codes, getName } from 'i18n-iso-countries' import { DatePicker } from '@mui/lab' import { subYears } from 'date-fns/fp' import { + isValidFirstName, isValidFnr, + isValidLastName, isValidMobilePhoneNumber, extractGenderOrBlank, extractBirthdateFromNationalId, @@ -320,7 +322,7 @@ const GuestRegisterStep = forwardRef( (initialGuestData.authentication_method === AuthenticationMethod.Feide || initialGuestData.authentication_method === - AuthenticationMethod.IdPorten) && + AuthenticationMethod.IdPorten) && initialGuestData.fnr !== null && initialGuestData.fnr?.length !== 0, } @@ -372,7 +374,7 @@ const GuestRegisterStep = forwardRef( name="firstName" control={control} rules={{ - required: t('common:validation.firstNameRequired').toString(), + validate: isValidFirstName, }} render={({ field: { onChange, value } }) => ( <TextField @@ -394,7 +396,7 @@ const GuestRegisterStep = forwardRef( name="lastName" control={control} rules={{ - required: t('common:validation.lastNameRequired').toString(), + validate: isValidLastName, }} render={({ field: { onChange, value } }) => ( <TextField diff --git a/frontend/src/routes/sponsor/register/index.test.tsx b/frontend/src/routes/sponsor/register/index.test.tsx index 1e22d17f029e82b8bb46e274277dc05682bdfc11..420299133d7f51feda07fb4b81be651e7bc4b0c3 100644 --- a/frontend/src/routes/sponsor/register/index.test.tsx +++ b/frontend/src/routes/sponsor/register/index.test.tsx @@ -4,7 +4,7 @@ import AdapterDateFns from '@mui/lab/AdapterDateFns' import { LocalizationProvider } from '@mui/lab' import { BrowserRouter } from 'react-router-dom' -import { render, waitFor, screen } from 'test-utils' +import { render, waitFor, screen, act } from 'test-utils' import StepRegistration from './stepRegistration' jest.mock('hooks/useOus', () => () => ({ @@ -25,7 +25,7 @@ test('Validation message showing if last name is missing', async () => { // Try to go to the next step and check that the validation message is showing const submitButton = screen.getByTestId('button-next') - userEvent.click(submitButton) + act(() => userEvent.click(submitButton)) const validationMessage = await waitFor(() => screen.getByText('validation.lastNameRequired') @@ -36,12 +36,12 @@ test('Validation message showing if last name is missing', async () => { const inputValue = 'Test input value' // Note that we need to use the test-ID of the input field inside the Material UI TextField-component - userEvent.type(screen.getByTestId('lastName-input-field'), inputValue) + act(() => userEvent.type(screen.getByTestId('lastName-input-field'), inputValue)) expect(screen.getByDisplayValue(inputValue)).toBeInTheDocument() // Type in text, the message should disappear screen.queryByText('validation.lastNameRequired') - userEvent.click(submitButton) + act(() => userEvent.click(submitButton)) await waitFor( () => { diff --git a/frontend/src/routes/sponsor/register/stepPersonForm.tsx b/frontend/src/routes/sponsor/register/stepPersonForm.tsx index e9c8f5a15160262a9647743ed9b7edac23fcd90c..c6075fe6de4daf5d4eeaed7482eb4511ad0c9a5f 100644 --- a/frontend/src/routes/sponsor/register/stepPersonForm.tsx +++ b/frontend/src/routes/sponsor/register/stepPersonForm.tsx @@ -22,7 +22,7 @@ import useOus, { enSort, nbSort, OuData } from 'hooks/useOus' import useRoleTypes, { RoleTypeData } from 'hooks/useRoleTypes' import Autocomplete from '@mui/material/Autocomplete' import Loading from 'components/loading' -import { isValidEmail } from 'utils' +import { isValidEmail, isValidFirstName, isValidLastName } from 'utils' import { FeatureContext } from 'contexts' import { RegisterFormData } from './formData' import { PersonFormMethods } from './personFormMethods' @@ -138,7 +138,7 @@ const StepPersonForm = forwardRef( const roleEnd = getValues('role_end') 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 true @@ -147,20 +147,17 @@ const StepPersonForm = forwardRef( function getFullOptionLabel(ouData: OuData) { switch (i18n.language) { case 'en': - return `${ouData.en}${ - ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' - }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` + return `${ouData.en}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' + }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` case 'nn': - return `${ouData.nb}${ - ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' - }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` + return `${ouData.nb}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' + }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` default: // There should always be a Norwegian bokmaal acronym set - return `${ouData.nb}${ - ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' - }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` + return `${ouData.nb}${ouData.identifier_1 ? ` (${ouData.identifier_1})` : '' + }${ouData.identifier_2 ? ` (${ouData.identifier_2})` : ''}` } } @@ -198,7 +195,7 @@ const StepPersonForm = forwardRef( error={!!errors.first_name} helperText={errors.first_name && errors.first_name.message} {...register(`first_name`, { - required: t<string>('validation.firstNameRequired'), + validate: isValidFirstName, })} /> <TextField @@ -207,7 +204,7 @@ const StepPersonForm = forwardRef( error={!!errors.last_name} helperText={errors.last_name && errors.last_name.message} {...register(`last_name`, { - required: t<string>('validation.lastNameRequired'), + validate: isValidLastName, })} inputProps={{ 'data-testid': 'lastName-input-field' }} /> diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx index fa8739024553136211812cbfeb356c9840f2fd8c..78d064435400956f779fa2d27be563be7edecc48 100644 --- a/frontend/src/test-utils.tsx +++ b/frontend/src/test-utils.tsx @@ -1,12 +1,32 @@ -import React from 'react' -import { render } from '@testing-library/react' import { ThemeProvider } from '@mui/material/styles' +import { render } from '@testing-library/react' +import React from 'react' +import { I18nextProvider, initReactI18next } from 'react-i18next' +import i18n from 'i18next' import { defaultTheme } from 'themes' +i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + + ns: ['translationsNS'], + defaultNS: 'translationsNS', + + debug: false, + + interpolation: { + escapeValue: false, + }, + + resources: { en: { translationsNS: {} } }, +}) + // Providers used in test rendering const AllTheProviders = ({ children }: any) => ( - <ThemeProvider theme={defaultTheme}> {children} </ThemeProvider> + <I18nextProvider i18n={i18n}> + <ThemeProvider theme={defaultTheme}> {children} </ThemeProvider> + </I18nextProvider> ) // Custom testing-library/react renderer using our providers. @@ -18,72 +38,3 @@ export * from '@testing-library/react' // override render method export { customRender as render } - -// The reason for this complex mock of react-i18next is to make the Trans-component work when running tests -jest.mock('react-i18next', (): object => { - // Need to require React at this level as well to avoid getting an error - // when running the tests saying that the module factory is not allowed - // to reference out-of-scope variables - // eslint-disable-next-line @typescript-eslint/no-shadow,global-require - const React = require('react') - - const hasChildren = (node: any): boolean => - node && (node.children || (node.props && node.props.children)) - - const getChildrenFromProps = (node: any): any => - node.props && node.props.children ? node.props.children : null - - const getChildren = (node: any): any => - node && node.children ? node.children : getChildrenFromProps(node) - - const renderNodes = (reactNodes: any): any => { - if (typeof reactNodes === 'string') { - return reactNodes - } - - return Object.keys(reactNodes).map((key, i) => { - const child = reactNodes[key] - - if (typeof child === 'string') { - return child - } - - if (hasChildren(child)) { - const inner = renderNodes(getChildren(child)) - // eslint-disable-next-line react/no-array-index-key - return React.cloneElement(child, { ...child.props, key: i }, inner) - } - - const isElement = React.isValidElement(child) - if (typeof child === 'object' && !isElement) { - return Object.keys(child).reduce( - (str, childKey) => `${str}${child[childKey]}`, - '' - ) - } - - return child - }) - } - - const useMock: any = [(k: any) => k, {}] - // Mock react-i18next module to return a translation that just returns the key - useMock.t = (k: any) => k - useMock.i18n = { - // Return "en" as the selected language if the code asks for it - language: 'en', - changeLanguage: () => new Promise(() => {}), - } - - return { - withTranslation: - () => - (Component: any): any => - (props: any): any => - <Component t={(k: any): any => k} {...props} />, - Trans: ({ children }: any) => renderNodes(children), - Translation: ({ children }: any) => - children((k: any): any => k, { i18n: {} }), - useTranslation: () => useMock, - } -}) diff --git a/frontend/src/utils/index.test.ts b/frontend/src/utils/index.test.ts index 5067541ad736df52b192d97638303fbbdb857e0c..bdc3108c327e6449021557ba70ed5c9155aec2c9 100644 --- a/frontend/src/utils/index.test.ts +++ b/frontend/src/utils/index.test.ts @@ -4,7 +4,9 @@ import { deleteCookie, setCookie, isValidEmail, + isValidFirstName, isValidFnr, + isValidLastName, isValidMobilePhoneNumber, maybeCsrfToken, submitJsonOpts, @@ -54,6 +56,46 @@ test('Invalid e-mail', async () => { }) }) +test('Valid first name', async () => { + expect(isValidFirstName('AZ az ÀÖ ØÞßö øÿ Āſ')).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' + ) + expect(isValidFirstName('÷')).toEqual( + 'common:validation.firstNameContainsInvalidChars' + ) + expect(isValidFirstName('汉å—')).toEqual( + 'common:validation.firstNameContainsInvalidChars' + ) +}) + +test('Valid last name', async () => { + expect(isValidLastName('AZ az ÀÖ ØÞßö øÿ Āſ')).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' + ) + expect(isValidLastName('÷')).toEqual( + 'common:validation.lastNameContainsInvalidChars' + ) + expect(isValidLastName('汉å—')).toEqual( + 'common:validation.lastNameContainsInvalidChars' + ) +}) + test('Body has values', async () => { const data = { foo: 'bar' } expect(submitJsonOpts('POST', data)).toEqual({ diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 0dd69ab5f46aa54bea2dbbb177cf5942fed45e33..d85c7f3ff3b966fe094f9bb1c21e8a35856c1bd5 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -121,6 +121,46 @@ export async function isValidEmail(data: string | undefined) { return i18n.t<string>('common:validation.invalidEmail') } +function stringContainsIllegalChars(string: string): boolean { + // Only allow the following characters: + // ----- Basic Latin ----- + // U+0020 (Space) + // U+0041 - U+005A (Latin Alphabet: Uppercase) + // U+0061 - U+007A (Latin Alphabet: Lowercase) + // ----- Latin-1 Supplement ----- + // U+00C0 - U+00D6 (Letters: Uppercase) + // U+00D8 - U+00DE (Letters: Uppercase) + // U+00DF - U+00F6 (Letters: Lowercase) + // U+00F8 - U+00FF (Letters: Lowercase) + // ----- Latin Extended-A ----- + // U+0100 - U+017F (European Latin) + + // eslint-disable-next-line no-control-regex + return /[^\u0020\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\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. * diff --git a/greg/tests/test_utils.py b/greg/tests/test_utils.py index c77def931927a4c19f342045b15fe4b7eaf73025..75b5687e76f180d6eb1afae793f318de6b2e4a89 100644 --- a/greg/tests/test_utils.py +++ b/greg/tests/test_utils.py @@ -1,5 +1,11 @@ +import pytest 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(): @@ -21,3 +27,17 @@ def test_not_valid_so_number(): settings.ALLOW_SO_NUMBERS = True assert not is_valid_id_number(so_number) assert not is_valid_so_number(so_number) + + +@pytest.mark.parametrize( + "string, expected_output", + [ + ("AZ az ÀÖ ØÞßö øÿ Āſ", False), + ("aaƂåå", True), + ("!", True), + ("÷", True), + ("汉å—", True), + ], +) +def test_string_contains_illegal_chars(string, expected_output): + assert string_contains_illegal_chars(string) == expected_output diff --git a/greg/utils.py b/greg/utils.py index bc108f354af6f8aefe306b0022f2a46345e9b92a..b60d9b948709f913495c86f63fc321e921b0f38f 100644 --- a/greg/utils.py +++ b/greg/utils.py @@ -220,3 +220,24 @@ def role_invitation_date_validator( raise serializers.ValidationError( 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 the following characters: + # ----- Basic Latin ----- + # U+0020 (Space) + # U+0041 - U+005A (Latin Alphabet: Uppercase) + # U+0061 - U+007A (Latin Alphabet: Lowercase) + # ----- Latin-1 Supplement ----- + # U+00C0 - U+00D6 (Letters: Uppercase) + # U+00D8 - U+00DE (Letters: Uppercase) + # U+00DF - U+00F6 (Letters: Lowercase) + # U+00F8 - U+00FF (Letters: Lowercase) + # ----- Latin Extended-A ----- + # U+0100 - U+017F (European Latin) + return bool( + re.search( + r"[^\u0020\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u017F]", + string, + ) + ) diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py index d18f56ff280dc95ced2430c1ef7c975acfb6d656..444c37bf28f2db5d9817f9d1e3cdec2b80844148 100644 --- a/gregui/api/serializers/guest.py +++ b/gregui/api/serializers/guest.py @@ -16,7 +16,7 @@ from greg.models import ( Person, 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 ( PartialIdentitySerializer, IdentityDuplicateError, @@ -144,6 +144,16 @@ class GuestRegisterSerializer(serializers.ModelSerializer): consent_instance.choice = choice 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): today = datetime.date.today() diff --git a/gregui/api/serializers/invitation.py b/gregui/api/serializers/invitation.py index add333aa7655ec35efd159134c7f9e32ab466584..7feddf953af023c830781363fbffe2e1ed2339e2 100644 --- a/gregui/api/serializers/invitation.py +++ b/gregui/api/serializers/invitation.py @@ -1,13 +1,19 @@ from rest_framework import serializers 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.role import InviteRoleSerializerUi from gregui.models import GregUserProfile class InviteGuestSerializer(serializers.ModelSerializer): + first_name = serializers.CharField(required=True) + last_name = serializers.CharField(required=True) email = serializers.EmailField(required=True) role = InviteRoleSerializerUi(required=True) uuid = serializers.UUIDField(read_only=True) @@ -29,6 +35,16 @@ class InviteGuestSerializer(serializers.ModelSerializer): 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): # The e-mail in the invite is the private e-mail if is_identity_duplicate(Identity.IdentityType.PRIVATE_EMAIL, email): diff --git a/gregui/tests/api/views/test_invitation.py b/gregui/tests/api/views/test_invitation.py index 54370a365113cf68e94e63b9c2e1f8bafe1a9ba0..8c8cb8669f14c9b42cfc8c0706d6c36e664e88b0 100644 --- a/gregui/tests/api/views/test_invitation.py +++ b/gregui/tests/api/views/test_invitation.py @@ -747,7 +747,7 @@ def test_session_type_id_porten( data = { "person": { "first_name": "Updated", - "last_name": "Updated2", + "last_name": "UpdatedTwo", "private_mobile": "+4797543992", } } @@ -763,7 +763,7 @@ def test_session_type_id_porten( person.refresh_from_db() assert person.first_name == "Updated" - assert person.last_name == "Updated2" + assert person.last_name == "UpdatedTwo" @pytest.mark.django_db diff --git a/gregui/tests/api/views/test_invite_guest.py b/gregui/tests/api/views/test_invite_guest.py index 51e9f5450703a51cc175c5e8b8f9ef6558f76cba..06d28720c026357560c3dee858fbae585514f891 100644 --- a/gregui/tests/api/views/test_invite_guest.py +++ b/gregui/tests/api/views/test_invite_guest.py @@ -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) data = { - "first_name": "foo木ðŸ‘Ø£", - "last_name": "غbaræ°´", + "first_name": "Å¿fooþ", + "last_name": "barÅ®", "email": "test@example.com", "role": { "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): assert Person.objects.count() == 1 person = Person.objects.first() - assert person.first_name == "foo木ðŸ‘Ø£" - assert person.last_name == "غbaræ°´" + assert person.first_name == "Å¿fooþ" + assert person.last_name == "barÅ®" assert Identity.objects.filter( person=person, @@ -66,6 +66,39 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker): 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 def test_invite_cancel(client, invitation_link, invitation, role, log_in, user_sponsor): # TODO: Should all sponsors be allowed to delete arbitrary invitations?