Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/user-management/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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"
}
}
14 changes: 7 additions & 7 deletions packages/user-management/src/user-types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RoleSchema>
Expand All @@ -19,8 +19,8 @@ export const PrincipalOrganizationSchema = z.object({
export type Impersonation = z.infer<typeof ImpersonationSchema>
export const ImpersonationSchema = z.object({
id: z.uuid(),
realSsn: zodSsn,
impersonatedSsn: zodSsn.or(z.literal('')),
realSsn: ssnSchema,
impersonatedSsn: ssnSchema.or(z.literal('')),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Onko noi aina vain tyhjiä merkkijonoja vai voiko syötteenä olla esim. ' '? Koska siihenhän toi z.literal('') ei osu? Oletan että tää on ok tällaisenaan.

explanation: z.string(),
validityStart: z.string(),
validityEnd: z.string(),
Expand All @@ -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()
Expand Down Expand Up @@ -143,7 +143,7 @@ export const UserSchoolSchema = z.object({

export type User = z.infer<typeof UserSchema>
export const UserSchema = StoredUserDetailsSchema.extend({
ssn: zodSsn,
ssn: ssnSchema,
userAccountId: z.string(),
schools: z.array(UserSchoolSchema),
censoring: CensoringSchema.optional(),
Expand All @@ -152,7 +152,7 @@ export const UserSchema = StoredUserDetailsSchema.extend({

export type UserToUpsert = z.infer<typeof UserToUpsertSchema>
export const UserToUpsertSchema = UserDetailsSchema.extend({
ssn: zodSsn,
ssn: ssnSchema,
schools: z.array(UserSchoolToUpsertSchema),
censoring: CensoringSchema.optional()
}).strict()
Expand Down Expand Up @@ -185,7 +185,7 @@ export const UserForAuthenticationSchema = z.xor([UserSchema, ImpersonatedUserSc
export type UserWithSchoolRoleAndSchoolId = z.infer<typeof UserWithSchoolRoleAndSchoolIdSchema>
export const UserWithSchoolRoleAndSchoolIdSchema = z.object({
userAccountId: z.string(),
ssn: zodSsn,
ssn: ssnSchema,
userDetails: UserDetailsSchema,
name: z.string(),
allowedExams: z.array(z.string()),
Expand Down
62 changes: 61 additions & 1 deletion packages/validation/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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))

Expand All @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion packages/validation/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
19 changes: 15 additions & 4 deletions packages/validation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Comment thread
adetabs marked this conversation as resolved.

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])$/)
Expand All @@ -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
Expand All @@ -69,14 +71,23 @@ 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$/

@ahinkka ahinkka Oct 13, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clanker huomasi tässä myös ongelman että tuossa menee läpi esim. abc123200102AU001 kun varmaankin haluttaisiin että vain 123200102AU001 menisi?


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])+$/
Expand Down
Loading