diff --git a/__tests__/integration/actions-reset-account.test.ts b/__tests__/integration/actions-reset-account.test.ts index 9983de7..7630832 100644 --- a/__tests__/integration/actions-reset-account.test.ts +++ b/__tests__/integration/actions-reset-account.test.ts @@ -191,4 +191,53 @@ describe('resetAccount', () => { expect(cats).toHaveLength(17) }) + + it('mantém o feedback legível depois do reset', async () => { + const { getDekForUser } = await import('@/lib/crypto/keys') + const { encryptField, decryptField } = await import('@/lib/crypto/fields') + const { resetAccount } = await import('@/lib/actions/reset-account') + + const dekAntiga = await getDekForUser(userId) + await db.insert(schema.feedback).values({ + userId, + category: 'melhoria', + page: '/dashboard', + message: encryptField('Adoraria um app nativo', dekAntiga), + }) + + await resetAccount() + + const [row] = await db.select().from(schema.feedback).where(eq(schema.feedback.userId, userId)) + const dekNova = await getDekForUser(userId) + + expect(row).toBeDefined() + expect(decryptField(row!.message, dekNova)).toBe('Adoraria um app nativo') + }) + + it('completa o reset mesmo com a DEK antiga ilegível', async () => { + const { randomBytes } = await import('crypto') + const { requireUserId } = await import('@/lib/auth/require-user') + const { resetAccount } = await import('@/lib/actions/reset-account') + + // Usuário próprio, não o userId compartilhado pelo arquivo: resetAccount() provisiona + // uma DEK nova e válida na Fase 2, então corromper o encryptedDek do userId + // compartilhado não deixaria dekAntiga = null para os testes seguintes — deixaria a + // linha de feedback do it() anterior órfã (cifrada com a DEK que este teste destruiu). + const { id: outroId } = await createUser(db, `reset-dek-ilegivel-${Date.now()}`) + await db.insert(schema.userSettings).values({ + userId: outroId, + encryptedDek: 'enc:' + randomBytes(60).toString('base64'), + creditMode: 'accrual', + faturaActiveFrom: null, + }) + + vi.mocked(requireUserId).mockResolvedValueOnce(outroId) + await expect(resetAccount()).resolves.toBeUndefined() + + const cats = await db + .select() + .from(schema.categories) + .where(eq(schema.categories.userId, outroId)) + expect(cats).toHaveLength(17) + }) }) diff --git a/__tests__/integration/queries-admin.test.ts b/__tests__/integration/queries-admin.test.ts new file mode 100644 index 0000000..aa364a7 --- /dev/null +++ b/__tests__/integration/queries-admin.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { randomBytes } from 'crypto' +import * as schema from '@/lib/db/schema' +import { neonTestingSetup } from './setup' +import { createTestDb, type TestDb } from './helpers/db' +import { createUser } from './helpers/factories' + +neonTestingSetup() + +let db: TestDb +let userId: string + +beforeAll(async () => { + db = createTestDb() + ;({ id: userId } = await createUser(db, `admin-feedback-${Date.now()}`)) +}) + +describe('getAllFeedbacks', () => { + it('não derruba a lista quando uma linha está órfã (cifrada com outra DEK)', async () => { + const { encryptField } = await import('@/lib/crypto/fields') + const { getAllFeedbacks } = await import('@/lib/queries/admin') + + // Simula uma linha cifrada com uma DEK que não é a do usuário — mesmo estado de uma + // linha órfã de um reset de conta cuja DEK antiga foi destruída. + const [orfa] = await db + .insert(schema.feedback) + .values({ + userId, + category: 'outros', + page: '/dashboard', + message: encryptField('cifrado com DEK destruída', randomBytes(32)), + }) + .returning({ id: schema.feedback.id }) + + const todos = await getAllFeedbacks() + + expect(todos.find((f) => f.id === orfa!.id)?.message).toBe('[mensagem ilegível]') + }) + + it('não derruba a lista quando a DEK do usuário está ilegível', async () => { + const { getAllFeedbacks } = await import('@/lib/queries/admin') + + const [outro] = await db + .insert(schema.users) + .values({ email: `dek-ilegivel-${Date.now()}@t.com` }) + .returning({ id: schema.users.id }) + + // 'enc:' + bytes aleatórios: passa o guard de prefixo e falha no auth tag do GCM, + // que é o estado de uma DEK cifrada com uma MEK que não é mais a do ambiente. + await db.insert(schema.userSettings).values({ + userId: outro!.id, + encryptedDek: 'enc:' + randomBytes(60).toString('base64'), + creditMode: 'accrual', + faturaActiveFrom: null, + }) + const [linha] = await db + .insert(schema.feedback) + .values({ userId: outro!.id, category: 'outros', page: '/x', message: 'qualquer' }) + .returning({ id: schema.feedback.id }) + + const todos = await getAllFeedbacks() + + expect(todos.find((f) => f.id === linha!.id)?.message).toBe('[mensagem ilegível]') + }) +}) diff --git a/lib/actions/reset-account.ts b/lib/actions/reset-account.ts index 00550b9..c49aeb4 100644 --- a/lib/actions/reset-account.ts +++ b/lib/actions/reset-account.ts @@ -20,10 +20,11 @@ import { goalContributions, people, debtorEntries, + feedback, } from '@/lib/db/schema' import { requireUserId } from '@/lib/auth/require-user' -import { getDekForUser } from '@/lib/crypto/keys' -import { encryptField, encryptOptional } from '@/lib/crypto/fields' +import { getDekForUser, decryptDek } from '@/lib/crypto/keys' +import { encryptField, encryptOptional, decryptField } from '@/lib/crypto/fields' type GroupSeed = { name: string @@ -66,6 +67,33 @@ const DEFAULT_GROUPS: GroupSeed[] = [ export async function resetAccount() { const userId = await requireUserId() + // DEK antiga: leitura direta de userSettings, nunca via getDekForUser — o cache() por + // request faria a Fase 2 devolver essa mesma DEK (já deletada) em vez da nova. + const [settings] = await db + .select({ encryptedDek: userSettings.encryptedDek }) + .from(userSettings) + .where(eq(userSettings.userId, userId)) + // decryptDek pode lançar (DEK cifrada com uma MEK que não é mais a do ambiente) — um + // throw aqui, antes da Fase 1, abortaria o reset inteiro sem apagar nem provisionar nada, + // pior do que o estado que esta issue corrige. Sem DEK antiga, feedback não é recifrado. + let dekAntiga: Buffer | null = null + if (settings?.encryptedDek) { + try { + dekAntiga = decryptDek(settings.encryptedDek) + } catch (err) { + console.error('[resetAccount] DEK antiga ilegível — feedback não será recifrado', { err }) + } + } + + // feedback não é apagado na Fase 1 (é dado de produto, não financeiro) — capturar antes + // do delete para recifrar com a DEK nova depois, senão fica ilegível para sempre + const pendingFeedback = dekAntiga + ? await db + .select({ id: feedback.id, message: feedback.message }) + .from(feedback) + .where(eq(feedback.userId, userId)) + : [] + // Phase 1: Delete everything in a transaction (including userSettings/encryptedDek) await db.transaction(async (tx) => { // Devedores @@ -119,5 +147,18 @@ export async function resetAccount() { ) } + // Phase 4: recifrar o feedback pendente com a DEK nova — a rotação de chave existe para + // cripto-apagar os dados financeiros, não para tornar o feedback do usuário ilegível + for (const f of pendingFeedback) { + try { + await db + .update(feedback) + .set({ message: encryptField(decryptField(f.message, dekAntiga!), dek) }) + .where(eq(feedback.id, f.id)) + } catch (err) { + console.error('[resetAccount] falha ao recifrar feedback', { feedbackId: f.id, err }) + } + } + revalidatePath('/', 'layout') } diff --git a/lib/queries/admin.ts b/lib/queries/admin.ts index 58212c6..ffa3066 100644 --- a/lib/queries/admin.ts +++ b/lib/queries/admin.ts @@ -46,19 +46,27 @@ export async function getAllFeedbacks() { // Group by userId to minimize DEK lookups const dekMap = new Map() - const getDecryptedMessage = async (userId: string, message: string) => { - if (!dekMap.has(userId)) { - dekMap.set(userId, await getDekForUser(userId)) + const getDecryptedMessage = async (userId: string, feedbackId: string, message: string) => { + try { + if (!dekMap.has(userId)) { + dekMap.set(userId, await getDekForUser(userId)) + } + return decryptField(message, dekMap.get(userId)!) + } catch (err) { + // getDekForUser também pode lançar aqui (MEK ausente/malformada, falha no upsert) — + // esse catch não sabe distinguir DEK rotacionada de ambiente quebrado, então o texto + // não pode afirmar uma causa específica; a causa real fica só no log (categoria 4 do + // .claude/audit.md — "catch que afirma causa que o código não conhece") + console.error('[getAllFeedbacks] mensagem ilegível', { feedbackId, err }) + return '[mensagem ilegível]' } - const dek = dekMap.get(userId)! - return decryptField(message, dek) } const decrypted = await Promise.all( rows.map(async (row) => ({ id: row.id, category: row.category, - message: await getDecryptedMessage(row.userId, row.message), + message: await getDecryptedMessage(row.userId, row.id, row.message), page: row.page, status: row.status, createdAt: row.createdAt,