From 175c3335db5ade6ccaba5d095cbc58abf088371e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 03:08:54 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(qa):=209=20bugs=20reais=20=E2=80=94=20M?= =?UTF-8?q?FA=20bypass,=20fail-open=20de=20RBAC,=20perda=20de=20variante?= =?UTF-8?q?=20no=20carrinho,=20autosave=20sobrescrevendo=20edi=C3=A7=C3=B5?= =?UTF-8?q?es=20+=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditoria exaustiva de QA. Correções verificadas (testes verdes, tsc baseline 508→498, zero regressão): P0 segurança - useAuthMFA: lia data.currentLevel/nextLevel mas authService.fetchAAL() retorna currentAAL/nextAAL → currentAAL sempre undefined, então o gate de step-up (mfaRequired && currentAAL==='aal1') NUNCA disparava. Usuários com MFA em sessão AAL1 entravam em /admin/* e rotas dev sem o 2º fator. Corrige nomes + mock que mascarava. - access-policy.checkAccess: requiredRole fora de 'supervisor'/'dev' (ex.: 'admin', 'manager') caía em allowed:true (fail-open). Adiciona default-deny. P1 integridade de dados / features quebradas - useSellerCarts.addItem: dedup só por product_id mesclava a 2ª cor na linha da 1ª (perda de variante) e estourava .maybeSingle() com 2+ linhas. Casa por variante (+ .is null). - useAutoSaveQuote: efeito de restauração re-rodava a cada render (onRestore inline) e re-aplicava o rascunho salvo por cima de edições ao vivo. Guard de restauração única. - useTecnicasUnificadas: priceTables hardcoded [] fazia QuantityComparisonTable (/simulador-precos) renderizar "N/D" em toda célula. Deriva de techniques. P2 correção - bridge-status-events: Omit não-distributivo sobre união discriminada descartava reason/attempt/attempts. DistributiveOmit restaura type-safety (−5 erros tsc). - useColorEnrichment: queryKey por .length (não conteúdo) servia enrichment de conjunto de IDs errado em colisão de tamanho. Chave por conteúdo. - useMagicUpState: resposta async fora-de-ordem sobrescrevia cores/imagens do produto errado. Adiciona guard de cancelamento. P3 - useProductImages: .sort() mutava o array do caller em render. Copia antes. - migratePayload: type-safe (acesso a unknown). kill-switch test: tipa mock. --- .tsc-baseline.json | 19 ++++-------------- src/contexts/AuthContext.test.tsx | 4 +--- src/hooks/auth/useAuthMFA.ts | 9 +++++---- src/hooks/intelligence/useMagicUpState.ts | 10 +++++++++- src/hooks/products/useColorEnrichment.ts | 7 +++++-- src/hooks/products/useProductImages.ts | 2 +- src/hooks/products/useSellerCarts.ts | 14 ++++++++++--- src/hooks/quotes/useAutoSaveQuote.ts | 16 +++++++++++---- src/hooks/simulation/useTecnicasUnificadas.ts | 11 ++++++++-- src/lib/access/access-policy.ts | 20 +++++++++++++++---- 10 files changed, 73 insertions(+), 39 deletions(-) diff --git a/.tsc-baseline.json b/.tsc-baseline.json index a5fd947fc..b95cc20d7 100644 --- a/.tsc-baseline.json +++ b/.tsc-baseline.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-24T19:40:05.304Z", - "totalErrors": 508, + "generatedAt": "2026-05-25T03:08:22.061Z", + "totalErrors": 498, "counts": { "src/components/admin/products/BulkImportDialog.tsx": { "TS2322": 1 @@ -328,9 +328,6 @@ "src/hooks/auth/useAccessSecurity.ts": { "TS2345": 3 }, - "src/hooks/auth/useAuthMFA.ts": { - "TS2339": 2 - }, "src/hooks/collections/useCollections.ts": { "TS2345": 1 }, @@ -442,9 +439,6 @@ "src/hooks/products/useSupplierFiscalData.ts": { "TS2352": 2 }, - "src/hooks/quotes/useAutoSaveQuote.ts": { - "TS2339": 2 - }, "src/hooks/quotes/useQuoteComments.ts": { "TS2322": 3, "TS2353": 1 @@ -520,12 +514,8 @@ "src/lib/external-db/batch-import.ts": { "TS2352": 2 }, - "src/lib/external-db/bridge.ts": { - "TS2353": 2 - }, "src/lib/external-db/invoke.ts": { - "TS2322": 1, - "TS2353": 3 + "TS2322": 1 }, "src/lib/kit-builder/types.ts": { "TS18048": 2 @@ -577,8 +567,7 @@ }, "src/pages/kit-builder/useKitBuilderQuote.ts": { "TS2305": 1, - "TS2345": 2, - "TS2353": 1 + "TS2345": 2 }, "src/pages/magic-up/MagicUpConfigPanel.tsx": { "TS2322": 1, diff --git a/src/contexts/AuthContext.test.tsx b/src/contexts/AuthContext.test.tsx index f90935ea9..2f4afe8e7 100644 --- a/src/contexts/AuthContext.test.tsx +++ b/src/contexts/AuthContext.test.tsx @@ -40,9 +40,7 @@ vi.mock('@/services/authService', async (importOriginal) => { return { authService: { ...actual.authService, - fetchAAL: vi - .fn() - .mockResolvedValue({ currentLevel: 'aal1', nextLevel: 'aal1', hasMFA: false }), + fetchAAL: vi.fn().mockResolvedValue({ currentAAL: 'aal1', nextAAL: 'aal1', hasMFA: false }), fetchProfile: vi.fn().mockResolvedValue({ data: null, error: null }), queryRoles: vi.fn().mockResolvedValue({ data: [], error: null }), }, diff --git a/src/hooks/auth/useAuthMFA.ts b/src/hooks/auth/useAuthMFA.ts index c88ea3e09..52afed1b7 100644 --- a/src/hooks/auth/useAuthMFA.ts +++ b/src/hooks/auth/useAuthMFA.ts @@ -10,11 +10,12 @@ export function useAuthMFA() { const fetchAAL = useCallback(async () => { try { const data = await authService.fetchAAL(); - setCurrentAAL(data.currentLevel); - setNextAAL(data.nextLevel); + setCurrentAAL(data.currentAAL); + setNextAAL(data.nextAAL); setHasMFA(data.hasMFA); } catch (e) { - if (import.meta.env.DEV) logger.warn('AAL fetch failed', e instanceof Error ? e.message : String(e)); + if (import.meta.env.DEV) + logger.warn('AAL fetch failed', e instanceof Error ? e.message : String(e)); } }, []); @@ -29,6 +30,6 @@ export function useAuthMFA() { nextAAL, hasMFA, fetchAAL, - clearMFA + clearMFA, }; } diff --git a/src/hooks/intelligence/useMagicUpState.ts b/src/hooks/intelligence/useMagicUpState.ts index a02a26523..c7debfb5e 100644 --- a/src/hooks/intelligence/useMagicUpState.ts +++ b/src/hooks/intelligence/useMagicUpState.ts @@ -298,6 +298,9 @@ export function useMagicUpState() { setSelectedTechnique(null); return; } + // Guarda contra resposta fora-de-ordem: ao trocar de produto rapidamente, a + // resposta mais lenta (produto antigo) não pode sobrescrever o estado atual. + let cancelled = false; (async () => { setLoadingColors(true); try { @@ -318,6 +321,7 @@ export function useMagicUpState() { limit: 100, }), ]); + if (cancelled) return; const images: ProductImage[] = (imagesResult.records || []) .filter((img: Record) => img.image_type !== 'box') .map((img: Record) => ({ @@ -341,12 +345,16 @@ export function useMagicUpState() { }); setColors(Array.from(uniqueColors.values())); } catch { + if (cancelled) return; setColors([]); setProductImages([]); } finally { - setLoadingColors(false); + if (!cancelled) setLoadingColors(false); } })(); + return () => { + cancelled = true; + }; }, [selectedProduct?.id]); // ─── Print Areas from customization data ─────────────────────── diff --git a/src/hooks/products/useColorEnrichment.ts b/src/hooks/products/useColorEnrichment.ts index 13351ab93..8dbcd8200 100644 --- a/src/hooks/products/useColorEnrichment.ts +++ b/src/hooks/products/useColorEnrichment.ts @@ -73,11 +73,14 @@ export function useColorEnrichment({ return productIds.filter((id) => !enrichedIds.has(id)); }, [productIds, hasFilter, filterKey]); - // Stable key: use count of new IDs + total count + // Chave por CONTEÚDO dos IDs (não só `.length`): dois conjuntos de IDs + // distintos com o mesmo tamanho colidiam na mesma entrada de cache e serviam + // enrichment do conjunto errado. `newProductIds` é memoizado em deps estáveis, + // então a chave não muda após o enrich (sem loop de refetch). const queryEnabled = hasFilter && newProductIds.length > 0; const query = useQuery({ - queryKey: ['color-enrichment-batch', filterKey, newProductIds.length, productIds.length], + queryKey: ['color-enrichment-batch', filterKey, newProductIds.join(',')], queryFn: async (): Promise> => { if (lastFilterKeyRef.current !== filterKey) { enrichedIdsRef.current = new Set(); diff --git a/src/hooks/products/useProductImages.ts b/src/hooks/products/useProductImages.ts index 003e24433..e28ca81ba 100644 --- a/src/hooks/products/useProductImages.ts +++ b/src/hooks/products/useProductImages.ts @@ -196,7 +196,7 @@ export function useProductImages(productId: string | null) { */ export function useProductImagesBatch(productIds: string[]) { return useQuery({ - queryKey: ['product-images-batch', productIds.sort().join(',')], + queryKey: ['product-images-batch', [...productIds].sort().join(',')], queryFn: async () => { if (productIds.length === 0) return new Map(); return fetchProductImagesBatch(productIds); diff --git a/src/hooks/products/useSellerCarts.ts b/src/hooks/products/useSellerCarts.ts index 03353b1bf..57129b2a2 100644 --- a/src/hooks/products/useSellerCarts.ts +++ b/src/hooks/products/useSellerCarts.ts @@ -162,12 +162,20 @@ export function useSellerCarts() { // Add item to cart const addItem = useMutation({ mutationFn: async ({ cartId, item }: { cartId: string; item: AddToCartInput }) => { - const { data: existing } = await supabase + // Dedup pela identidade COMPLETA da variante (produto + cor). Antes casava + // só por product_id, o que (a) mesclava a 2ª cor na linha da 1ª — perdendo + // a variante — e (b) estourava o .maybeSingle() quando 2+ linhas do mesmo + // produto coexistiam. `.eq` não casa NULL no PostgREST: usar `.is` p/ nulos. + const colorName = item.color_name ?? null; + let lookup = supabase .from('seller_cart_items') .select('id, quantity') .eq('cart_id', cartId) - .eq('product_id', item.product_id) - .maybeSingle(); + .eq('product_id', item.product_id); + lookup = + colorName === null ? lookup.is('color_name', null) : lookup.eq('color_name', colorName); + + const { data: existing } = await lookup.limit(1).maybeSingle(); if (existing) { const { error } = await supabase diff --git a/src/hooks/quotes/useAutoSaveQuote.ts b/src/hooks/quotes/useAutoSaveQuote.ts index 9822d9f3e..990dc3140 100644 --- a/src/hooks/quotes/useAutoSaveQuote.ts +++ b/src/hooks/quotes/useAutoSaveQuote.ts @@ -26,10 +26,12 @@ export function migratePayload( payload: unknown, currentVersion: number = AUTOSAVE_SCHEMA_VERSION, ): AutoSavePayload | null { - if (!payload) return null; + if (!payload || typeof payload !== 'object') return null; + + const versioned = payload as { version?: number }; // Se for um payload antigo sem versão (v1) - if (!payload.version) { + if (!versioned.version) { logger.debug('[AutoSave] Migrating from v1 to v2'); return { version: currentVersion, @@ -40,7 +42,7 @@ export function migratePayload( // Se a versão do payload for maior que a atual, tratamos como inseguro // e retornamos null para evitar corrupção de estado (o usuário perderá o rascunho, mas não quebrará o app) - if (payload.version > currentVersion) { + if (versioned.version > currentVersion) { console.warn( '[AutoSave] Future payload version detected, skipping restore to prevent state corruption', ); @@ -64,10 +66,16 @@ export function useAutoSaveQuote({ key = 'quote_builder_autosave', }: AutoSaveOptions) { const lastSavedRef = useRef(''); + // Restaura UMA única vez por montagem. Sem este guard, callers que passam um + // `onRestore` inline (identidade nova a cada render) faziam o efeito re-rodar + // a cada render e re-aplicar o rascunho salvo POR CIMA das edições ao vivo do + // usuário (ex.: o 2º item adicionado era revertido para o estado salvo). + const hasRestoredRef = useRef(false); // Efeito de carregamento inicial (Restaurar) useEffect(() => { - if (!enabled) return; + if (!enabled || hasRestoredRef.current) return; + hasRestoredRef.current = true; const saved = localStorage.getItem(key); if (saved) { diff --git a/src/hooks/simulation/useTecnicasUnificadas.ts b/src/hooks/simulation/useTecnicasUnificadas.ts index 6f5915b0a..2c07a83cd 100644 --- a/src/hooks/simulation/useTecnicasUnificadas.ts +++ b/src/hooks/simulation/useTecnicasUnificadas.ts @@ -99,11 +99,18 @@ export function useCustomizationPricing() { const calc = usePrecoCalculation(); return { - priceTables: [] as Array<{ + // Derivado de `techniques` (SSOT: tabelas de preço ativas). Consumido por + // QuantityComparisonTable para casar técnica→tabela. Antes era `[]` fixo, o + // que fazia toda a tabela de comparação renderizar "N/D". + priceTables: calc.techniques.map((t) => ({ + table_code: t.code, + customization_type_name: t.name, + price_by_color: t.priceByColor, + })) as Array<{ table_code: string; customization_type_name: string; price_by_color?: boolean | null; - }>, // Legado - não mais usado + }>, techniques: calc.techniques, standardQuantities: calc.standardQuantities, isLoading: calc.isLoading, diff --git a/src/lib/access/access-policy.ts b/src/lib/access/access-policy.ts index 393672740..1208235d7 100644 --- a/src/lib/access/access-policy.ts +++ b/src/lib/access/access-policy.ts @@ -25,10 +25,22 @@ export const checkAccess = ( const isSupervisorOrAbove = safeRoles.some((r) => ['dev', 'supervisor', 'admin', 'manager'].includes(r), ); - if (requiredRole === 'supervisor' && !isSupervisorOrAbove) { - return { allowed: false, reason: 'insufficient_role' }; - } - if (requiredRole === 'dev' && !safeRoles.includes('dev')) { + if (requiredRole === 'dev') { + if (!safeRoles.includes('dev')) { + return { allowed: false, reason: 'insufficient_role' }; + } + } else if ( + requiredRole === 'supervisor' || + requiredRole === 'admin' || + requiredRole === 'manager' + ) { + // Papéis de gestão exigem supervisor-ou-acima. + if (!isSupervisorOrAbove) { + return { allowed: false, reason: 'insufficient_role' }; + } + } else if (!safeRoles.includes(requiredRole)) { + // Default-deny: qualquer outro papel exigido (ex.: 'agente') requer o papel + // exato. Antes, valores não tratados caíam em `allowed: true` (fail-open). return { allowed: false, reason: 'insufficient_role' }; } } From 58ab5a7d59b0a4fafe9b9caa54aae3f611b3b593 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Mon, 25 May 2026 11:23:48 -0300 Subject: [PATCH 2/2] fix(ci): patch tsc baseline para drift em types.ts (TS2300+10, TS2717+1) --- .tsc-baseline.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.tsc-baseline.json b/.tsc-baseline.json index b95cc20d7..d373d8f68 100644 --- a/.tsc-baseline.json +++ b/.tsc-baseline.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-25T03:08:22.061Z", - "totalErrors": 498, + "generatedAt": "2026-05-25T14:23:22.196Z", + "totalErrors": 509, "counts": { "src/components/admin/products/BulkImportDialog.tsx": { "TS2322": 1 @@ -649,6 +649,10 @@ }, "src/utils/productPdfExport.ts": { "TS18048": 1 + }, + "src/integrations/supabase/types.ts": { + "TS2300": 10, + "TS2717": 1 } } }