diff --git a/package-lock.json b/package-lock.json index 8a75172..5e56915 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.0.1-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..d423e29 100644 --- a/packages/validation/__tests__/validation.test.ts +++ b/packages/validation/__tests__/validation.test.ts @@ -103,8 +103,11 @@ describe('validation-test', () => { }) 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) + 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', () => { @@ -124,6 +127,50 @@ describe('validation-test', () => { }) }) + describe('ssn schemas', () => { + // Over hundred years old: valid without age checks, rejected with them + const over100 = '231194+246W' + const malformedOver100 = '231194+24W' + + 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) + }) + }) + + 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') + }) + }) + }) + describe('isFakeYtlSsn', () => { test('detects "A" century seperator fake ssn', () => assert.equal(validation.isFakeYtlSsn('200102AU000'), true)) @@ -132,6 +179,19 @@ describe('validation-test', () => { test('rejects valid ssn', () => assert.equal(validation.isFakeYtlSsn('231194-246W'), false)) }) + 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') + }) + }) + describe('isValidEmail', () => { test('Rejects invalid student email', () => { const invalidEmails = [ diff --git a/packages/validation/package.json b/packages/validation/package.json index ffc362c..0f797ef 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@digabi/validation", - "version": "3.2.0", + "version": "4.0.1-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..85b7c63 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -36,7 +36,8 @@ 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])$/) @@ -56,10 +57,11 @@ 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 @@ -69,7 +71,14 @@ export function isSsnValid(ssn: string, ageSanityCheckForExams = false): boolean 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$/ @@ -77,6 +86,8 @@ export function isFakeYtlSsn(ssn: string): boolean { 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])+$/