diff --git a/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts b/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts deleted file mode 100644 index ebbd85fb..00000000 --- a/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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) - }) -}) diff --git a/app/api/maintenance/backfill-personnummer/route.ts b/app/api/maintenance/backfill-personnummer/route.ts deleted file mode 100644 index 45e4a8cb..00000000 --- a/app/api/maintenance/backfill-personnummer/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 = { 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, - }) -}