From 4cac97a3ca7542132dd582c7cdaa0600a77c579e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 13:09:15 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20resetAccount=20recifra=20feedback=20?= =?UTF-8?q?com=20a=20DEK=20nova=20em=20vez=20de=20descart=C3=A1-la=20(clos?= =?UTF-8?q?es=20#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feedback sobrevive à Fase 1 do reset por ser dado de produto, mas a Fase 2 provisiona uma DEK aleatória nova — as mensagens continuavam cifradas com a chave antiga, destruída, e getAllFeedbacks derrubava o /admin inteiro no primeiro Promise.all que topasse com uma delas. resetAccount agora lê a DEK antiga direto de userSettings (nunca via getDekForUser, que memoiza por request e devolveria a DEK já apagada na Fase 2) e recifra o feedback pendente com a DEK nova ao final. getAllFeedbacks passa a degradar por linha em vez de derrubar a página inteira, para as linhas já órfãs em produção. --- .../integration/actions-reset-account.test.ts | 22 ++++++++++++ lib/actions/reset-account.ts | 35 +++++++++++++++++-- lib/queries/admin.ts | 11 ++++-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/actions-reset-account.test.ts b/__tests__/integration/actions-reset-account.test.ts index 9983de7..aaaa120 100644 --- a/__tests__/integration/actions-reset-account.test.ts +++ b/__tests__/integration/actions-reset-account.test.ts @@ -191,4 +191,26 @@ 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') + }) }) diff --git a/lib/actions/reset-account.ts b/lib/actions/reset-account.ts index 00550b9..ee13b6b 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,23 @@ 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)) + const dekAntiga = settings?.encryptedDek ? decryptDek(settings.encryptedDek) : null + + // 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 +137,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..4033419 100644 --- a/lib/queries/admin.ts +++ b/lib/queries/admin.ts @@ -46,19 +46,24 @@ export async function getAllFeedbacks() { // Group by userId to minimize DEK lookups const dekMap = new Map() - const getDecryptedMessage = async (userId: string, message: string) => { + const getDecryptedMessage = async (userId: string, feedbackId: string, message: string) => { if (!dekMap.has(userId)) { dekMap.set(userId, await getDekForUser(userId)) } const dek = dekMap.get(userId)! - return decryptField(message, dek) + try { + return decryptField(message, dek) + } catch (err) { + console.error('[getAllFeedbacks] mensagem ilegível', { feedbackId, err }) + return '[mensagem ilegível — chave rotacionada]' + } } 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, From 1b814cee35a748b550bf9558326f0c626614da19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:17:32 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20cobre=20getAllFeedbacks=20e=20blinda?= =?UTF-8?q?=20decryptDek=20contra=20DEK=20antiga=20ileg=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endereça a revisão de #136: - lib/actions/reset-account.ts: decryptDek pode lançar (DEK cifrada com MEK que não é mais a do ambiente); antes rodava sem guard e um throw ali abortaria o reset inteiro antes mesmo da Fase 1 — pior que o bug que este PR corrige. Agora falha para null e loga, feedback simplesmente não é recifrado nesse caso. - __tests__/integration/queries-admin.test.ts (novo): cobre o try/catch de getAllFeedbacks, que não tinha teste nenhum — insere uma linha cifrada com uma DEK estrangeira e assere o sentinela de fallback em vez de deixar o Promise.all rejeitar a lista inteira. --- __tests__/integration/queries-admin.test.ts | 41 +++++++++++++++++++++ lib/actions/reset-account.ts | 12 +++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 __tests__/integration/queries-admin.test.ts diff --git a/__tests__/integration/queries-admin.test.ts b/__tests__/integration/queries-admin.test.ts new file mode 100644 index 0000000..452dd88 --- /dev/null +++ b/__tests__/integration/queries-admin.test.ts @@ -0,0 +1,41 @@ +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 — chave rotacionada]' + ) + }) +}) diff --git a/lib/actions/reset-account.ts b/lib/actions/reset-account.ts index ee13b6b..c49aeb4 100644 --- a/lib/actions/reset-account.ts +++ b/lib/actions/reset-account.ts @@ -73,7 +73,17 @@ export async function resetAccount() { .select({ encryptedDek: userSettings.encryptedDek }) .from(userSettings) .where(eq(userSettings.userId, userId)) - const dekAntiga = settings?.encryptedDek ? decryptDek(settings.encryptedDek) : null + // 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 From 31f3019fce34e13604d0769ec9ae7ffe557f5de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:19:31 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20fecha=20os=20dois=20nits=20da=202?= =?UTF-8?q?=C2=AA=20rodada=20de=20revis=C3=A3o=20de=20#136?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endereça a revisão de 1b814ce: - lib/queries/admin.ts: getDekForUser estava fora do try/catch de getDecryptedMessage — uma DEK ilegível (MEK do ambiente diferente da que cifrou aquela DEK) ainda derrubava o Promise.all inteiro, o mesmo modo de falha que este catch existe para evitar. Movida a busca para dentro do try já existente. - __tests__/integration/queries-admin.test.ts: novo caso cobrindo DEK ilegível (não só mensagem órfã) — cria um usuário com encryptedDek corrompido e confere que getAllFeedbacks degrada por linha em vez de rejeitar tudo. - __tests__/integration/actions-reset-account.test.ts: novo caso cobrindo o guard de decryptDek em resetAccount — corrompe o encryptedDek do usuário e confere que o reset completa (17 categorias recriadas) em vez de abortar sem apagar nem provisionar nada. Precisa ser o último it() do describe por corromper o encryptedDek do userId compartilhado pelo arquivo. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018NiDhufvRwKxUxbqNF4k3y --- .../integration/actions-reset-account.test.ts | 20 +++++++++++++ __tests__/integration/queries-admin.test.ts | 28 +++++++++++++++++++ lib/queries/admin.ts | 9 +++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/actions-reset-account.test.ts b/__tests__/integration/actions-reset-account.test.ts index aaaa120..29b76a3 100644 --- a/__tests__/integration/actions-reset-account.test.ts +++ b/__tests__/integration/actions-reset-account.test.ts @@ -213,4 +213,24 @@ describe('resetAccount', () => { expect(row).toBeDefined() expect(decryptField(row!.message, dekNova)).toBe('Adoraria um app nativo') }) + + // Precisa ser o último it() do describe: corrompe o encryptedDek do userId compartilhado + // pelo arquivo, o que faria os testes seguintes verem dekAntiga = null. + it('completa o reset mesmo com a DEK antiga ilegível', async () => { + const { randomBytes } = await import('crypto') + const { resetAccount } = await import('@/lib/actions/reset-account') + + await db + .update(schema.userSettings) + .set({ encryptedDek: 'enc:' + randomBytes(60).toString('base64') }) + .where(eq(schema.userSettings.userId, userId)) + + await expect(resetAccount()).resolves.toBeUndefined() + + const cats = await db + .select() + .from(schema.categories) + .where(eq(schema.categories.userId, userId)) + expect(cats).toHaveLength(17) + }) }) diff --git a/__tests__/integration/queries-admin.test.ts b/__tests__/integration/queries-admin.test.ts index 452dd88..d29391a 100644 --- a/__tests__/integration/queries-admin.test.ts +++ b/__tests__/integration/queries-admin.test.ts @@ -38,4 +38,32 @@ describe('getAllFeedbacks', () => { '[mensagem ilegível — chave rotacionada]' ) }) + + 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 — chave rotacionada]' + ) + }) }) diff --git a/lib/queries/admin.ts b/lib/queries/admin.ts index 4033419..21f3150 100644 --- a/lib/queries/admin.ts +++ b/lib/queries/admin.ts @@ -47,12 +47,11 @@ export async function getAllFeedbacks() { // Group by userId to minimize DEK lookups const dekMap = new Map() const getDecryptedMessage = async (userId: string, feedbackId: string, message: string) => { - if (!dekMap.has(userId)) { - dekMap.set(userId, await getDekForUser(userId)) - } - const dek = dekMap.get(userId)! try { - return decryptField(message, dek) + if (!dekMap.has(userId)) { + dekMap.set(userId, await getDekForUser(userId)) + } + return decryptField(message, dekMap.get(userId)!) } catch (err) { console.error('[getAllFeedbacks] mensagem ilegível', { feedbackId, err }) return '[mensagem ilegível — chave rotacionada]' From 2fed7d242c54f4b04c5eebea3daade1b60a80173 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:25:54 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20sentinela=20de=20getAllFeedbacks=20n?= =?UTF-8?q?=C3=A3o=20afirma=20causa=20que=20n=C3=A3o=20conhece;=20teste=20?= =?UTF-8?q?isolado=20por=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endereça a revisão de 31f3019: - lib/queries/admin.ts: mover getDekForUser para dentro do try (na rodada anterior) alargou o catch para cobrir também MEK ausente/ malformada e falha de upsert — nenhuma delas é "chave rotacionada". Sentinela trocado para '[mensagem ilegível]', sem afirmar causa que o catch não sabe distinguir (categoria 4 do .claude/audit.md). - __tests__/integration/queries-admin.test.ts: assertions atualizadas para o novo texto. - __tests__/integration/actions-reset-account.test.ts: o último it() passou a usar um usuário próprio em vez de corromper o encryptedDek do userId compartilhado pelo arquivo — o comentário anterior justificava a ordem com um dekAntiga=null que resetAccount() nunca produz (a Fase 2 sempre reprovisiona DEK válida); o resíduo real era a linha de feedback do it() anterior ficando órfã. Isolamento por ID remove a dependência de ordem. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018NiDhufvRwKxUxbqNF4k3y --- .../integration/actions-reset-account.test.ts | 21 ++++++++++++------- __tests__/integration/queries-admin.test.ts | 8 ++----- lib/queries/admin.ts | 6 +++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/__tests__/integration/actions-reset-account.test.ts b/__tests__/integration/actions-reset-account.test.ts index 29b76a3..7630832 100644 --- a/__tests__/integration/actions-reset-account.test.ts +++ b/__tests__/integration/actions-reset-account.test.ts @@ -214,23 +214,30 @@ describe('resetAccount', () => { expect(decryptField(row!.message, dekNova)).toBe('Adoraria um app nativo') }) - // Precisa ser o último it() do describe: corrompe o encryptedDek do userId compartilhado - // pelo arquivo, o que faria os testes seguintes verem dekAntiga = null. 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') - await db - .update(schema.userSettings) - .set({ encryptedDek: 'enc:' + randomBytes(60).toString('base64') }) - .where(eq(schema.userSettings.userId, userId)) + // 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, userId)) + .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 index d29391a..aa364a7 100644 --- a/__tests__/integration/queries-admin.test.ts +++ b/__tests__/integration/queries-admin.test.ts @@ -34,9 +34,7 @@ describe('getAllFeedbacks', () => { const todos = await getAllFeedbacks() - expect(todos.find((f) => f.id === orfa!.id)?.message).toBe( - '[mensagem ilegível — chave rotacionada]' - ) + 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 () => { @@ -62,8 +60,6 @@ describe('getAllFeedbacks', () => { const todos = await getAllFeedbacks() - expect(todos.find((f) => f.id === linha!.id)?.message).toBe( - '[mensagem ilegível — chave rotacionada]' - ) + expect(todos.find((f) => f.id === linha!.id)?.message).toBe('[mensagem ilegível]') }) }) diff --git a/lib/queries/admin.ts b/lib/queries/admin.ts index 21f3150..ffa3066 100644 --- a/lib/queries/admin.ts +++ b/lib/queries/admin.ts @@ -53,8 +53,12 @@ export async function getAllFeedbacks() { } 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 — chave rotacionada]' + return '[mensagem ilegível]' } }