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>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
ebeaeec80e
commit
c4adc8eb7d
@@ -184,6 +184,49 @@ describe('commitPendingOperation: register_absence', () => {
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('logs the PG details and persists a sanitized error_code when the upsert fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
|
||||
enqueue({
|
||||
data: null,
|
||||
error: {
|
||||
code: '42501',
|
||||
message:
|
||||
'new row violates row-level security policy for table "salary_absence_franvaro_audit"',
|
||||
},
|
||||
}) // upsert denied (the franvaro audit-trigger bug)
|
||||
enqueue({ data: null, error: null }) // finalize (failed)
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'register_absence',
|
||||
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-02', absence_type: 'parental' },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(500)
|
||||
expect(result.code).toBe('DB_PERMISSION_DENIED')
|
||||
// The op row keeps the structured code so the failure mode is
|
||||
// diagnosable later, but never the raw PG text.
|
||||
const updates = findCalls('pending_operations', 'update')
|
||||
const finalize = updates[updates.length - 1]![0] as { result_data?: Record<string, unknown> }
|
||||
expect(finalize.result_data).toMatchObject({
|
||||
error_code: 'DB_PERMISSION_DENIED',
|
||||
http_status: 500,
|
||||
})
|
||||
expect(JSON.stringify(finalize.result_data)).not.toContain('row-level security')
|
||||
// The raw PG message goes to the log: it is persisted nowhere else.
|
||||
const logged = consoleError.mock.calls.map((c) => c.join(' ')).join('\n')
|
||||
expect(logged).toContain('register_absence commit failed')
|
||||
expect(logged).toContain('row-level security')
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: book_salary_run', () => {
|
||||
@@ -279,6 +322,34 @@ describe('commitPendingOperation: delete_absence', () => {
|
||||
expect(result.status).not.toBe('committed')
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('logs the PG details and persists a sanitized error_code when the delete fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
|
||||
enqueue({ data: null, error: { code: '57014', message: 'canceling statement due to statement timeout' } }) // delete fails
|
||||
enqueue({ data: null, error: null }) // finalize (failed)
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'delete_absence',
|
||||
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06' },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.code).toBe('INTERNAL_ERROR')
|
||||
const updates = findCalls('pending_operations', 'update')
|
||||
const finalize = updates[updates.length - 1]![0] as { result_data?: Record<string, unknown> }
|
||||
expect(finalize.result_data).toMatchObject({ error_code: 'INTERNAL_ERROR' })
|
||||
const logged = consoleError.mock.calls.map((c) => c.join(' ')).join('\n')
|
||||
expect(logged).toContain('delete_absence commit failed')
|
||||
expect(logged).toContain('statement timeout')
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: create_employee', () => {
|
||||
|
||||
Reference in New Issue
Block a user