diff --git a/package-lock.json b/package-lock.json index 8a75172..0be8e99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18700,6 +18700,16 @@ "express": "^5.1.0" } }, + "packages/testing/node_modules/@digabi/validation": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@digabi/validation/-/validation-3.2.0.tgz", + "integrity": "sha512-EOPf5Ocr4z4D4gGVeWWy+0R+y6IQUoCQdgd4RTI/DoERgymT4L4mxxqI2JBIihd8WtiiUiir1niY4ho0zxeItg==", + "dev": true, + "license": "EUPL-1.2", + "dependencies": { + "zod": "^4.4.3" + } + }, "packages/typescript-config": { "name": "@digabi/typescript-config", "version": "3.0.0", @@ -18707,16 +18717,25 @@ }, "packages/user-management": { "name": "@digabi/user-management", - "version": "0.13.0", + "version": "0.14.0-feature-ssn-schemas-with-input-trimming.0", + "license": "EUPL-1.2", + "dependencies": { + "@digabi/validation": "^4.0.0-feature-ssn-schemas-with-input-trimming.0", + "zod": "^4.4.3" + } + }, + "packages/user-management/node_modules/@digabi/validation": { + "version": "4.0.0-feature-ssn-schemas-with-input-trimming.0", + "resolved": "https://registry.npmjs.org/@digabi/validation/-/validation-4.0.0-feature-ssn-schemas-with-input-trimming.0.tgz", + "integrity": "sha512-6xR5HxzG8pILBbbIyHFl3qJ253gNB+C7yiYJ8MZ+T73bHAsBkHy2D3oVF7lTy0RYAlFOlfTQ79odaO/KwWbDuw==", "license": "EUPL-1.2", "dependencies": { - "@digabi/validation": "^3.1.0", "zod": "^4.4.3" } }, "packages/validation": { "name": "@digabi/validation", - "version": "3.2.0", + "version": "4.1.0-feature-ssn-schemas-with-input-trimming.0", "license": "EUPL-1.2", "dependencies": { "zod": "^4.4.3" diff --git a/packages/user-management/package.json b/packages/user-management/package.json index bbe2199..257c560 100644 --- a/packages/user-management/package.json +++ b/packages/user-management/package.json @@ -1,6 +1,6 @@ { "name": "@digabi/user-management", - "version": "0.13.0", + "version": "0.14.0-feature-ssn-schemas-with-input-trimming.0", "author": "Matriculation Examination Board, Finland", "license": "EUPL-1.2", "repository": { @@ -27,7 +27,7 @@ "access": "public" }, "dependencies": { - "@digabi/validation": "^3.1.0", + "@digabi/validation": "^4.0.0-feature-ssn-schemas-with-input-trimming.0", "zod": "^4.4.3" } } diff --git a/packages/user-management/src/user-types.ts b/packages/user-management/src/user-types.ts index baf8e6d..864e009 100644 --- a/packages/user-management/src/user-types.ts +++ b/packages/user-management/src/user-types.ts @@ -1,6 +1,6 @@ import z from 'zod' import { RoleSchema } from './validations' -import { zodSsn } from '@digabi/validation' +import { ssnSchema } from '@digabi/validation' import { PermissionGrantSchema } from './permissions' export type Role = z.infer @@ -19,8 +19,8 @@ export const PrincipalOrganizationSchema = z.object({ export type Impersonation = z.infer export const ImpersonationSchema = z.object({ id: z.uuid(), - realSsn: zodSsn, - impersonatedSsn: zodSsn.or(z.literal('')), + realSsn: ssnSchema, + impersonatedSsn: ssnSchema.or(z.literal('')), explanation: z.string(), validityStart: z.string(), validityEnd: z.string(), @@ -32,7 +32,7 @@ export const MockUserSchema = z.object({ username: z.string(), lastname: z.string(), firstname: z.string(), - ssn: zodSsn, + ssn: ssnSchema, henkiloOid: z.string(), principalOrganization: z.array(PrincipalOrganizationSchema), roles: z.array(z.object({ role: z.string() })).optional() @@ -143,7 +143,7 @@ export const UserSchoolSchema = z.object({ export type User = z.infer export const UserSchema = StoredUserDetailsSchema.extend({ - ssn: zodSsn, + ssn: ssnSchema, userAccountId: z.string(), schools: z.array(UserSchoolSchema), censoring: CensoringSchema.optional(), @@ -152,7 +152,7 @@ export const UserSchema = StoredUserDetailsSchema.extend({ export type UserToUpsert = z.infer export const UserToUpsertSchema = UserDetailsSchema.extend({ - ssn: zodSsn, + ssn: ssnSchema, schools: z.array(UserSchoolToUpsertSchema), censoring: CensoringSchema.optional() }).strict() @@ -185,7 +185,7 @@ export const UserForAuthenticationSchema = z.xor([UserSchema, ImpersonatedUserSc export type UserWithSchoolRoleAndSchoolId = z.infer export const UserWithSchoolRoleAndSchoolIdSchema = z.object({ userAccountId: z.string(), - ssn: zodSsn, + ssn: ssnSchema, userDetails: UserDetailsSchema, name: z.string(), allowedExams: z.array(z.string()), diff --git a/packages/validation/__tests__/validation.test.ts b/packages/validation/__tests__/validation.test.ts index 4594760..6c4ca60 100644 --- a/packages/validation/__tests__/validation.test.ts +++ b/packages/validation/__tests__/validation.test.ts @@ -3,162 +3,245 @@ import { test, describe } from 'node:test' import assert from 'node:assert/strict' describe('validation-test', () => { - describe('validating ssn', () => { - test('accepts valid ssn', () => { - assert.equal(validation.isSsnValid('231194-246W'), true) + describe('validation functions', () => { + describe('isSsnValid', () => { + test('accepts valid ssn', () => { + assert.equal(validation.isSsnValid('231194-246W'), true) + }) + + test('accepts check char in lower case', () => { + assert.equal(validation.isSsnValid('231194-246w'), true) + }) + + test('accepts ssn not from 20th century', () => { + assert.equal(validation.isSsnValid('231194+246w'), true) + assert.equal(validation.isSsnValid('010601A0102'), true) + assert.equal(validation.isSsnValid('010601a0102'), true) + }) + + test('accepts ssn from 20th century', () => { + assert.equal(validation.isSsnValid('010103A2465'), true) + assert.equal(validation.isSsnValid('010280Y246u'), true) + assert.equal(validation.isSsnValid('010280X246u'), true) + assert.equal(validation.isSsnValid('010280W246u'), true) + assert.equal(validation.isSsnValid('010280V246u'), true) + assert.equal(validation.isSsnValid('010280U246u'), true) + assert.equal(validation.isSsnValid('010280G246u'), false) + assert.equal(validation.isSsnValid('010280y246u'), true) + assert.equal(validation.isSsnValid('010280x246u'), true) + assert.equal(validation.isSsnValid('010280w246u'), true) + assert.equal(validation.isSsnValid('010280v246u'), true) + assert.equal(validation.isSsnValid('010280u246u'), true) + assert.equal(validation.isSsnValid('010280g246u'), false) + }) + + test('accepts ssn from 21st century', () => { + assert.equal(validation.isSsnValid('010103A2465'), true) + assert.equal(validation.isSsnValid('010103B2465'), true) + assert.equal(validation.isSsnValid('010103C2465'), true) + assert.equal(validation.isSsnValid('010103D2465'), true) + assert.equal(validation.isSsnValid('010103E2465'), true) + assert.equal(validation.isSsnValid('010103F2465'), true) + assert.equal(validation.isSsnValid('010103a2465'), true) + assert.equal(validation.isSsnValid('010103b2465'), true) + assert.equal(validation.isSsnValid('010103c2465'), true) + assert.equal(validation.isSsnValid('010103d2465'), true) + assert.equal(validation.isSsnValid('010103e2465'), true) + assert.equal(validation.isSsnValid('010103f2465'), true) + assert.equal(validation.isSsnValid('010103Z2465'), false) + assert.equal(validation.isSsnValid('010103z2465'), false) + }) + + test('accepts when check char is a number', () => { + assert.equal(validation.isSsnValid('130672-4400'), true) + }) + + test('accepts ssn with leading and trailing whitespace ', () => { + assert.equal(validation.isSsnValid(' 130672-4400\t\n'), true) + }) + + test('rejects when check char is wrong', () => { + assert.equal(validation.isSsnValid('010601A010W'), false) + }) + + test('rejects when check char is missing', () => { + assert.equal(validation.isSsnValid('010601A010'), false) + }) + + test('rejects when century separator is missing', () => { + assert.equal(validation.isSsnValid('0106010102'), false) + }) + + test('rejects when century separator is invalid', () => { + assert.equal(validation.isSsnValid('0907184X22N'), false) + assert.equal(validation.isSsnValid('0907184x22N'), false) + }) + + test('rejects when there are extraneous alphabetical characters', () => { + assert.equal(validation.isSsnValid('020698-01IS'), false) + }) + + test('rejects when ssn is too long', () => { + assert.equal(validation.isSsnValid('130672-44001'), false) + }) + + test('rejects when ssn is too short', () => { + assert.equal(validation.isSsnValid('130672-44'), false) + }) + + test('rejects when ssn length is valid but checksum char is whitespace', () => { + assert.equal(validation.isSsnValid('130672-44 '), false) + }) + + test('accepts 010101-0101 as special case', () => { + assert.equal(validation.isSsnValid('010101-0101'), true) + assert.equal(validation.isSsnValid('010101-0101', true), true) + }) + + test('accepts when person is over hundred years old, and age sanity check is off', () => { + assert.equal(validation.isSsnValid('100417-458U'), true) + assert.equal(validation.isSsnValid('231194+246w'), true) + }) + + test('rejects when person is over hundred years old, and age sanity check is on', () => { + assert.equal(validation.isSsnValid('231194+246w', true), false) + for (const separator of ['-', 'Y', 'X', 'W', 'V', 'U']) { + assert.equal(validation.isSsnValid(`010120${separator}123K`, true), false, separator) + assert.equal(validation.isSsnValid(`010180${separator}1232`, true), true, separator) + } + }) + + test('accepts person not yet born when age sanity check is off', () => { + assert.equal(validation.isSsnValid('071144A239E'), true) + }) + + test('rejects person not yet born when age sanity check is on', () => { + assert.equal(validation.isSsnValid('071144A239E', true), false) + }) + + test('Accepts an artificial ssn (last part starts with 9)', () => { + assert.equal(validation.isSsnValid('010697-9184'), true) + }) + + test('Accepts a special YTL-defined fake ssn (checkCode calculation is skipped)', () => { + assert.equal(validation.isSsnValid('210999-U097'), true) + }) + }) + + describe('isFakeYtlSsn', () => { + test('detects "A" century seperator fake ssn', () => assert.equal(validation.isFakeYtlSsn('200102AU000'), true)) + + test('detects "-" century seperator fake ssn', () => assert.equal(validation.isFakeYtlSsn('200192-U000'), true)) + + test('rejects valid SSN', () => assert.equal(validation.isFakeYtlSsn('231194-246W'), false)) + }) + + describe('isValidEmail', () => { + test('Rejects invalid student email', () => { + const invalidEmails = [ + 'testuserexample.com', + 'testuserexample.com...', + 'tesdkjgqjeklqjwköejökqwjegökljeqwefiqewifqhweufhqowiuhfoqiuwehfoiuqwhefoiuqwhefoiuqhweofiuhqwoiuehfoqiuwehfouiqwehfoiuqwhefoiuqhweoiufhqwouiefhoqiuwehfoiquwehfoiuqhweiufhqwoieufhoqiuwehfoiuqhweouuiuweiqutqiopwuetiouqwepiotuqpwioeutpqwoeutpoqiwuetpoqiwuetpoquweodhlskjhfajdhlajsdhgituqwpoefiht@userexample.com', + 'erkki.emptysubdomain@.foo.bar', + 'test.user@@example.com', + 'test..user@example.com', + 'test.user@', + '.test.user@example.com', + ';test.user@example.com', + 'john.käärä@example.com', + {} as string + ] + + for (const email of invalidEmails) { + assert.equal(validation.isValidEmail(email), false) + } + }) + + test('accepts valid email', () => { + const validEmails = ['', 'a@a.com', 'test@example.com'] + + for (const email of validEmails) { + assert.equal(validation.isValidEmail(email), true) + } + }) }) - - test('accepts check char in lower case', () => { - assert.equal(validation.isSsnValid('231194-246w'), true) - }) - - test('accepts ssn not from 20th century', () => { - assert.equal(validation.isSsnValid('231194+246w'), true) - assert.equal(validation.isSsnValid('010601A0102'), true) - assert.equal(validation.isSsnValid('010601a0102'), true) - }) - - test('accepts ssn from 20th century', () => { - assert.equal(validation.isSsnValid('010103A2465'), true) - assert.equal(validation.isSsnValid('010280Y246u'), true) - assert.equal(validation.isSsnValid('010280X246u'), true) - assert.equal(validation.isSsnValid('010280W246u'), true) - assert.equal(validation.isSsnValid('010280V246u'), true) - assert.equal(validation.isSsnValid('010280U246u'), true) - assert.equal(validation.isSsnValid('010280G246u'), false) - assert.equal(validation.isSsnValid('010280y246u'), true) - assert.equal(validation.isSsnValid('010280x246u'), true) - assert.equal(validation.isSsnValid('010280w246u'), true) - assert.equal(validation.isSsnValid('010280v246u'), true) - assert.equal(validation.isSsnValid('010280u246u'), true) - assert.equal(validation.isSsnValid('010280g246u'), false) - }) - - test('accepts ssn from 21st century', () => { - assert.equal(validation.isSsnValid('010103A2465'), true) - assert.equal(validation.isSsnValid('010103B2465'), true) - assert.equal(validation.isSsnValid('010103C2465'), true) - assert.equal(validation.isSsnValid('010103D2465'), true) - assert.equal(validation.isSsnValid('010103E2465'), true) - assert.equal(validation.isSsnValid('010103F2465'), true) - assert.equal(validation.isSsnValid('010103a2465'), true) - assert.equal(validation.isSsnValid('010103b2465'), true) - assert.equal(validation.isSsnValid('010103c2465'), true) - assert.equal(validation.isSsnValid('010103d2465'), true) - assert.equal(validation.isSsnValid('010103e2465'), true) - assert.equal(validation.isSsnValid('010103f2465'), true) - assert.equal(validation.isSsnValid('010103Z2465'), false) - assert.equal(validation.isSsnValid('010103z2465'), false) - }) - - test('accepts when check char is a number', () => { - assert.equal(validation.isSsnValid('130672-4400'), true) - }) - - test('accepts ssn with leading and trailing whitespace ', () => { - assert.equal(validation.isSsnValid(' 130672-4400\t\n'), true) - }) - - test('rejects when check char is wrong', () => { - assert.equal(validation.isSsnValid('010601A010W'), false) - }) - - test('rejects when check char is missing', () => { - assert.equal(validation.isSsnValid('010601A010'), false) - }) - - test('rejects when century separator is missing', () => { - assert.equal(validation.isSsnValid('0106010102'), false) - }) - - test('rejects when century separator is invalid', () => { - assert.equal(validation.isSsnValid('0907184X22N'), false) - assert.equal(validation.isSsnValid('0907184x22N'), false) - }) - - test('rejects when there are extraneous alphabetical characters', () => { - assert.equal(validation.isSsnValid('020698-01IS'), false) - }) - - test('rejects when ssn is too long', () => { - assert.equal(validation.isSsnValid('130672-44001'), false) - }) - - test('rejects when ssn is too short', () => { - assert.equal(validation.isSsnValid('130672-44'), false) - }) - - test('rejects when ssn length is valid but checksum char is whitespace', () => { - assert.equal(validation.isSsnValid('130672-44 '), false) - }) - - test('accepts 010101-0101 as special case', () => { - assert.equal(validation.isSsnValid('010101-0101'), true) - assert.equal(validation.isSsnValid('010101-0101', true), true) - }) - - test('accepts when person is over hundred years old, and age sanity check is off', () => { - assert.equal(validation.isSsnValid('100417-458U'), true) - assert.equal(validation.isSsnValid('231194+246w'), true) - }) - - test('rejects when person is over hundred years old, and age sanity check is on', () => { - assert.equal(validation.isSsnValid('100417-458U', true), false) - assert.equal(validation.isSsnValid('231194+246w', true), false) - }) - - test('accepts person not yet born when age sanity check is off', () => { - assert.equal(validation.isSsnValid('071144A239E'), true) - }) - - test('rejects person not yet born when age sanity check is on', () => { - assert.equal(validation.isSsnValid('071144A239E', true), false) - }) - - test('Accepts an artificial ssn (last part starts with 9)', () => { - assert.equal(validation.isSsnValid('010697-9184'), true) - }) - - test('Accepts a special YTL-defined fake ssn (checkCode calculation is skipped)', () => { - assert.equal(validation.isSsnValid('210999-U097'), true) - }) - }) - - describe('isFakeYtlSsn', () => { - test('detects "A" century seperator fake ssn', () => assert.equal(validation.isFakeYtlSsn('200102AU000'), true)) - - test('detects "-" century seperator fake ssn', () => assert.equal(validation.isFakeYtlSsn('200192-U000'), true)) - - test('rejects valid ssn', () => assert.equal(validation.isFakeYtlSsn('231194-246W'), false)) }) - - describe('isValidEmail', () => { - test('Rejects invalid student email', () => { - const invalidEmails = [ - 'testuserexample.com', - 'testuserexample.com...', - 'tesdkjgqjeklqjwköejökqwjegökljeqwefiqewifqhweufhqowiuhfoqiuwehfoiuqwhefoiuqwhefoiuqhweofiuhqwoiuehfoqiuwehfouiqwehfoiuqwhefoiuqhweoiufhqwouiefhoqiuwehfoiquwehfoiuqhweiufhqwoieufhoqiuwehfoiuqhweouuiuweiqutqiopwuetiouqwepiotuqpwioeutpqwoeutpoqiwuetpoqiwuetpoquweodhlskjhfajdhlajsdhgituqwpoefiht@userexample.com', - 'erkki.emptysubdomain@.foo.bar', - 'test.user@@example.com', - 'test..user@example.com', - 'test.user@', - '.test.user@example.com', - ';test.user@example.com', - 'john.käärä@example.com', - {} as string - ] - - for (const email of invalidEmails) { - assert.equal(validation.isValidEmail(email), false) - } - }) - - test('accepts valid email', () => { - const validEmails = ['', 'a@a.com', 'test@example.com'] - - for (const email of validEmails) { - assert.equal(validation.isValidEmail(email), true) - } + describe('ssn schemas', () => { + // Over hundred years old: valid without age checks, rejected with them + const over100 = ' \t231194+246W\n ' + const malformedOver100 = '\n 231194+24W \t' + const over100fakeYTL = '\n 231194+U46W \t' + + describe('ssnSchema', () => { + test('trims and uppercases', () => { + const result = validation.ssnSchema.safeParse(' 231194-246w\t\n') + assert.deepEqual(result, { success: true, data: '231194-246W' }) + }) + + test('rejects invalid ssn with message', () => { + const result = validation.ssnSchema.safeParse('231194-246X') + assert.equal(result.success, false) + assert.equal(result.error?.issues[0].message, 'SSN must be valid') + }) + + test('accepts person over hundred years old', () => { + assert.equal(validation.ssnSchema.safeParse(over100).success, true) + }) + + test('accepts fake YTL SSN', () => { + let result = validation.ssnSchema.safeParse('\t\n231194-u46w ') + assert.equal(result.success, true) + result = validation.ssnSchema.safeParse(' 231101au46w\t\n') + assert.equal(result.success, true) + }) + }) + + describe('ssnSchemaWithAgeChecks', () => { + test('trims and uppercases', () => { + const result = validation.ssnSchemaWithAgeChecks.safeParse(' 010601a0102 ') + assert.deepEqual(result, { success: true, data: '010601A0102' }) + }) + + test('rejects person over hundred years old with message', () => { + const result = validation.ssnSchemaWithAgeChecks.safeParse(over100) + assert.equal(result.success, false) + assert.notEqual(result.error?.issues[0].message, 'SSN must be valid') + assert.equal(result.error?.issues[0].message, 'SSN fails age checks') + }) + + test('rejects person over hundred years old with invalid SSN with a message about invalidity instead of age checks', () => { + const result = validation.ssnSchemaWithAgeChecks.safeParse(malformedOver100) + assert.equal(result.success, false) + assert.equal(result.error?.issues[0].message, 'SSN must be valid') + assert.notEqual(result.error?.issues[0].message, 'SSN fails age checks') + }) + + test('rejects person over 100 years old with fake YTL SSN with message', () => { + const result = validation.ssnSchemaWithAgeChecks.safeParse(over100fakeYTL) + assert.equal(result.success, false) + assert.notEqual(result.error?.issues[0].message, 'SSN must be valid') + assert.equal(result.error?.issues[0].message, 'SSN fails age checks') + }) + }) + + describe('fakeYtlSsnSchema', () => { + test('trims and uppercases', () => { + const result = validation.fakeYtlSsnSchema.safeParse(' 200102AU00a\n') + assert.deepEqual(result, { success: true, data: '200102AU00A' }) + }) + + test('rejects non-fake ssn with message', () => { + const result = validation.fakeYtlSsnSchema.safeParse('231194-246W') + assert.equal(result.success, false) + assert.equal(result.error?.issues[0].message, 'Invalid YTL fake SSN') + }) + + test('accepts fake SSN even if U is lowercase', () => { + let result = validation.fakeYtlSsnSchema.safeParse('200102au00a') + assert.equal(result.success, true) + result = validation.fakeYtlSsnSchema.safeParse('200198-u00a') + assert.equal(result.success, true) + }) }) }) }) diff --git a/packages/validation/package.json b/packages/validation/package.json index ffc362c..402c288 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@digabi/validation", - "version": "3.2.0", + "version": "4.1.0-feature-ssn-schemas-with-input-trimming.0", "author": "Matriculation Examination Board, Finland", "license": "EUPL-1.2", "repository": { diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 9e34487..3ba68a6 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -36,18 +36,16 @@ export function isSsnValid(ssn: string, ageSanityCheckForExams = false): boolean } const centurySeparator21st = ['A', 'B', 'C', 'D', 'E', 'F'] - const specialFakeSsnToBeAllowed = '010101-0101' + const centurySeparator20th = ['-', 'Y', 'X', 'W', 'V', 'U'] + const matched = ssn .trim() - .match(/^([0-9]{6})([-+]|[A-F]|[a-f]|[Yy]|[Xx]|[Ww]|[Vv]|[Uu])([\d]{3}|U[\d]{2})([\dA-Ya-y])$/) + .match(/^([0-9]{6})([-+]|[A-F]|[a-f]|[Yy]|[Xx]|[Ww]|[Vv]|[Uu])([\d]{3}|[Uu][\d]{2})([\dA-Ya-y])$/) if (!matched) return false - const individualNumber = matched[3] - // Special YTL-defined fake ssn. checkCode calculations won't make sense for these - if (individualNumber.startsWith('U')) return true - const birthDate = matched[1] const centurySeparator = matched[2].toUpperCase() + const individualNumber = matched[3] const checkCode = matched[4].toUpperCase() const birthDateYear = Number(birthDate.slice(-2)) @@ -56,27 +54,40 @@ export function isSsnValid(ssn: string, ageSanityCheckForExams = false): boolean // We don't want to always do these checks, for example if we validate // data which is not related to current matricular exams if (ageSanityCheckForExams) { - if (ssn.trim() === specialFakeSsnToBeAllowed) return true + const specialFakeSsnToBeAllowed = '010101-0101' + if (specialFakeSsnToBeAllowed === ssn.trim().toUpperCase()) return true // Do not allow people over hundred years old (accidental use of - instead of A) if (centurySeparator === '+') return false - if (centurySeparator === '-' && birthDateYear < currentYear) return false + if (centurySeparator20th.includes(centurySeparator) && birthDateYear < currentYear) return false // Do not allow people not yet born if (centurySeparator21st.includes(centurySeparator) && birthDateYear > currentYear) return false } + // Special YTL-defined fake ssn. checkCode calculations won't make sense for these + if (isFakeYtlSsn(ssn)) return true + const checkCodeIndex = parseInt(birthDate.concat(individualNumber), 10) % 31 return checkCode === checkCodes[checkCodeIndex] } -export const zodSsn = z.string().refine(isSsnValid, { message: 'Invalid ssn' }).toUpperCase() +export const ssnSchema = z.string().trim().refine(isSsnValid, { error: 'SSN must be valid' }).toUpperCase() + +export const ssnSchemaWithAgeChecks = z + .string() + .trim() + .refine(isSsnValid, { error: 'SSN must be valid', abort: true }) + .refine(ssn => isSsnValid(ssn, true), { error: 'SSN fails age checks' }) + .toUpperCase() export function isFakeYtlSsn(ssn: string): boolean { - const fakeYtlSsnRegEx = /\d{6}(AU|-U)\d{2}\w$/ + const fakeYtlSsnRegEx = /^([0-9]{6})([-+]|[A-F]|[a-f]|[Yy]|[Xx]|[Ww]|[Vv]|[Uu])[Uu][\d]{2}([\dA-Ya-y])$/ return fakeYtlSsnRegEx.test(ssn) } +export const fakeYtlSsnSchema = z.string().trim().refine(isFakeYtlSsn, { error: 'Invalid YTL fake SSN' }).toUpperCase() + export function isValidEmail(email: string): boolean { const emailRegex = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/