/** * Regression test for the plaintext-personnummer crash, plus auth wiring. * * GET /api/salary/employees decrypts every employee's personnummer on read and * maps over the whole roster. A row whose personnummer was stored UNENCRYPTED * (a pre-fix v1 REST create, or a seed) used to throw * ERR_CRYPTO_INVALID_AUTH_TAG ("Invalid authentication tag length: 6") inside * the .map(), 500-ing the entire endpoint for the affected company. The decrypt * helper now passes a raw 12-digit value through unchanged, so a mixed * encrypted/plaintext table no longer takes the roster down. * * The route now runs through the withRouteContext wrapper, so we mock its * auth/company/write dependencies and inject the Supabase client via requireAuth. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { NextResponse } from 'next/server' vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), getCompanyEntityType: vi.fn(), })) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) import { GET, POST } from '../route' import { requireAuth } from '@/lib/auth/require-auth' import { encryptPersonnummer } from '@/lib/salary/personnummer' // Synthetic 12-digit values (year 1900 / 1902, zero suffix): obviously not // real birthdates. ISO A.5.34 / GDPR Art.5(1)(c): fixtures must not look like // production PII. const PLAINTEXT_PNR = '190001010000' const ENCRYPTED_PNR = encryptPersonnummer('190203040000') function supabaseWithRows(rows: unknown[]) { const query: Record = {} query.select = vi.fn(() => query) query.eq = vi.fn(() => query) query.order = vi.fn(() => Promise.resolve({ data: rows, error: null })) return { from: vi.fn(() => query) } } function authed(supabase: unknown) { vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'user-1' } as never, supabase: supabase as never, error: null, } as never) } function req() { return new Request('https://x.test/api/salary/employees') } const params = { params: Promise.resolve({}) } as never beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/salary/employees', () => { it('returns 401 when unauthenticated', async () => { vi.mocked(requireAuth).mockResolvedValue({ user: null, supabase: null, error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), } as never) const res = await GET(req(), params) expect(res.status).toBe(401) }) it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => { authed( supabaseWithRows([ { id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR, personnummer_last4: '0000' }, { id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' }, ]), ) const res = await GET(req(), params) expect(res.status).toBe(200) const body = await res.json() // Both rows masked birthdate-visible, last-4 hidden. expect(body.data[0].personnummer_masked).toBe('19000101-XXXX') expect(body.data[1].personnummer_masked).toBe('19020304-XXXX') // Neither the plaintext nor the stored ciphertext may leak. expect(JSON.stringify(body)).not.toContain(PLAINTEXT_PNR) expect(JSON.stringify(body)).not.toContain(ENCRYPTED_PNR) }) it('never returns the mask under the writable `personnummer` key', async () => { // The roster feeds edit forms. If the mask came back as `personnummer`, a // client that reads a row and writes it back would post 'ÅÅÅÅMMDD-XXXX' // into the encrypt path. The masked value lives under `personnummer_masked` // so the read key and the write key can never be confused. authed(supabaseWithRows([{ id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR }])) const res = await GET(req(), params) const body = await res.json() expect(body.data[0].personnummer).toBeUndefined() expect('personnummer' in body.data[0]).toBe(false) expect(body.data[0].personnummer_masked).toBe('19020304-XXXX') }) it('strips personnummer_last4 so the mask cannot be reassembled', async () => { // The mask is YYYYMMDD-XXXX. A response carrying the mask AND the last // four digits hands the client the full personnummer by concatenation, so // personnummer_last4 must never ride along with the roster rows. authed( supabaseWithRows([ { id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' }, ]), ) const res = await GET(req(), params) const body = await res.json() expect(body.data[0]).not.toHaveProperty('personnummer_last4') expect(body.data[0]).not.toHaveProperty('personnummer') expect(body.data[0].personnummer_masked).toBe('19020304-XXXX') }) }) describe('POST /api/salary/employees', () => { // Luhn-valid synthetic personnummer (checksum verified in personnummer.test.ts). const NEW_PNR = '199001019802' function supabaseWithInsert(returned: Record) { const single = vi.fn(() => Promise.resolve({ data: returned, error: null })) const select = vi.fn(() => ({ single })) const insert = vi.fn(() => ({ select })) return { supabase: { from: vi.fn(() => ({ insert })) }, insert } } it('create response carries the mask only: no ciphertext, no last4', async () => { const inserted = { id: 'emp-new', company_id: 'company-1', first_name: 'Test', last_name: 'Testsson', personnummer: encryptPersonnummer(NEW_PNR), personnummer_last4: '9802', employment_type: 'employee', } const { supabase } = supabaseWithInsert(inserted) authed(supabase) const res = await POST( new Request('https://x.test/api/salary/employees', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name: 'Test', last_name: 'Testsson', personnummer: NEW_PNR, employment_start: '2026-01-01', monthly_salary: 30000, tax_table_number: 34, tax_municipality: 'Stockholm', }), }), params, ) expect(res.status).toBe(201) const body = await res.json() expect(body.data.personnummer_masked).toBe('19900101-XXXX') expect(body.data).not.toHaveProperty('personnummer') expect(body.data).not.toHaveProperty('personnummer_last4') // Neither the full personnummer nor its suffix may appear anywhere in the // serialized response: mask + last4 would reassemble the identity. expect(JSON.stringify(body)).not.toContain(NEW_PNR) expect(JSON.stringify(body)).not.toContain('9802') }) })