Files
accounted/lib/salary/__tests__/personnummer.test.ts
T
ca93ef3fb6 fix(salary): surface employee-save failures and a typed 503 for the missing encryption key (#1996) (#2009)
* fix(salary): surface employee-save failures in the dialog and type the missing encryption key (#1996)

Pressing Spara in "Ny anställd" could fail without any feedback: a thrown
fetch or a non-JSON 5xx body escaped handleSubmit before setSaving(false)
ran, leaving the button stuck on "Sparar..." and the dialog silent. Even
when the toast did fire, the Radix modal aria-hides the root-layout
Toaster, so assistive tech (and the E2E driver that found this) heard
nothing, and the requestId support needs was never shown anywhere.

- NewEmployeeDialog: fetch + parse run in a never-throwing helper, saving
  is released in finally, the body is parsed with json().catch(() => null)
  so an HTML/plain-text error page still maps through the HTTP-status map,
  and the failure is rendered inline (role="alert" in the footer) with
  "Ärende-id: <requestId>" next to the single destructive toast.
- personnummer.ts: the production "key missing" throw now carries the
  registry code PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED, and the SALARY
  registry gains a 503 entry naming PERSONNUMMER_ENCRYPTION_KEY with a
  "contact support" message and a remediation hint. withRouteContext
  emits the typed envelope automatically instead of INTERNAL_ERROR 500,
  which read as transient and invited retries that can never succeed.
- Tests for the route (401, 400, 503 with requestId and no insert), the
  key guard, the registry entry, errorResponse dispatch on a coded Error,
  and getErrorMessage locale handling of the new envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(salary): address review findings (#1996)

- NewEmployeeDialog: fall back to the X-Request-Id response header when the
  body carries no error.requestId. The route hand-builds its 409 (duplicate
  personnummer) and generic insert-failure 500 bodies as flat strings, so the
  inline "Ärende-id" line was hidden for exactly the DB-failure class the
  issue names; withRouteContext sets the header on every response.
- Route tests pin that the 409 and 500 insert-error arms carry X-Request-Id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:57:54 +02:00

409 lines
16 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, it, expect, vi } from 'vitest'
import {
PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED,
validatePersonnummer,
extractLast4,
extractBirthDate,
calculateAge,
calculateAgeAtYearStart,
maskPersonnummer,
formatPersonnummer,
expandPersonnummerTo12,
encryptPersonnummer,
decryptPersonnummer,
} from '../personnummer'
describe('validatePersonnummer', () => {
it('accepts valid 12-digit personnummer', () => {
// Valid test personnummer (checksum matches)
const result = validatePersonnummer('199001019802')
expect(result.valid).toBe(true)
})
it('rejects non-12-digit input', () => {
const result = validatePersonnummer('9001019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('12 siffror')
})
it('rejects invalid month', () => {
const result = validatePersonnummer('199013019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('månad')
})
it('rejects invalid day', () => {
// Luhn-valid on purpose, so this proves the day check is what rejects it
// rather than the checksum incidentally failing first.
const result = validatePersonnummer('199001329805')
expect(result.valid).toBe(false)
expect(result.error).toContain('dag')
})
it('strips non-digits before validation', () => {
const result = validatePersonnummer('19900101-9802')
expect(result.valid).toBe(true)
})
// A samordningsnummer is a personnummer whose day field carries an added 60,
// so the printed day is 61-91. Skatteverket files these under FK215 in the
// arbetsgivardeklaration exactly like a personnummer, and our AGI generator
// accepts them, so the employee validator has to accept them too. All values
// below are synthetic and genuinely check-digit valid.
describe('samordningsnummer', () => {
it('accepts day 61 (the 1st, offset by 60)', () => {
expect(validatePersonnummer('199001619809').valid).toBe(true)
})
it('accepts day 91 (the 31st, offset by 60)', () => {
expect(validatePersonnummer('199001919803').valid).toBe(true)
})
it('still enforces the Luhn checksum on the offset form', () => {
// Same samordningsnummer as above with the check digit bumped: the +60
// offset must not become a way to skip the checksum.
const result = validatePersonnummer('199001619808')
expect(result.valid).toBe(false)
expect(result.error).toContain('Luhn')
})
it('rejects days 32-60, which are neither a day nor an offset day', () => {
// Both Luhn-valid, so only the day range can be rejecting them.
expect(validatePersonnummer('199001329805').valid).toBe(false)
expect(validatePersonnummer('199001609800').valid).toBe(false)
})
it('rejects days 92-99, which offset back to day 32-39', () => {
expect(validatePersonnummer('199001929802').valid).toBe(false)
expect(validatePersonnummer('199001999805').valid).toBe(false)
})
})
})
/**
* Read Skatteverket's IDENTITET pattern out of the AGI generator source at test
* time. The const is module-private, so lifting the literal from the file is
* deliberate: a copy pasted into this file would keep passing after the
* generator changed, and that drift is the exact thing this test exists to
* catch. If the const is renamed or reshaped, this throws instead of silently
* testing nothing.
*/
function loadIdentitetPattern(): RegExp {
const generatorPath = fileURLToPath(new URL('../agi/xml-generator.ts', import.meta.url))
const source = readFileSync(generatorPath, 'utf8')
const match = source.match(/\bconst IDENTITET_PATTERN = \/(.+)\/\r?\n/)
if (!match) {
throw new Error(
'Could not read IDENTITET_PATTERN from lib/salary/agi/xml-generator.ts. ' +
'If it moved or was renamed, fix this loader rather than inlining a copy of ' +
'the pattern: reading the generator is the point of this test.',
)
}
return new RegExp(match[1])
}
/**
* Append the Luhn check digit to an 11-digit YYYYMMDDNNN prefix. Used only to
* neutralise the checksum, so that in the sweep below the day rule is the only
* thing that can decide a case. Pinned to the hand-verified constants above so
* it cannot quietly drift into agreeing with a broken validator.
*/
function withCheckDigit(prefix11: string): string {
for (let c = 0; c <= 9; c++) {
const candidate = `${prefix11}${c}`
const luhnDigits = candidate.slice(2)
let sum = 0
for (let i = 0; i < luhnDigits.length; i++) {
let d = parseInt(luhnDigits[i], 10)
if (i % 2 === 0) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
if (sum % 10 === 0) return candidate
}
throw new Error(`No Luhn check digit exists for ${prefix11}`)
}
/**
* The employee validator and the AGI generator have to agree about who counts
* as a person. If the validator is stricter, we refuse to register an employee
* we would happily file an arbetsgivardeklaration for (that was the
* samordningsnummer bug). If it is looser, we accept an employee whose AGI
* Skatteverket will later reject, weeks after payroll ran.
*/
describe('agreement with the AGI generator IDENTITET pattern', () => {
const IDENTITET_PATTERN = loadIdentitetPattern()
it('reads a working pattern out of the generator source', () => {
expect(IDENTITET_PATTERN.test('199001019802')).toBe(true)
expect(IDENTITET_PATTERN.test('199001329805')).toBe(false)
})
it('builds sweep fixtures with a genuinely valid check digit', () => {
expect(withCheckDigit('19900101980')).toBe('199001019802')
expect(withCheckDigit('19900161980')).toBe('199001619809')
})
// Skatteverket's pattern additionally allows day 60: an otherwise ordinary
// identity number whose day of month is unknown (day 00, offset by 60).
// Registering such a person as an employee has never been supported here and
// is out of scope for the samordningsnummer fix, so the divergence is
// asserted rather than hidden: whichever side moves, this fails loudly.
const KNOWN_PATTERN_ONLY_DAYS = [60]
it('accepts exactly the days the generator accepts, for a 31-day month', () => {
const patternOnly: number[] = []
const validatorOnly: number[] = []
for (let day = 0; day <= 99; day++) {
const pnr = withCheckDigit(`199001${String(day).padStart(2, '0')}980`)
const patternAccepts = IDENTITET_PATTERN.test(pnr)
const validatorAccepts = validatePersonnummer(pnr).valid
if (patternAccepts && !validatorAccepts) patternOnly.push(day)
if (validatorAccepts && !patternAccepts) validatorOnly.push(day)
}
// Generator says yes, we say no: an employee we refuse to register but
// would file for. Before the fix this listed every samordningsnummer day,
// 61 through 91.
expect(patternOnly).toEqual(KNOWN_PATTERN_ONLY_DAYS)
// We say yes, generator says no: an employee whose AGI is rejected later.
expect(validatorOnly).toEqual([])
})
it('does not accept the unknown-birth-date forms the generator allows', () => {
const unknownDay = withCheckDigit('19900160980')
const unknownDate = withCheckDigit('19900065980')
expect(IDENTITET_PATTERN.test(unknownDay)).toBe(true)
expect(IDENTITET_PATTERN.test(unknownDate)).toBe(true)
expect(validatePersonnummer(unknownDay).valid).toBe(false)
expect(validatePersonnummer(unknownDate).valid).toBe(false)
})
it('is looser than the generator for short months, both offset and not', () => {
// Pre-existing and unchanged by the samordningsnummer fix: the day check is
// a plain 1-31 range, so it lets April 31 and February 30 through while the
// generator applies real calendar lengths. That is why the sweep above uses
// January. Recorded here so nobody reads the sweep as proving more than it
// does.
const april31 = withCheckDigit('19900431980')
const april91 = withCheckDigit('19900491980')
expect(validatePersonnummer(april31).valid).toBe(true)
expect(validatePersonnummer(april91).valid).toBe(true)
expect(IDENTITET_PATTERN.test(april31)).toBe(false)
expect(IDENTITET_PATTERN.test(april91)).toBe(false)
})
})
describe('extractLast4', () => {
it('extracts last 4 digits', () => {
expect(extractLast4('199001019802')).toBe('9802')
})
it('handles dash-formatted input', () => {
expect(extractLast4('19900101-9802')).toBe('9802')
})
})
describe('extractBirthDate', () => {
it('extracts birth date from 12-digit personnummer', () => {
const result = extractBirthDate('199001019802')
expect(result.year).toBe(1990)
expect(result.month).toBe(1)
expect(result.day).toBe(1)
})
it('normalizes the samordningsnummer day offset to the real calendar day', () => {
// Day 61 = the 1st + 60, day 91 = the 31st + 60. The offset is a numbering
// convention on the printed digits, not a calendar fact: consumers doing
// date math must see 1-31, never 61-91. Same synthetic, Luhn-valid
// samordningsnummer as the validatePersonnummer suite above.
expect(extractBirthDate('199001619809')).toEqual({ year: 1990, month: 1, day: 1 })
expect(extractBirthDate('199001919803')).toEqual({ year: 1990, month: 1, day: 31 })
})
it('leaves ordinary days 1-31 untouched', () => {
expect(extractBirthDate('199006159802').day).toBe(15)
})
})
describe('calculateAge', () => {
it('calculates age at a given date', () => {
expect(calculateAge('199001019802', '2026-04-14')).toBe(36)
})
it('returns age minus one before birthday', () => {
expect(calculateAge('199006159802', '2026-06-14')).toBe(35)
expect(calculateAge('199006159802', '2026-06-15')).toBe(36)
})
it('computes samordningsnummer age from the real calendar day', () => {
// 199001619809 is born 1990-01-01 (printed day 61 = 1 + 60). Comparing the
// reference day against the OFFSET day made `refDay < birth.day` true for
// every day of the birth month, shaving a year off the age until the month
// was over: born-on-the-1st read as 35 on their own 36th birthday.
expect(calculateAge('199001619809', '2026-01-01')).toBe(36)
expect(calculateAge('199001619809', '2025-12-31')).toBe(35)
// End of the offset range too: 1990-01-31 (printed day 91).
expect(calculateAge('199001919803', '2026-01-30')).toBe(35)
expect(calculateAge('199001919803', '2026-01-31')).toBe(36)
})
})
describe('calculateAgeAtYearStart', () => {
// Skatteverket applies "vid årets ingång fyllt X" rules as birth-year
// ranges, so the age is the one attained by December 31 of the prior year.
it('is birth-year based: same birth year means same year-start age', () => {
expect(calculateAgeAtYearStart('199006159802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('199012319802', 2026)).toBe(35)
})
it('does not count a January 1 birthday as attained at årets ingång', () => {
// The 2026 youth cohort is born 2003-2007: born 2003-01-01 must read
// as 22 (eligible) and born 2008-01-01 as 17 (not eligible).
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(35)
expect(calculateAgeAtYearStart('200301011234', 2026)).toBe(22)
expect(calculateAgeAtYearStart('200801011234', 2026)).toBe(17)
})
})
describe('maskPersonnummer', () => {
it('shows birthdate and masks the 4-digit suffix', () => {
expect(maskPersonnummer('199001019802')).toBe('19900101-XXXX')
})
it('strips non-digits before masking', () => {
expect(maskPersonnummer('19900101-9802')).toBe('19900101-XXXX')
})
})
describe('formatPersonnummer', () => {
it('formats with dash', () => {
expect(formatPersonnummer('199001019802')).toBe('19900101-9802')
})
})
describe('encryption roundtrip', () => {
it('encrypts and decrypts correctly', () => {
const pnr = '199001019802'
const encrypted = encryptPersonnummer(pnr)
expect(encrypted).not.toBe(pnr)
expect(encrypted.length).toBeGreaterThan(pnr.length)
const decrypted = decryptPersonnummer(encrypted)
expect(decrypted).toBe(pnr)
})
it('produces different ciphertexts for same input (random IV)', () => {
const pnr = '199001019802'
const a = encryptPersonnummer(pnr)
const b = encryptPersonnummer(pnr)
expect(a).not.toBe(b)
})
})
describe('encryption key configuration guard (#1996)', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('throws a coded error in production when PERSONNUMMER_ENCRYPTION_KEY is unset', () => {
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('PERSONNUMMER_ENCRYPTION_KEY', '')
let thrown: unknown
try {
encryptPersonnummer('199001019802')
} catch (err) {
thrown = err
}
expect(thrown).toBeInstanceOf(Error)
// The code is what withRouteContext -> errorResponse() dispatches on, so
// the route answers 503 with the registry message instead of a generic 500.
expect((thrown as { code?: unknown }).code).toBe(PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED)
expect(PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED).toBe('PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED')
})
it('decrypt is guarded the same way (genuine ciphertext, no key, production)', () => {
const ciphertext = encryptPersonnummer('199001019802')
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('PERSONNUMMER_ENCRYPTION_KEY', '')
expect(() => decryptPersonnummer(ciphertext)).toThrow(
expect.objectContaining({ code: PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED }),
)
})
it('falls back to the deterministic dev key outside production', () => {
vi.stubEnv('NODE_ENV', 'test')
vi.stubEnv('PERSONNUMMER_ENCRYPTION_KEY', '')
const encrypted = encryptPersonnummer('199001019802')
expect(decryptPersonnummer(encrypted)).toBe('199001019802')
})
})
describe('decryptPersonnummer tolerance for unencrypted rows', () => {
it('passes a raw 12-digit personnummer through unchanged (no crash)', () => {
// A row stored unencrypted (pre-fix v1 create, or a seed) would otherwise
// be sliced as iv/ciphertext/tag and throw ERR_CRYPTO_INVALID_AUTH_TAG
// ("Invalid authentication tag length: 6"), 500-ing the whole roster.
expect(decryptPersonnummer('190001010000')).toBe('190001010000')
})
it('still decrypts genuine ciphertext', () => {
const enc = encryptPersonnummer('199001019802')
expect(decryptPersonnummer(enc)).toBe('199001019802')
})
})
describe('expandPersonnummerTo12', () => {
// Fixed clock (2026-08-17) so century inference is deterministic.
const now = new Date(2026, 7, 17)
it('passes a 12-digit value through with the separator stripped', () => {
expect(expandPersonnummerTo12('19900101-9802', now)).toBe('199001019802')
expect(expandPersonnummerTo12('199001019802', now)).toBe('199001019802')
})
it('expands a 10-digit value to the most recent past century', () => {
expect(expandPersonnummerTo12('900101-9802', now)).toBe('199001019802')
expect(expandPersonnummerTo12('9001019802', now)).toBe('199001019802')
})
it('keeps a birth date earlier this century in the 2000s', () => {
expect(expandPersonnummerTo12('250101-0025', now)).toBe('202501010025')
})
it('treats a birth date equal to today as this century', () => {
expect(expandPersonnummerTo12('260817-0000', now)).toBe('202608170000')
})
it('rolls a not-yet-reached date this century back to the 1900s', () => {
expect(expandPersonnummerTo12('261231-0000', now)).toBe('192612310000')
})
it('subtracts a further century for the over-100 plus separator', () => {
expect(expandPersonnummerTo12('900101+9802', now)).toBe('189001019802')
})
it('strips the samordningsnummer day offset for the comparison only', () => {
// Day 77 = real day 17 (today): this century. Day 78 = real day 18
// (tomorrow): last century. The returned digits keep the printed day.
expect(expandPersonnummerTo12('260877-0000', now)).toBe('202608770000')
expect(expandPersonnummerTo12('260878-0000', now)).toBe('192608780000')
})
it('returns null for shapes that are neither 10 nor 12 digits', () => {
expect(expandPersonnummerTo12('', now)).toBeNull()
expect(expandPersonnummerTo12('123', now)).toBeNull()
expect(expandPersonnummerTo12('********-9802', now)).toBeNull()
})
})