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:
co-authored by
Claude Fable 5
parent
aec81cb7ad
commit
53452e183d
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user