diff --git a/backend/benefit/requirements.in b/backend/benefit/requirements.in index ba59d0e106..58c424a831 100644 --- a/backend/benefit/requirements.in +++ b/backend/benefit/requirements.in @@ -25,7 +25,7 @@ pdfkit pillow psycopg2 python-dateutil -python-stdnum +python-stdnum>=1.19 pyyaml requests sentry-sdk diff --git a/backend/kesaseteli/applications/tests/test_youth_applications_api.py b/backend/kesaseteli/applications/tests/test_youth_applications_api.py index 995c4e691c..b381f8b699 100644 --- a/backend/kesaseteli/applications/tests/test_youth_applications_api.py +++ b/backend/kesaseteli/applications/tests/test_youth_applications_api.py @@ -1156,6 +1156,16 @@ def test_youth_applications_reactivate_unexpired_inactive__vtj_disabled( "121212A899H", "111111-111C", "111111A111C", + "111111B111C", + "111111C111C", + "111111D111C", + "111111E111C", + "111111F111C", + "111111U111C", + "111111V111C", + "111111W111C", + "111111X111C", + "111111Y111C", # Django Rest Framework's serializers.CharField trims leading and trailing # whitespace by default, so we allow them here. " 111111-111C", @@ -1853,6 +1863,13 @@ def test_youth_application_post_empty_required_field( # Not uppercase "111111-111c", "111111a111C", + # Invalid century character + "111111/111C", + "111111G111C", + "111111M111C", + "111111R111C", + "111111T111C", + "111111Z111C", # Invalid checksum "111111-111X", # "111111-111C" would be valid "111111A111W", # "111111A111C" would be valid diff --git a/backend/kesaseteli/common/tests/test_utils.py b/backend/kesaseteli/common/tests/test_utils.py index e89ce506bc..ec442714fe 100644 --- a/backend/kesaseteli/common/tests/test_utils.py +++ b/backend/kesaseteli/common/tests/test_utils.py @@ -159,7 +159,7 @@ def test_utc_datetime(args): assert result.tzinfo == timezone.utc -@pytest.mark.parametrize("year", [1800, 2022, 2023, 2099]) +@pytest.mark.parametrize("year", [1800, 1987, 2022, 2023, 2024, 2025, 2099]) def test_get_random_social_security_number_for_year(year): for _ in range(1000): result = get_random_social_security_number_for_year(year) diff --git a/backend/kesaseteli/common/tests/utils.py b/backend/kesaseteli/common/tests/utils.py index 66520cf37d..2617cac6a4 100644 --- a/backend/kesaseteli/common/tests/utils.py +++ b/backend/kesaseteli/common/tests/utils.py @@ -21,6 +21,7 @@ def get_random_social_security_number_for_year(year: int) -> str: tzinfo=timezone.utc, ) .date(), + century_variant=get_faker().pyint(0, 99), # Inclusive range individual_number=get_faker().pyint(2, 899), # Inclusive range ) diff --git a/backend/kesaseteli/requirements.in b/backend/kesaseteli/requirements.in index c1847146aa..6816dbc62e 100644 --- a/backend/kesaseteli/requirements.in +++ b/backend/kesaseteli/requirements.in @@ -18,7 +18,7 @@ jsonpath-ng mozilla-django-oidc pillow psycopg2 -python-stdnum +python-stdnum>=1.19 requests sentry-sdk xlsx-streaming diff --git a/backend/kesaseteli/requirements.txt b/backend/kesaseteli/requirements.txt index 3a8a220e82..6c8c8c068f 100644 --- a/backend/kesaseteli/requirements.txt +++ b/backend/kesaseteli/requirements.txt @@ -152,7 +152,7 @@ python-dateutil==2.8.1 # azure-storage-common # faker # pysaml2 -python-stdnum==1.17 +python-stdnum==1.19 # via # -r requirements.in # django-localflavor diff --git a/backend/shared/shared/common/tests/test_utils.py b/backend/shared/shared/common/tests/test_utils.py index 34ca2606f6..9d8e3e90f0 100644 --- a/backend/shared/shared/common/tests/test_utils.py +++ b/backend/shared/shared/common/tests/test_utils.py @@ -97,28 +97,88 @@ def test_valid_social_security_number_birthdate(test_value, expected_result): @pytest.mark.parametrize( - "expected_result,birthdate,individual_number", + "expected_result,birthdate,century_variant,individual_number", [ - ("010100+002H", date(year=1800, month=1, day=1), 2), - ("010203-1230", date(year=1903, month=2, day=1), 123), - ("121212A899H", date(year=2012, month=12, day=12), 899), - ("111111-002V", date(year=1911, month=11, day=11), 2), - ("111111-111C", date(year=1911, month=11, day=11), 111), - ("111111A111C", date(year=2011, month=11, day=11), 111), - ("111111-900U", date(year=1911, month=11, day=11), 900), - ("111111-9991", date(year=1911, month=11, day=11), 999), - ("300522A0024", date(year=2022, month=5, day=30), 2), - ("311299A999E", date(year=2099, month=12, day=31), 999), + ("010100+002H", date(year=1800, month=1, day=1), 0, 2), + ("010100+002H", date(year=1800, month=1, day=1), 99, 2), + ("010203-1230", date(year=1903, month=2, day=1), 0, 123), + ("121212A899H", date(year=2012, month=12, day=12), 0, 899), + ("121212A899H", date(year=2012, month=12, day=12), 30, 899), + ("121212B899H", date(year=2012, month=12, day=12), 1, 899), + ("121212B899H", date(year=2012, month=12, day=12), 31, 899), + ("121212C899H", date(year=2012, month=12, day=12), 2, 899), + ("121212C899H", date(year=2012, month=12, day=12), 32, 899), + ("121212D899H", date(year=2012, month=12, day=12), 3, 899), + ("121212D899H", date(year=2012, month=12, day=12), 33, 899), + ("121212E899H", date(year=2012, month=12, day=12), 4, 899), + ("121212E899H", date(year=2012, month=12, day=12), 34, 899), + ("121212F899H", date(year=2012, month=12, day=12), 5, 899), + ("121212F899H", date(year=2012, month=12, day=12), 35, 899), + ("111111-002V", date(year=1911, month=11, day=11), 0, 2), + ("111111-111C", date(year=1911, month=11, day=11), 0, 111), + ("111111Y111C", date(year=1911, month=11, day=11), 1, 111), + ("111111Y111C", date(year=1911, month=11, day=11), 61, 111), + ("111111X111C", date(year=1911, month=11, day=11), 2, 111), + ("111111X111C", date(year=1911, month=11, day=11), 62, 111), + ("111111W111C", date(year=1911, month=11, day=11), 3, 111), + ("111111W111C", date(year=1911, month=11, day=11), 63, 111), + ("111111V111C", date(year=1911, month=11, day=11), 4, 111), + ("111111V111C", date(year=1911, month=11, day=11), 64, 111), + ("111111U111C", date(year=1911, month=11, day=11), 5, 111), + ("111111U111C", date(year=1911, month=11, day=11), 65, 111), + ("111111A111C", date(year=2011, month=11, day=11), 0, 111), + ("111111-900U", date(year=1911, month=11, day=11), 0, 900), + ("111111-9991", date(year=1911, month=11, day=11), 0, 999), + ("300522A0024", date(year=2022, month=5, day=30), 0, 2), + ("300522B0024", date(year=2022, month=5, day=30), 1, 2), + ("311299A999E", date(year=2099, month=12, day=31), 0, 999), + ("311299F999E", date(year=2099, month=12, day=31), 5, 999), + # Go through all the possible checksum characters & before first & beyond last + ("111111B098Y", date(year=2011, month=11, day=11), 1, 98), + ("111111B0990", date(year=2011, month=11, day=11), 1, 99), + ("111111B1001", date(year=2011, month=11, day=11), 1, 100), + ("111111B1012", date(year=2011, month=11, day=11), 1, 101), + ("111111B1023", date(year=2011, month=11, day=11), 1, 102), + ("111111B1034", date(year=2011, month=11, day=11), 1, 103), + ("111111B1045", date(year=2011, month=11, day=11), 1, 104), + ("111111B1056", date(year=2011, month=11, day=11), 1, 105), + ("111111B1067", date(year=2011, month=11, day=11), 1, 106), + ("111111B1078", date(year=2011, month=11, day=11), 1, 107), + ("111111B1089", date(year=2011, month=11, day=11), 1, 108), + ("111111B109A", date(year=2011, month=11, day=11), 1, 109), + ("111111B110B", date(year=2011, month=11, day=11), 1, 110), + ("111111B111C", date(year=2011, month=11, day=11), 1, 111), + ("111111B112D", date(year=2011, month=11, day=11), 1, 112), + ("111111B113E", date(year=2011, month=11, day=11), 1, 113), + ("111111B114F", date(year=2011, month=11, day=11), 1, 114), + ("111111B115H", date(year=2011, month=11, day=11), 1, 115), + ("111111B116J", date(year=2011, month=11, day=11), 1, 116), + ("111111B117K", date(year=2011, month=11, day=11), 1, 117), + ("111111B118L", date(year=2011, month=11, day=11), 1, 118), + ("111111B119M", date(year=2011, month=11, day=11), 1, 119), + ("111111B120N", date(year=2011, month=11, day=11), 1, 120), + ("111111B121P", date(year=2011, month=11, day=11), 1, 121), + ("111111B122R", date(year=2011, month=11, day=11), 1, 122), + ("111111B123S", date(year=2011, month=11, day=11), 1, 123), + ("111111B124T", date(year=2011, month=11, day=11), 1, 124), + ("111111B125U", date(year=2011, month=11, day=11), 1, 125), + ("111111B126V", date(year=2011, month=11, day=11), 1, 126), + ("111111B127W", date(year=2011, month=11, day=11), 1, 127), + ("111111B128X", date(year=2011, month=11, day=11), 1, 128), + ("111111B129Y", date(year=2011, month=11, day=11), 1, 129), + ("111111B1300", date(year=2011, month=11, day=11), 1, 130), ], ) def test_valid_create_finnish_social_security_number( - expected_result: str, birthdate: date, individual_number: int + expected_result: str, birthdate: date, century_variant: int, individual_number: int ): assert validate_finnish_social_security_number( expected_result, allow_temporary=True ) assert ( - create_finnish_social_security_number(birthdate, individual_number) + create_finnish_social_security_number( + birthdate, century_variant, individual_number + ) == expected_result ) @@ -138,31 +198,37 @@ def test_valid_consecutive_create_finnish_social_security_number(birthdate: date Testing consecutive social security numbers for validity to ensure all their parts are calculated correctly, including the checksum (i.e. the last character). """ - for individual_number in range(2, 900): - result = create_finnish_social_security_number(birthdate, individual_number) - assert len(result) == 11 - assert validate_finnish_social_security_number(result, allow_temporary=True) - individual_number_from_result: int = int(result[-4:-1]) - assert individual_number_from_result == individual_number - assert social_security_number_birthdate(result) == birthdate + for century_variant in range(0, 6): + for individual_number in range(2, 900): + result = create_finnish_social_security_number( + birthdate, century_variant, individual_number + ) + assert len(result) == 11 + assert validate_finnish_social_security_number(result, allow_temporary=True) + individual_number_from_result: int = int(result[-4:-1]) + assert individual_number_from_result == individual_number + assert social_security_number_birthdate(result) == birthdate @pytest.mark.parametrize( - "birthdate,individual_number", + "birthdate,century_variant,individual_number", [ - (date(year=1799, month=12, day=31), 2), # Birth year < 1800 - (date(year=2100, month=1, day=1), 2), # Birth year > 2099 - (date(year=2000, month=1, day=1), -1), # Individual number < 2 - (date(year=2000, month=1, day=1), 0), # Individual number < 2 - (date(year=2000, month=1, day=1), 1), # Individual number < 2 - (date(year=2000, month=1, day=1), 1000), # Individual number > 999 + (date(year=1799, month=12, day=31), 0, 2), # Birth year < 1800 + (date(year=2100, month=1, day=1), 0, 2), # Birth year > 2099 + (date(year=2000, month=1, day=1), -1, 0), # Century variant < 0 + (date(year=2000, month=1, day=1), 0, -1), # Individual number < 2 + (date(year=2000, month=1, day=1), 0, 0), # Individual number < 2 + (date(year=2000, month=1, day=1), 0, 1), # Individual number < 2 + (date(year=2000, month=1, day=1), 0, 1000), # Individual number > 999 ], ) def test_invalid_create_finnish_social_security_number( - birthdate: date, individual_number: int + birthdate: date, century_variant: int, individual_number: int ): with pytest.raises(ValueError): - assert create_finnish_social_security_number(birthdate, individual_number) + assert create_finnish_social_security_number( + birthdate, century_variant, individual_number + ) @pytest.mark.django_db @@ -175,6 +241,12 @@ def test_invalid_create_finnish_social_security_number( "111111 -111x", # Invalid checksum, inner whitespace "320522A002T", # Invalid date because no 32 days in any month "311322A002E", # Invalid date because no 13 months in any year + "111111/111C", # Invalid century character + "111111G111C", # Invalid century character + "111111M111C", # Invalid century character + "111111R111C", # Invalid century character + "111111T111C", # Invalid century character + "111111Z111C", # Invalid century character # Invalid checksum "111111-111X", # "111111-111C" would be valid "111111A111W", # "111111A111C" would be valid diff --git a/backend/shared/shared/common/tests/utils.py b/backend/shared/shared/common/tests/utils.py index 0d85aad849..ec2f9d7aa0 100644 --- a/backend/shared/shared/common/tests/utils.py +++ b/backend/shared/shared/common/tests/utils.py @@ -5,31 +5,54 @@ def create_finnish_social_security_number( - birthdate: date, individual_number: int + birthdate: date, century_variant: int, individual_number: int ) -> str: """ - Create a Finnish social security number based on birthdate and individual number + Create a Finnish social security number based on birthdate, century variant and + individual number :param birthdate: Date of birth + :param century_variant: Non-negative integer value for deciding which century + character variant to use, e.g. birthdates in 2000–2099 may + have century character A, B, C, D, E or F. There's no upper + limit as (century_variant % available_century_characters) is + the value being used. :param individual_number: Integer value where 2 <= individual_number <= 999 :return: Finnish social security number in format where dd = day of birth with leading zeroes, mm = month of birth with leading zeroes, yy = year of birth modulo 100 with leading zeroes, - c = century of birth ("+" = 1800, "-" = 1900, "A" = 2000), + c = century of birth, where c is + "+" = 1800, + "-" = 1900 if (century_variant % 6) == 0, + "Y" = 1900 if (century_variant % 6) == 1, + "X" = 1900 if (century_variant % 6) == 2, + "W" = 1900 if (century_variant % 6) == 3, + "V" = 1900 if (century_variant % 6) == 4, + "U" = 1900 if (century_variant % 6) == 5, + "A" = 2000 if (century_variant % 6) == 0, + "B" = 2000 if (century_variant % 6) == 1, + "C" = 2000 if (century_variant % 6) == 2, + "D" = 2000 if (century_variant % 6) == 3, + "E" = 2000 if (century_variant % 6) == 4, + "F" = 2000 if (century_variant % 6) == 5, iii = individual number in range 2–999 with leading zeroes, s = checksum value calculated from . :raises ValueError: if not (1800 <= birthdate.year <= 2099) + :raises ValueError: if century_variant < 0 :raises ValueError: if not (2 <= individual_number <= 999) """ if not (1800 <= birthdate.year <= 2099): raise ValueError("Invalid birthdate year, only years 1800–2099 are supported") + if century_variant < 0: + raise ValueError("Invalid century variant, must be non-negative") if not (2 <= individual_number <= 999): raise ValueError("Invalid individual number, must be in range 2–999") ddmmyy: str = f"{birthdate.day:02}{birthdate.month:02}{birthdate.year % 100:02}" iii: str = f"{individual_number:003}" ddmmyyiii: str = f"{ddmmyy}{iii}" - century_char: str = {18: "+", 19: "-", 20: "A"}[birthdate.year // 100] + century_chars: str = {18: "+", 19: "-YXWVU", 20: "ABCDEF"}[birthdate.year // 100] + century_char: str = century_chars[century_variant % len(century_chars)] checksum_char: str = "0123456789ABCDEFHJKLMNPRSTUVWXY"[int(ddmmyyiii) % 31] return f"{ddmmyy}{century_char}{iii}{checksum_char}" diff --git a/backend/shared/shared/common/utils.py b/backend/shared/shared/common/utils.py index 7b6188c105..ed1f5217e7 100644 --- a/backend/shared/shared/common/utils.py +++ b/backend/shared/shared/common/utils.py @@ -15,6 +15,24 @@ _ALWAYS_FALSE_Q_FILTER = Q(pk=None) # A hack but works as primary keys can't be null +# Mapping from Finnish social security number century character to century number. +# Should be the same as stdnum.fi.hetu._century_codes but that dictionary is private. +_CENTURY_CHAR_TO_CENTURY_NUMBER = { + "+": 1800, + "-": 1900, + "A": 2000, + "B": 2000, + "C": 2000, + "D": 2000, + "E": 2000, + "F": 2000, + "U": 1900, + "V": 1900, + "W": 1900, + "X": 1900, + "Y": 1900, +} + def any_of_q_filter(**kwargs): """ @@ -123,5 +141,5 @@ def social_security_number_birthdate(social_security_number) -> date: month = int(compacted_social_security_number[2:4]) year_mod_100 = int(compacted_social_security_number[4:6]) century_char = compacted_social_security_number[6] - century = {"+": 1800, "-": 1900, "A": 2000}[century_char] + century = _CENTURY_CHAR_TO_CENTURY_NUMBER[century_char] return date(year=century + year_mod_100, month=month, day=day) diff --git a/backend/tet/requirements.in b/backend/tet/requirements.in index 8377e887d9..eaad5d2dd1 100644 --- a/backend/tet/requirements.in +++ b/backend/tet/requirements.in @@ -16,7 +16,7 @@ filetype mozilla-django-oidc pillow psycopg2 -python-stdnum +python-stdnum>=1.19 requests sentry-sdk xlsxwriter diff --git a/backend/tet/requirements.txt b/backend/tet/requirements.txt index 727b013e49..19b3f69386 100644 --- a/backend/tet/requirements.txt +++ b/backend/tet/requirements.txt @@ -138,7 +138,7 @@ python-dateutil==2.8.1 # pysaml2 python-jose==3.3.0 # via django-helusers -python-stdnum==1.17 +python-stdnum==1.19 # via -r requirements.in pytz==2021.1 # via diff --git a/frontend/benefit/applicant/package.json b/frontend/benefit/applicant/package.json index 37646d8567..18e58975a6 100644 --- a/frontend/benefit/applicant/package.json +++ b/frontend/benefit/applicant/package.json @@ -25,7 +25,7 @@ "camelcase-keys": "^7.0.2", "date-fns": "^2.24.0", "dotenv": "^16.0.0", - "finnish-ssn": "^2.1.1", + "finnish-ssn": "^2.1.2", "formik": "^2.2.9", "hds-design-tokens": "^2.17.0", "hds-react": "^2.17.0", diff --git a/frontend/benefit/handler/package.json b/frontend/benefit/handler/package.json index e9ce2a1fe3..69ce5e4f08 100644 --- a/frontend/benefit/handler/package.json +++ b/frontend/benefit/handler/package.json @@ -23,7 +23,7 @@ "date-fns": "^2.24.0", "dotenv": "^16.0.0", "finnish-business-ids": "^3.1.1", - "finnish-ssn": "^2.1.1", + "finnish-ssn": "^2.1.2", "formik": "^2.2.9", "fuse.js": "^6.6.2", "hds-design-tokens": "^2.17.0", diff --git a/frontend/kesaseteli/handler/package.json b/frontend/kesaseteli/handler/package.json index 70c16f97ac..82b042cb73 100644 --- a/frontend/kesaseteli/handler/package.json +++ b/frontend/kesaseteli/handler/package.json @@ -23,7 +23,7 @@ "@sentry/nextjs": "^7.16.0", "axios": "^0.27.2", "dotenv": "^16.0.0", - "finnish-ssn": "2.0.4", + "finnish-ssn": "^2.1.2", "hds-design-tokens": "^2.17.0", "hds-react": "^2.17.0", "lodash": "^4.17.21", diff --git a/frontend/kesaseteli/shared/package.json b/frontend/kesaseteli/shared/package.json index 6c493e891d..964b0ef3af 100644 --- a/frontend/kesaseteli/shared/package.json +++ b/frontend/kesaseteli/shared/package.json @@ -15,7 +15,7 @@ "dependencies": { "@frontend/shared": "*", "axios": "^0.27.2", - "finnish-ssn": "2.0.4", + "finnish-ssn": "^2.1.2", "react-query": "^3.34.0" }, "devDependencies": { diff --git a/frontend/kesaseteli/youth/package.json b/frontend/kesaseteli/youth/package.json index cb403219d3..5ba8d14910 100644 --- a/frontend/kesaseteli/youth/package.json +++ b/frontend/kesaseteli/youth/package.json @@ -24,7 +24,7 @@ "@sentry/nextjs": "^7.16.0", "axios": "^0.27.2", "dotenv": "^16.0.0", - "finnish-ssn": "2.0.4", + "finnish-ssn": "^2.1.2", "hds-design-tokens": "^2.17.0", "hds-react": "^2.17.0", "lodash": "^4.17.21", diff --git a/frontend/shared/package.json b/frontend/shared/package.json index 61c62080d2..7d1793449f 100644 --- a/frontend/shared/package.json +++ b/frontend/shared/package.json @@ -21,7 +21,7 @@ "dotenv": "^16.0.0", "express": "^4.17.1", "fast-deep-equal": "^3.1.3", - "finnish-ssn": "^2.1.1", + "finnish-ssn": "^2.1.2", "hds-react": "^2.17.0", "js-file-download": "^0.4.12", "lodash": "^4.17.21", diff --git a/frontend/shared/src/__tests__/constants.test.ts b/frontend/shared/src/__tests__/constants.test.ts index 909a01241c..24ec09240c 100644 --- a/frontend/shared/src/__tests__/constants.test.ts +++ b/frontend/shared/src/__tests__/constants.test.ts @@ -4,6 +4,7 @@ import { ADDRESS_REGEX, CITY_REGEX, COMPANY_BANK_ACCOUNT_NUMBER, + FINNISH_SSN_REGEX, NAMES_REGEX, PHONE_NUMBER_REGEX, POSTAL_CODE_REGEX, @@ -197,5 +198,97 @@ describe('constants', () => { expect(accountNumber).not.toMatch(COMPANY_BANK_ACCOUNT_NUMBER); }); }); + + describe('FINNISH_SSN_REGEX', () => { + it('should match Finnish non-temporary social security numbers', () => { + const socialSecurityNumbers = [ + '010100+002H', + '010203-1230', + '111111-002V', + '111111-111C', + '111111A111C', + '111111U111C', + '111111V111C', + '111111W111C', + '111111X111C', + '111111Y111C', + '121212A899H', + '121212B899H', + '121212C899H', + '121212D899H', + '121212E899H', + '121212F899H', + '300522A0024', + '300522B0024', + // All possible checksum characters & before first & beyond last + '111111B098Y', + '111111B0990', + '111111B1001', + '111111B1012', + '111111B1023', + '111111B1034', + '111111B1045', + '111111B1056', + '111111B1067', + '111111B1078', + '111111B1089', + '111111B109A', + '111111B110B', + '111111B111C', + '111111B112D', + '111111B113E', + '111111B114F', + '111111B115H', + '111111B116J', + '111111B117K', + '111111B118L', + '111111B119M', + '111111B120N', + '111111B121P', + '111111B122R', + '111111B123S', + '111111B124T', + '111111B125U', + '111111B126V', + '111111B127W', + '111111B128X', + '111111B129Y', + '111111B1300', + ]; + + socialSecurityNumbers.forEach((socialSecurityNumber) => { + expect(socialSecurityNumber).toMatch(FINNISH_SSN_REGEX); + }); + }); + + it('should not match Finnish temporary or invalid social security numbers', () => { + const socialSecurityNumbers = [ + '111111-900U', // Otherwise valid but 900–999 individual number is rejected + '111111-9991', // Otherwise valid but 900–999 individual number is rejected + '111111-900U', // Otherwise valid but 900–999 individual number is rejected + '111111-9991', // Otherwise valid but 900–999 individual number is rejected + '311299A999E', // Otherwise valid but 900–999 individual number is rejected + '311299F999E', // Otherwise valid but 900–999 individual number is rejected + '30052 2A0025', // Inner whitespace + '111111 -111x', // Invalid checksum, inner whitespace + '111111/111C', // Invalid century character + '111111G111C', // Invalid century character + '111111M111C', // Invalid century character + '111111R111C', // Invalid century character + '111111T111C', // Invalid century character + '111111Z111C', // Invalid century character + '111111B111G', // Invalid checksum character + '111111B111I', // Invalid checksum character + '111111B111O', // Invalid checksum character + '111111B111Q', // Invalid checksum character + '111111B111Z', // Invalid checksum character + '111111B111', // Missing checksum + ]; + + socialSecurityNumbers.forEach((socialSecurityNumber) => { + expect(socialSecurityNumber).not.toMatch(FINNISH_SSN_REGEX); + }); + }); + }); }); }); diff --git a/frontend/shared/src/constants.ts b/frontend/shared/src/constants.ts index 969ca4ff8a..da33bec812 100644 --- a/frontend/shared/src/constants.ts +++ b/frontend/shared/src/constants.ts @@ -26,6 +26,6 @@ export const DATE_VTJ_REGEX = /^\d{4}(0?[1-9]|1[0-2])(0?[1-9]|[12]\d|3[01])$/; // a modification of finnish ssn regex https://regex101.com/library/HPFWw6 that does not accept "fake" (keinotunnus) ssn's: // https://www.tuomas.salste.net/doc/tunnus/henkilotunnus.html#keinotunnus (more info only in finnish) -export const FINNISH_SSN_REGEX = /^\d{6}[+Aa-][0-8]\d{2}[\dA-z]$/; +export const FINNISH_SSN_REGEX = /^\d{6}[+a-fu-y-][0-8]\d{2}[\da-fhj-npr-y]$/i; export const WEBSITE_URL = /(https?:\/\/(?:www\.|(?!www))[\da-z][\da-z-]+[\da-z]\.\S{2,}|www\.[\da-z][\da-z-]+[\da-z]\.\S{2,}|https?:\/\/(?:www\.|(?!www))[\da-z]+\.\S{2,}|www\.[\da-z]+\.\S{2,})/gi; diff --git a/frontend/shared/src/utils/__tests__/mask-gdpr-data.test.ts b/frontend/shared/src/utils/__tests__/mask-gdpr-data.test.ts index 8526afd9ad..c5adf0d335 100644 --- a/frontend/shared/src/utils/__tests__/mask-gdpr-data.test.ts +++ b/frontend/shared/src/utils/__tests__/mask-gdpr-data.test.ts @@ -8,6 +8,7 @@ import maskGDPRData from 'shared/utils/mask-gdpr-data'; const fakeObjectFactory = new FakeObjectFactory({ useUuid: false }); const masked = (str?: string): string => '*'.repeat(str?.length ?? 0); +const MASKED_SSN = '***********' as const; describe('frontend/shared/src/utils/masked-gdpr-data.ts', () => { it('masks youth application', () => { @@ -66,8 +67,13 @@ describe('frontend/shared/src/utils/masked-gdpr-data.ts', () => { ); const text1 = faker.lorem.text(); const text2 = faker.lorem.text(); - expect(maskGDPRData(text1 + ssn + text2)).toEqual( - text1 + masked(ssn) + text2 + const expectedResult = text1 + MASKED_SSN + text2; + expect(maskGDPRData(text1 + ssn + text2)).toEqual(expectedResult); + expect(maskGDPRData(text1 + ssn.toLowerCase() + text2)).toEqual( + expectedResult + ); + expect(maskGDPRData(text1 + ssn.toUpperCase() + text2)).toEqual( + expectedResult ); }); @@ -80,17 +86,30 @@ describe('frontend/shared/src/utils/masked-gdpr-data.ts', () => { ); const text1 = faker.lorem.text(); const text2 = faker.lorem.text(); - const obj = { - foo: { - bar: text1 + ssn1 + text2, - }, - baz: `${ssn1}0${ssn2}`, + const expectedResult = { + foo: { bar: text1 + MASKED_SSN + text2 }, + baz: `${MASKED_SSN}0${MASKED_SSN}`, }; - expect(maskGDPRData(obj)).toEqual({ - foo: { - bar: text1 + masked(ssn1) + text2, - }, - baz: `${masked(ssn1)}0${masked(ssn2)}`, - }); + + expect( + maskGDPRData({ + foo: { bar: text1 + ssn1 + text2 }, + baz: `${ssn1}0${ssn2}`, + }) + ).toEqual(expectedResult); + + expect( + maskGDPRData({ + foo: { bar: text1 + ssn1.toLowerCase() + text2 }, + baz: `${ssn1.toLowerCase()}0${ssn2.toLowerCase()}`, + }) + ).toEqual(expectedResult); + + expect( + maskGDPRData({ + foo: { bar: text1 + ssn1.toUpperCase() + text2 }, + baz: `${ssn1.toUpperCase()}0${ssn2.toUpperCase()}`, + }) + ).toEqual(expectedResult); }); }); diff --git a/frontend/shared/src/utils/mask-gdpr-data.ts b/frontend/shared/src/utils/mask-gdpr-data.ts index 627decae93..c98c07bbb2 100644 --- a/frontend/shared/src/utils/mask-gdpr-data.ts +++ b/frontend/shared/src/utils/mask-gdpr-data.ts @@ -7,7 +7,7 @@ import { isString } from 'shared/utils/type-guards'; const maskFinnishSsn = (text: unknown): unknown => isString(text) - ? text.replace(/\d{6}[+-y]\d{3}[\dA-z]/g, '*'.repeat(11)) + ? text.replace(/\d{6}[+a-z-]\d{3}[\da-z]/gi, '*'.repeat(11)) : text; /* Gets the length of the given variable */ diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 74e86fd043..fb7caa8ece 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7225,15 +7225,10 @@ finnish-business-ids@^3.1.1: resolved "https://registry.yarnpkg.com/finnish-business-ids/-/finnish-business-ids-3.1.1.tgz#cedde5d391bd290f9112e51d81cb2a340f4a7ee4" integrity sha512-9MbFNBoLas7BoYw+rtPYy0xTAtfE1ResD0CUMEZLXuSythlpThZjuBHF35ELNIMslSnZnHRVpzYKrQrf9buWSQ== -finnish-ssn@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/finnish-ssn/-/finnish-ssn-2.0.4.tgz#5614b48b91ba63f4a27367971797291a47628d8c" - integrity sha512-boRI1T1W4Y+d6A8A5aujN7HJVE6HEXtXOevasvo8Xipi2gxcBKTSj07OWVoJl2bq4eBtH4drh2UgNMnQpHIJ+A== - -finnish-ssn@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/finnish-ssn/-/finnish-ssn-2.1.1.tgz#0f0685666530ddc4c36a0c0d34d0ba0c413d7cb2" - integrity sha512-0mwAfmHw2PlSZ+niAWNtYvzw+byG881qSIN9sWZRadxE3CW8KdtIYlfwvk0J7MzlnrE7HM19DM78LJrt7X1PvQ== +finnish-ssn@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/finnish-ssn/-/finnish-ssn-2.1.2.tgz#5fa8d42a42fe7b3de214240c7dd373f3b18fda3f" + integrity sha512-B8xXx9KNnwG0l0wUkz6OtctYs/ffTlvVcbj4W1qFMgI/YyB5XCjEqTcbZ3ZK0tgOourb54CSZAenQQQNVxzBMA== flat-cache@^3.0.4: version "3.0.4"