Files
accounted/lib/salary/__tests__/franvaro-specnummer.pg.test.ts
T
c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:32 +02:00

203 lines
7.2 KiB
TypeScript

import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import type { PoolClient } from 'pg'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Regression lock for the franvaro-specifikationsnummer trigger under the
* `authenticated` role (feedback 2026-08-13: register_absence 500 for
* foraldraledighet/VAB).
*
* Migration 20260517135000 made the BEFORE INSERT trigger on
* salary_absence_days write an audit row into salary_absence_franvaro_audit,
* a table with RLS enabled and zero policies, from a SECURITY INVOKER
* function. Every vab/parental INSERT from a non-BYPASSRLS role then failed
* with 42501 while 'sick' (which skips the trigger) kept working. Migration
* 20260813120000 makes both trigger functions SECURITY DEFINER with a pinned
* search_path; these tests fail with /row-level security|permission denied/
* without it.
*
* The audit table deliberately has no policies (trigger/service-only
* writes), so audit assertions run on the superuser pool connection after
* RESET ROLE inside the same transaction (withUserContext always rolls back,
* so nothing persists across tests).
*/
async function insertEmployee(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
// personnummer must be 12 digits; last4 mirrors the last four chars.
const pnr = '199001011234'
await getPool().query(
`INSERT INTO public.employees
(id, user_id, company_id, first_name, last_name, personnummer,
personnummer_last4, employment_start, monthly_salary, tax_table_number)
VALUES ($1, $2, $3, 'Test', 'Person', $4, '1234', '2026-01-01', 30000, 32)`,
[id, params.userId, params.companyId, pnr],
)
return id
}
async function insertAbsenceDayAs(
client: PoolClient,
params: {
companyId: string
employeeId: string
date: string
type: string
},
): Promise<{ id: string; specnummer: number | null }> {
const res = await client.query<{ id: string; franvaro_specifikationsnummer: number | null }>(
`INSERT INTO public.salary_absence_days
(company_id, employee_id, absence_date, absence_type, hours)
VALUES ($1, $2, $3, $4, 8)
RETURNING id, franvaro_specifikationsnummer`,
[params.companyId, params.employeeId, params.date, params.type],
)
return {
id: res.rows[0]!.id,
specnummer: res.rows[0]!.franvaro_specifikationsnummer,
}
}
interface AuditRow {
absence_day_id: string
year_month: string
new_specifikationsnummer: number
trigger_op: string
}
/** Read the audit table as superuser (no policies exist by design). */
async function readAudit(client: PoolClient, employeeId: string): Promise<AuditRow[]> {
await client.query('RESET ROLE')
const res = await client.query<AuditRow>(
`SELECT absence_day_id, year_month, new_specifikationsnummer, trigger_op
FROM public.salary_absence_franvaro_audit
WHERE employee_id = $1
ORDER BY assigned_at, new_specifikationsnummer`,
[employeeId],
)
return res.rows
}
describe('franvaro-specnummer.pg: authenticated-role vab/parental inserts', () => {
it('parental INSERT succeeds under role authenticated and mints the shared per-month sequence + audit rows', async () => {
const a = await seedCompany()
const emp = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await withUserContext(a.userId, async (client) => {
const day1 = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-03-02',
type: 'parental',
})
expect(day1.specnummer).toBe(1)
const day2 = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-03-03',
type: 'parental',
})
expect(day2.specnummer).toBe(2)
// vab shares the same per-(employee, year-month) sequence.
const vabDay = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-03-04',
type: 'vab',
})
expect(vabDay.specnummer).toBe(3)
// A different month restarts the sequence.
const aprilDay = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-04-01',
type: 'parental',
})
expect(aprilDay.specnummer).toBe(1)
const audit = await readAudit(client, emp)
expect(audit).toHaveLength(4)
expect(audit.every((r) => r.trigger_op === 'insert')).toBe(true)
const march = audit.filter((r) => r.year_month === '2026-03')
expect(march.map((r) => r.new_specifikationsnummer)).toEqual([1, 2, 3])
expect(march.map((r) => r.absence_day_id)).toEqual([day1.id, day2.id, vabDay.id])
const april = audit.filter((r) => r.year_month === '2026-04')
expect(april.map((r) => r.new_specifikationsnummer)).toEqual([1])
})
})
it('sick days skip the trigger: no specnummer, no audit row', async () => {
const a = await seedCompany()
const emp = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await withUserContext(a.userId, async (client) => {
const sickDay = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-03-02',
type: 'sick',
})
expect(sickDay.specnummer).toBeNull()
const audit = await readAudit(client, emp)
expect(audit).toHaveLength(0)
})
})
it('upsert retry (ON CONFLICT DO UPDATE) is idempotent: no error, specnummer unchanged', async () => {
const a = await seedCompany()
const emp = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await withUserContext(a.userId, async (client) => {
const day1 = await insertAbsenceDayAs(client, {
companyId: a.companyId,
employeeId: emp,
date: '2026-03-02',
type: 'parental',
})
expect(day1.specnummer).toBe(1)
// Mirror the PostgREST upsert lib/salary/absence.ts sends: the payload
// columns land in SET, franvaro_specifikationsnummer is never touched.
const retry = await client.query<{ franvaro_specifikationsnummer: number | null }>(
`INSERT INTO public.salary_absence_days
(company_id, employee_id, absence_date, absence_type, hours)
VALUES ($1, $2, '2026-03-02', 'parental', 4)
ON CONFLICT (employee_id, absence_date, absence_type)
DO UPDATE SET hours = EXCLUDED.hours
RETURNING franvaro_specifikationsnummer`,
[a.companyId, emp],
)
expect(retry.rows[0]!.franvaro_specifikationsnummer).toBe(1)
})
})
it('both trigger functions are SECURITY DEFINER with a pinned search_path', async () => {
const res = await getPool().query<{
proname: string
prosecdef: boolean
proconfig: string[] | null
}>(
`SELECT proname, prosecdef, proconfig
FROM pg_proc
WHERE proname IN (
'assign_franvaro_specifikationsnummer',
'assign_franvaro_specifikationsnummer_on_update'
)`,
)
expect(res.rows).toHaveLength(2)
for (const row of res.rows) {
expect(row.prosecdef).toBe(true)
expect(row.proconfig ?? []).toContain('search_path=public, pg_temp')
}
})
})