* fix(payroll): require jamkning valid_to on every write path (#2058) A jamkningsbeslut saved through the v1 API or MCP with a percentage and a start date but no end date was stored and returned 200, yet the engine (isJamkningValid) never applies a beslut without both dates: the payslip and the AGI carried the table tax while the caller believed the beslut was live. One shared validator (lib/salary/jamkning-rules.ts) now requires both dates whenever a percentage is set and checks their ordering. Every write path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST and PATCH routes, the v1 PATCH route (its private copy is deleted), the MCP create and update executors in employee-commands, and the MCP update tool preflights the merged row at staging time so the agent sees the error before approval. The update paths keep the existing touched gate, so legacy rows stored without valid_to stay editable in unrelated ways. The MCP tool descriptions state that both dates are required for the beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing rows (percentage set, valid_to null) per company, read-only; setting an end date or clearing the beslut is decided per company since either changes the next payslip. Declined: defaulting valid_to to 31 December of the from-year. It matches most beslut but silently changes withholding on rows that today do nothing. Closes #2058 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * fix(payroll): keep the jamkning PR inside the type and tools/list budgets CI on the first push failed on two ratchets this PR itself tripped: - Typecheck ratchet: the three staging tests added here reused the untyped 'agent_chat' actor literal the file already carried, which raised that file's error count above its baseline. They now pass { type: 'user' }. - tools/list payload budget: the first jamkning field descriptions on gnubok_create_employee and gnubok_update_employee pushed the projected catalog to 60 113 tokens against the 60 000 ceiling. The percentage fields keep a one-line "needs both dates or never applied" note; the date fields drop theirs. Also acts on the compliance swarm's GDPR Art.32 note: the read-only lister no longer selects employee names at all (the employee id is what the per-company decision needs), so the script touches no PII. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * docs(mcp): say the jamkning percentage is rejected without both dates CodeRabbit on #2240: "never applied" described the pre-fix engine behaviour; the contract now is that a create or update with a percentage and a missing date is rejected before staging. Same length, so the tools/list payload budget is unchanged. The concurrency finding is tracked in #2256 instead of this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under budget after proforma landed on main After merging main (#2254 proforma fields) the projected tools/list measured 60 010 tokens against the 60 000 ceiling with this PR's two jamkning field notes. Per the budget test's own rule, demote a read tool instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes search-only. Versions exist only once a report is rendered for signing or filing, which is the same switched-off iXBRL path as its sibling gnubok_get_arsredovisning_filing_status, already search-only since 2026-09-02. Still reachable via gnubok_call_tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
/**
|
|
* 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 { afterEach, 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<string, unknown> = {}
|
|
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<string, unknown>) {
|
|
const single = vi.fn(() => Promise.resolve({ data: returned, error: null }))
|
|
const select = vi.fn(() => ({ single }))
|
|
const insert = vi.fn((_payload: Record<string, unknown>) => ({ 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')
|
|
})
|
|
|
|
// #1913: the NewEmployeeDialog sends the jämkning beslut on create; pin that
|
|
// the insert carries it, and that omitting it inserts nulls (no beslut).
|
|
const CREATE_BASE = {
|
|
first_name: 'Test',
|
|
last_name: 'Testsson',
|
|
personnummer: NEW_PNR,
|
|
employment_start: '2026-01-01',
|
|
monthly_salary: 30000,
|
|
tax_table_number: 34,
|
|
tax_municipality: 'Stockholm',
|
|
}
|
|
|
|
function postRequest(body: Record<string, unknown>) {
|
|
return new Request('https://x.test/api/salary/employees', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
it('inserts the jämkning percentage and validity when provided', async () => {
|
|
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) })
|
|
authed(supabase)
|
|
|
|
const res = await POST(
|
|
postRequest({
|
|
...CREATE_BASE,
|
|
jamkning_percentage: 12.5,
|
|
jamkning_valid_from: '2026-01-01',
|
|
jamkning_valid_to: '2026-12-31',
|
|
}),
|
|
params,
|
|
)
|
|
|
|
expect(res.status).toBe(201)
|
|
expect(insert).toHaveBeenCalledTimes(1)
|
|
expect(insert.mock.calls[0][0]).toMatchObject({
|
|
jamkning_percentage: 12.5,
|
|
jamkning_valid_from: '2026-01-01',
|
|
jamkning_valid_to: '2026-12-31',
|
|
})
|
|
})
|
|
|
|
it('returns 400 on a jämkning percentage without an end date, without inserting (#2058)', async () => {
|
|
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) })
|
|
authed(supabase)
|
|
|
|
const res = await POST(
|
|
postRequest({ ...CREATE_BASE, jamkning_percentage: 12.5, jamkning_valid_from: '2026-01-01' }),
|
|
params,
|
|
)
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(insert).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('inserts null jämkning fields when the body omits them', async () => {
|
|
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) })
|
|
authed(supabase)
|
|
|
|
const res = await POST(postRequest(CREATE_BASE), params)
|
|
|
|
expect(res.status).toBe(201)
|
|
expect(insert.mock.calls[0][0]).toMatchObject({
|
|
jamkning_percentage: null,
|
|
jamkning_valid_from: null,
|
|
jamkning_valid_to: null,
|
|
})
|
|
})
|
|
|
|
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 POST(postRequest(CREATE_BASE), params)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 on a personnummer that fails the checksum, without inserting', async () => {
|
|
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new' })
|
|
authed(supabase)
|
|
|
|
const res = await POST(postRequest({ ...CREATE_BASE, personnummer: '199001019803' }), params)
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(insert).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// #1996: the route hand-builds the 409 and generic insert-failure 500 bodies
|
|
// as flat strings (no error.requestId), so the dialog falls back to the
|
|
// X-Request-Id header for its "Ärende-id" line. Pin that the header is there.
|
|
describe('insert failures carry X-Request-Id for support correlation', () => {
|
|
function supabaseWithInsertError(error: { code: string; message: string }) {
|
|
const single = vi.fn(() => Promise.resolve({ data: null, error }))
|
|
const select = vi.fn(() => ({ single }))
|
|
const insert = vi.fn(() => ({ select }))
|
|
return { from: vi.fn(() => ({ insert })) }
|
|
}
|
|
|
|
it('409 duplicate personnummer: flat error body plus X-Request-Id header', async () => {
|
|
authed(supabaseWithInsertError({ code: '23505', message: 'duplicate key value' }))
|
|
|
|
const res = await POST(postRequest(CREATE_BASE), params)
|
|
|
|
expect(res.status).toBe(409)
|
|
const body = await res.json()
|
|
expect(body.error).toBe('En anställd med detta personnummer finns redan')
|
|
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
|
|
})
|
|
|
|
it('500 generic insert error: flat error body plus X-Request-Id header', async () => {
|
|
authed(supabaseWithInsertError({ code: '57014', message: 'canceling statement due to statement timeout' }))
|
|
|
|
const res = await POST(postRequest(CREATE_BASE), params)
|
|
|
|
expect(res.status).toBe(500)
|
|
const body = await res.json()
|
|
expect(typeof body.error).toBe('string')
|
|
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
|
|
})
|
|
})
|
|
|
|
// #1996: the deployment that filed the issue had no PERSONNUMMER_ENCRYPTION_KEY.
|
|
// The encrypt helper used to throw a bare Error, which the wrapper answered
|
|
// with the generic INTERNAL_ERROR 500 ("try again later") even though no
|
|
// retry can ever succeed. It now carries a registry code, so the envelope
|
|
// names the configuration gap, points at support, and keeps the requestId.
|
|
describe('missing PERSONNUMMER_ENCRYPTION_KEY in production', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('answers 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED with a requestId and no insert', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production')
|
|
vi.stubEnv('PERSONNUMMER_ENCRYPTION_KEY', '')
|
|
const { supabase, insert } = supabaseWithInsert({ id: 'emp-new' })
|
|
authed(supabase)
|
|
|
|
const res = await POST(postRequest(CREATE_BASE), params)
|
|
|
|
expect(res.status).toBe(503)
|
|
const body = await res.json()
|
|
expect(body.error.code).toBe('PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED')
|
|
expect(body.error.message).toContain('PERSONNUMMER_ENCRYPTION_KEY')
|
|
expect(body.error.message).toMatch(/Kontakta supporten/)
|
|
expect(body.error.message_en).toContain('PERSONNUMMER_ENCRYPTION_KEY')
|
|
expect(body.error.requestId).toMatch(/^req_/)
|
|
expect(res.headers.get('X-Request-Id')).toBe(body.error.requestId)
|
|
// The failure happens before the database is touched: nothing to clean up.
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
expect(insert).not.toHaveBeenCalled()
|
|
// The personnummer itself must never leak into the error envelope.
|
|
expect(JSON.stringify(body)).not.toContain(NEW_PNR)
|
|
})
|
|
})
|
|
})
|