Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions __tests__/integration/actions-reset-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
65 changes: 65 additions & 0 deletions __tests__/integration/queries-admin.test.ts
Original file line number Diff line number Diff line change
@@ -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]')
})
})
45 changes: 43 additions & 2 deletions lib/actions/reset-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
Guiroos marked this conversation as resolved.
} 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
Expand Down Expand Up @@ -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')
}
20 changes: 14 additions & 6 deletions lib/queries/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,27 @@ export async function getAllFeedbacks() {

// Group by userId to minimize DEK lookups
const dekMap = new Map<string, Buffer>()
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]'
}
Comment thread
Guiroos marked this conversation as resolved.
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,
Expand Down
Loading