feat(ops): personnummer backfill route (temporary) + FX repair script (#981)

* feat(ops): temporary cron-gated route to backfill plaintext personnummer in prod

PERSONNUMMER_ENCRYPTION_KEY is a sensitive Vercel env var and cannot be
read outside the runtime, so scripts/backfill-encrypt-personnummer.ts
cannot run locally with the production key. This route performs the same
guarded, idempotent backfill inside the production runtime instead.
CRON_SECRET-gated, dry-run by default, counts-only response.

To be deleted after the backfill is verified (issue #979).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ops): commit the FX fallback-rate repair script for the audit trail

One-off repair for transactions booked with pre-#892 hardcoded fallback
rates; unbooked rows only, rate-guarded and idempotent. Already executed
against prod 2026-07-10 (issue #979).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger CI after preview env fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-10 14:07:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent aec81cb7ad
commit 53452e183d
3 changed files with 329 additions and 0 deletions
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockVerifyCronSecret = vi.fn()
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: (req: Request) => mockVerifyCronSecret(req),
}))
const mockUpdateEq2 = vi.fn()
const mockUpdateEq1 = vi.fn(() => ({ eq: mockUpdateEq2 }))
const mockUpdate = vi.fn(() => ({ eq: mockUpdateEq1 }))
const mockSelect = vi.fn()
vi.mock('@supabase/supabase-js', () => ({
createClient: () => ({
from: () => ({ select: mockSelect, update: mockUpdate }),
}),
}))
import { POST } from '../route'
const ENCRYPTED =
'a1b2c3d4e5f60718293a4b5c' + 'deadbeefdeadbeefdeadbeef' + 'cafebabecafebabecafebabecafebabe'
function makeRequest(confirm: boolean) {
const url = `http://localhost/api/maintenance/backfill-personnummer${confirm ? '?confirm=true' : ''}`
return new Request(url, { method: 'POST' })
}
describe('POST /api/maintenance/backfill-personnummer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockVerifyCronSecret.mockReturnValue(null)
mockUpdateEq2.mockResolvedValue({ error: null })
mockSelect.mockResolvedValue({
data: [
{ id: 'emp-1', personnummer: '199001011234', personnummer_last4: null },
{ id: 'emp-2', personnummer: ENCRYPTED, personnummer_last4: '5678' },
],
error: null,
})
})
it('returns 401 when the cron secret is missing or wrong', async () => {
mockVerifyCronSecret.mockReturnValue(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const res = await POST(makeRequest(true))
expect(res.status).toBe(401)
expect(mockSelect).not.toHaveBeenCalled()
})
it('dry run counts plaintext rows without writing', async () => {
const res = await POST(makeRequest(false))
const body = await res.json()
expect(res.status).toBe(200)
expect(body).toEqual({ mode: 'dry_run', scanned: 2, plaintext: 1, updated: 0, failed: 0 })
expect(mockUpdate).not.toHaveBeenCalled()
})
it('confirm encrypts only plaintext rows, guarded on the old value', async () => {
const res = await POST(makeRequest(true))
const body = await res.json()
expect(res.status).toBe(200)
expect(body).toEqual({ mode: 'write', scanned: 2, plaintext: 1, updated: 1, failed: 0 })
expect(mockUpdate).toHaveBeenCalledTimes(1)
const patch = mockUpdate.mock.calls[0][0] as { personnummer: string; personnummer_last4: string }
// aes-256-gcm output: 12-byte iv + ciphertext + 16-byte tag, hex-encoded
expect(patch.personnummer).toMatch(/^[0-9a-f]+$/)
expect(patch.personnummer).not.toMatch(/^\d{12}$/)
expect(patch.personnummer.length).toBeGreaterThan(60)
expect(patch.personnummer_last4).toBe('1234')
expect(mockUpdateEq1).toHaveBeenCalledWith('id', 'emp-1')
expect(mockUpdateEq2).toHaveBeenCalledWith('personnummer', '199001011234')
})
it('returns 500 when the employees read fails', async () => {
mockSelect.mockResolvedValue({ data: null, error: { message: 'boom' } })
const res = await POST(makeRequest(false))
expect(res.status).toBe(500)
})
})
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { verifyCronSecret } from '@/lib/auth/cron'
import { encryptPersonnummer, extractLast4 } from '@/lib/salary/personnummer'
export const dynamic = 'force-dynamic'
const PLAINTEXT = /^\d{12}$/
/**
* TEMPORARY maintenance endpoint: re-encrypt employees.personnummer rows that
* were stored as plaintext by the v1 REST create route before the #911 fix.
*
* This exists because PERSONNUMMER_ENCRYPTION_KEY is a sensitive Vercel env
* var (unreadable outside the runtime), so the committed local backfill
* script (scripts/backfill-encrypt-personnummer.ts) cannot obtain the
* production key. This route runs the same guarded, idempotent backfill
* inside the production runtime, where the key already lives.
*
* Gated by CRON_SECRET. Dry-run by default; writes only with ?confirm=true.
* The response carries counts only, never personnummer values.
*
* DELETE this route once the backfill is verified (tracked in issue #979).
*/
export async function POST(request: Request) {
const unauthorized = verifyCronSecret(request)
if (unauthorized) return unauthorized
const confirm = new URL(request.url).searchParams.get('confirm') === 'true'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
const { data, error } = await supabase
.from('employees')
.select('id, personnummer, personnummer_last4')
if (error) {
return NextResponse.json({ error: 'Failed to read employees' }, { status: 500 })
}
const rows = data ?? []
const plaintextRows = rows.filter((r) => PLAINTEXT.test(String(r.personnummer ?? '')))
let updated = 0
let failed = 0
if (confirm) {
for (const row of plaintextRows) {
const plaintext = String(row.personnummer)
const patch: Record<string, string> = { personnummer: encryptPersonnummer(plaintext) }
const last4 = extractLast4(plaintext)
if (row.personnummer_last4 !== last4) patch.personnummer_last4 = last4
// Guard on the still-plaintext value: idempotent and safe against a
// concurrent write; can never double-encrypt.
const { error: updateError } = await supabase
.from('employees')
.update(patch)
.eq('id', row.id)
.eq('personnummer', plaintext)
if (updateError) failed++
else updated++
}
}
return NextResponse.json({
mode: confirm ? 'write' : 'dry_run',
scanned: rows.length,
plaintext: plaintextRows.length,
updated,
failed,
})
}