diff --git a/src/lib/external-db/__tests__/rest-native-write.test.ts b/src/lib/external-db/__tests__/rest-native-write.test.ts new file mode 100644 index 000000000..3b6509f92 --- /dev/null +++ b/src/lib/external-db/__tests__/rest-native-write.test.ts @@ -0,0 +1,161 @@ +/** + * Testes do caminho de ESCRITA REST nativo (Plano A / PR#2). + * + * Cobre as 6 guardas: + * A1 sessão autenticada → delegada ao RLS (não testável aqui; erro vira LOUD). + * A2 update/delete SEM filtro/id → proibido (proteção contra mutação em massa). + * A3 escrita sempre na tabela BASE — nunca na view v_*_public; aliases de rename. + * A4 `.select()` de volta; insert OK com select-back vazio (RLS de SELECT) ainda é sucesso. + * A5 remap EN→PT no payload (tecnicas_gravacao). + * + elegibilidade (whitelist) e propagação LOUD de erro. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isRestNativeWriteEligible, + executeRestNativeWrite, + tryExecuteRestNativeWrite, +} from '../rest-native'; +import type { InvokeOptions } from '../bridge'; + +// ── Mock chainable do supabase ──────────────────────────────────── +interface Capture { + table: string | null; + op: 'insert' | 'update' | 'delete' | 'upsert' | null; + payload: unknown; + eqCalls: Array<[string, unknown]>; + selected: boolean; +} +const cap: Capture = { table: null, op: null, payload: undefined, eqCalls: [], selected: false }; +let nextResult: { data: unknown[] | null; error: { message: string } | null } = { data: [], error: null }; + +function makeBuilder() { + const builder: Record = {}; + const chain = () => builder; + builder.eq = vi.fn((c: string, v: unknown) => { cap.eqCalls.push([c, v]); return chain(); }); + builder.in = vi.fn(() => chain()); + builder.is = vi.fn(() => chain()); + builder.select = vi.fn(() => { cap.selected = true; return chain(); }); + // awaitable + builder.then = (resolve: (r: typeof nextResult) => unknown) => resolve(nextResult); + return builder; +} + +vi.mock('@/integrations/supabase/client', () => ({ + supabase: { + from: (table: string) => { + cap.table = table; + return { + insert: (p: unknown) => { cap.op = 'insert'; cap.payload = p; return makeBuilder(); }, + update: (p: unknown) => { cap.op = 'update'; cap.payload = p; return makeBuilder(); }, + upsert: (p: unknown) => { cap.op = 'upsert'; cap.payload = p; return makeBuilder(); }, + delete: () => { cap.op = 'delete'; return makeBuilder(); }, + }; + }, + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +function resetCap() { + cap.table = null; cap.op = null; cap.payload = undefined; cap.eqCalls = []; cap.selected = false; + nextResult = { data: [], error: null }; +} + +describe('rest-native WRITE (Plano A)', () => { + beforeEach(resetCap); + afterEach(resetCap); + + // ── Elegibilidade / whitelist ─────────────────────────────── + it('elegibilidade: write em tabela whitelisted = true; fora = false; select = false', () => { + expect(isRestNativeWriteEligible({ table: 'collections', operation: 'insert' } as InvokeOptions)).toBe(true); + expect(isRestNativeWriteEligible({ table: 'products', operation: 'update', id: 'x' } as InvokeOptions)).toBe(true); + expect(isRestNativeWriteEligible({ table: 'tabela_aleatoria', operation: 'insert' } as InvokeOptions)).toBe(false); + expect(isRestNativeWriteEligible({ table: 'products', operation: 'select' } as InvokeOptions)).toBe(false); + }); + + // ── A3: tabela BASE, nunca view ───────────────────────────── + it('A3: insert em products vai para a tabela BASE products (não v_products_public)', async () => { + nextResult = { data: [{ id: '1', name: 'X' }], error: null }; + const r = await executeRestNativeWrite({ table: 'products', operation: 'insert', data: { name: 'X' } } as InvokeOptions); + expect(cap.table).toBe('products'); + expect(cap.op).toBe('insert'); + expect(cap.selected).toBe(true); + expect(r.count).toBe(1); + }); + + it('A3: alias de rename personalization_techniques → tecnicas_gravacao', async () => { + nextResult = { data: [{ codigo: 'c1', nome: 'T' }], error: null }; + await executeRestNativeWrite({ table: 'personalization_techniques', operation: 'insert', data: { name: 'T', is_active: true } } as InvokeOptions); + expect(cap.table).toBe('tecnicas_gravacao'); + }); + + // ── A5: remap EN→PT no payload ──────────────────────────── + it('A5: payload de tecnicas_gravacao remapeia name→nome, is_active→ativo', async () => { + nextResult = { data: [{ codigo: 'c1' }], error: null }; + await executeRestNativeWrite( + { table: 'personalization_techniques', operation: 'insert', data: { name: 'Tampografia', is_active: true } } as InvokeOptions, + ); + expect(cap.payload).toMatchObject({ nome: 'Tampografia', ativo: true }); + expect(cap.payload).not.toHaveProperty('name'); + }); + + // ── A2: proteção contra mutação em massa ────────────────────── + it('A2: update SEM filtro/id é proibido', async () => { + await expect( + executeRestNativeWrite({ table: 'products', operation: 'update', data: { is_active: false } } as InvokeOptions), + ).rejects.toThrow(/mutação em massa/); + }); + + it('A2: delete SEM filtro/id é proibido', async () => { + await expect( + executeRestNativeWrite({ table: 'products', operation: 'delete' } as InvokeOptions), + ).rejects.toThrow(/mutação em massa/); + }); + + it('A2: update COM id é permitido e aplica eq(id)', async () => { + nextResult = { data: [{ id: 'p1' }], error: null }; + await executeRestNativeWrite({ table: 'products', operation: 'update', id: 'p1', data: { is_active: false } } as InvokeOptions); + expect(cap.eqCalls).toContainEqual(['id', 'p1']); + }); + + it('A2: delete COM id aplica eq(id) e select()', async () => { + nextResult = { data: [{ id: 'p9' }], error: null }; + await executeRestNativeWrite({ table: 'products', operation: 'delete', id: 'p9' } as InvokeOptions); + expect(cap.op).toBe('delete'); + expect(cap.eqCalls).toContainEqual(['id', 'p9']); + expect(cap.selected).toBe(true); + }); + + // ── A4: select-back vazio ainda é sucesso ───────────────────── + it('A4: insert com select-back vazio (RLS de SELECT) ainda é sucesso (count 0, sem throw)', async () => { + nextResult = { data: [], error: null }; + const r = await executeRestNativeWrite({ table: 'collections', operation: 'insert', data: { name: 'C' } } as InvokeOptions); + expect(r.records).toEqual([]); + expect(r.count).toBe(0); + }); + + // ── Propagação LOUD de erro (RLS negada) ───────────────────── + it('erro de RLS/validação PROPAGA (LOUD), não vira no-op', async () => { + nextResult = { data: null, error: { message: 'new row violates row-level security policy' } }; + await expect( + tryExecuteRestNativeWrite({ table: 'products', operation: 'insert', data: { name: 'X' } } as InvokeOptions), + ).rejects.toThrow(/row-level security/); + }); + + it('tryExecuteRestNativeWrite retorna null p/ tabela não-elegível (caller decide fallback)', async () => { + const r = await tryExecuteRestNativeWrite({ table: 'tabela_aleatoria', operation: 'insert', data: {} } as InvokeOptions); + expect(r).toBeNull(); + }); + + // ── batch_insert (array) ────────────────────────────────── + it('batch_insert envia array e remapeia cada linha', async () => { + nextResult = { data: [{ codigo: 'a' }, { codigo: 'b' }], error: null }; + await executeRestNativeWrite( + { table: 'personalization_techniques', operation: 'batch_insert', data: [{ name: 'A' }, { name: 'B' }] } as unknown as InvokeOptions, + ); + expect(Array.isArray(cap.payload)).toBe(true); + expect((cap.payload as Array>)[0]).toMatchObject({ nome: 'A' }); + }); +}); diff --git a/src/lib/external-db/bridge.ts b/src/lib/external-db/bridge.ts index 9a58f52e0..c4f74bd4f 100644 --- a/src/lib/external-db/bridge.ts +++ b/src/lib/external-db/bridge.ts @@ -14,7 +14,7 @@ import { supabase } from '@/integrations/supabase/client'; import { logger } from '@/lib/logger'; import { emitBridgeStatus, isColdStartSignal } from './bridge-status-events'; import { getKillSwitchState, KillSwitchActiveError, invalidateKillSwitchCache } from './kill-switch-client'; -import { tryExecuteRestNative, isRestNativeEligible, runWithConcurrency } from './rest-native'; +import { tryExecuteRestNative, isRestNativeEligible, runWithConcurrency, tryExecuteRestNativeWrite, isRestNativeWriteEligible } from './rest-native'; import { reportSilentEmpty } from './silent-empty-report'; import { recordBridgeCall, estimatePayloadBytes, type BridgeOperation } from '@/lib/telemetry/bridgeCallMetrics'; import { newRequestId } from '@/lib/telemetry/requestId'; @@ -24,6 +24,43 @@ const KILL_SWITCH_NAME = 'edge_external_db_bridge'; export type Operation = 'select' | 'insert' | 'update' | 'delete' | 'upsert' | 'batch_insert'; +const WRITE_OPERATIONS: ReadonlySet = new Set([ + 'insert', + 'update', + 'delete', + 'upsert', + 'batch_insert', +]); + +/** True para qualquer operação que MUTA dados (tudo menos 'select'). */ +export function isWriteOperation(op: Operation | string | undefined): boolean { + return !!op && WRITE_OPERATIONS.has(op as Operation); +} + +/** + * Lançado quando uma operação de ESCRITA não pôde ser executada porque a bridge + * está OFF (REST nativo ainda não cobre escrita) ou está inacessível (CORS/rede). + * + * Diferença crítica em relação ao retorno vazio de LEITURA: uma escrita NUNCA + * pode falhar em silêncio — senão a UI reporta "salvo com sucesso" sem que nada + * tenha sido persistido (corrupção silenciosa). Os callers (mutations / try-catch) + * já exibem `toast.error` a partir de `error.message`, então este erro tipado + * transforma o no-op silencioso em falha honesta e visível. + */ +export class WriteUnavailableError extends Error { + table: string; + operation: string; + constructor(table: string, operation: string) { + super( + `Escrita indisponível: '${operation}' em '${table}' não pôde ser persistida ` + + `(bridge OFF ou inacessível). Nenhum dado foi alterado.`, + ); + this.name = 'WriteUnavailableError'; + this.table = table; + this.operation = operation; + } +} + export interface InvokeOptions> { table: string; operation: Operation; @@ -66,7 +103,7 @@ export interface BatchResult { fromCache?: boolean; } -// ── Bridge invocation (legacy, kept for rollback) ─────────────────── +// ── Bridge invocation (legacy, kept for rollback) ──── const BOOT_RETRY_ATTEMPTS = 4; const BOOT_INITIAL_BACKOFF_MS = 400; @@ -158,7 +195,7 @@ export async function invokeBridge(body: Record): Promise
(options: InvokeOptions): Promise(options); + if (writeResult !== null) return writeResult; + } + if (!bridgeEnabled) { - if (options.operation !== 'select') { + if (isWriteOperation(options.operation)) { // (c) write became a no-op while bridge is OFF — actionable, error level. + // Telemetria + ERRO TIPADO: escrita nunca pode no-op silencioso (a UI mostraria + // "salvo" sem persistir). O throw vira toast.error nos callers (mutations/try-catch). reportSilentEmpty({ reason: 'write_bridge_off', table: options.table, @@ -298,6 +346,7 @@ export async function invokeExternalDb(options: InvokeOptions): Promise(options: InvokeOptions): Promise(options: InvokeOptions): Promise } export async function invokeExternalDbDelete(table: string, id: string): Promise { + // Plano A: tenta escrita REST nativa (RLS) primeiro. Sucesso → done; erro → LOUD. + if (isRestNativeWriteEligible({ table, operation: 'delete', id })) { + await tryExecuteRestNativeWrite({ table, operation: 'delete', id }); + return; + } try { await invokeBridge<{ success: boolean; deleted_id: string }>({ table, operation: 'delete', id }); } catch (err) { if (err instanceof KillSwitchActiveError) { - logger.warn(`[external-db] Delete blocked by kill-switch for ${table}/${id}`); - return; + // Delete não pode no-op silencioso: a UI mostraria "excluído com sucesso" + // sem nada ter sido removido. Vira toast.error no caller. + logger.warn(`[external-db] Delete blocked by kill-switch for ${table}/${id} — surfacing loud`); + throw new WriteUnavailableError(table, 'delete'); } if (err instanceof Error && isCorsOrNetworkBridgeError(err.message)) { - logger.warn(`[external-db] Delete CORS error for ${table}/${id}`); - return; + logger.warn(`[external-db] Delete CORS error for ${table}/${id} — surfacing loud`); + throw new WriteUnavailableError(table, 'delete'); } throw err; } diff --git a/src/lib/external-db/index.ts b/src/lib/external-db/index.ts index 1e0c7e9db..1d3bb541e 100644 --- a/src/lib/external-db/index.ts +++ b/src/lib/external-db/index.ts @@ -21,6 +21,8 @@ export { invokeExternalDbDelete, invokeBatchBridge, invokeBridge, + isWriteOperation, + WriteUnavailableError, } from './bridge'; export type { InvokeOptions, diff --git a/src/lib/external-db/rest-native.ts b/src/lib/external-db/rest-native.ts index 39ebf21c0..9f2b66de3 100644 --- a/src/lib/external-db/rest-native.ts +++ b/src/lib/external-db/rest-native.ts @@ -16,7 +16,7 @@ import { recordBridgeCall, estimatePayloadBytes } from '@/lib/telemetry/bridgeCa import { newRequestId } from '@/lib/telemetry/requestId'; import type { InvokeOptions, InvokeResult } from './bridge'; -// ── Whitelist ──────────────────────────────────────────────────────── +// ── Whitelist ──── const REST_NATIVE_SAFE_TABLES = new Set([ // Core catalog @@ -139,7 +139,7 @@ function mapRows(table: string, rows: unknown[]): unknown[] { }); } -// ── Metrics (Etapa 6) ───────────────────────────────────────────────── +// ── Metrics (Etapa 6) ──── interface RestNativeMetrics { success: number; @@ -177,7 +177,7 @@ export function resetRestNativeMetrics(): void { metrics.lastErrorAt = null; } -// ── Retry (Etapa 3) ────────────────────────────────────────────────── +// ── Retry (Etapa 3) ──── const REST_NATIVE_RETRY_COUNT = 1; const REST_NATIVE_RETRY_DELAY_MS = 500; @@ -199,7 +199,7 @@ function isRetryableError(msg: string): boolean { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// ── Concurrency limiter (Etapa 4) ─────────────────────────────────── +// ── Concurrency limiter (Etapa 4) ──── const BATCH_CONCURRENCY_LIMIT = 6; @@ -231,7 +231,7 @@ export async function runWithConcurrency( return results; } -// ── Constants ─────────────────────────────────────────────────────── +// ── Constants ──── const OFFSET_WITHOUT_LIMIT_FALLBACK_UPPER = 999; @@ -265,7 +265,7 @@ type RestNativeClient = { }; }; -// ── Eligibility ───────────────────────────────────────────────────── +// ── Eligibility ──── export function isRestNativeEligible(options: InvokeOptions): boolean { if (options.operation !== 'select') return false; @@ -280,7 +280,7 @@ export function isRestNativeEligible(options: InvokeOptions): boolean { return true; } -// ── PostgREST operator parsing ────────────────────────────────────── +// ── PostgREST operator parsing ──── const POSTGREST_OP_REGEX = /^(eq|neq|gt|gte|lt|lte|like|ilike|is|in|not)\.(.+)$/; @@ -339,7 +339,7 @@ function applyFilters(query: RestQuery, filters?: Record): Rest return query; } -// ── Core execution ────────────────────────────────────────────────── +// ── Core execution ──── export async function executeRestNativeSelect(options: InvokeOptions): Promise> { const tableName = TABLE_ALIASES[options.table] ?? options.table; @@ -489,3 +489,164 @@ export async function tryExecuteRestNative( } return null; } + +// ── WRITE support (Plano A) ──── +// +// Escrita vai SEMPRE para a tabela BASE — nunca para as views v_*_public +// (read-only). Por isso NÃO reusa TABLE_ALIASES (que mapeia p/ views de leitura); +// usa apenas WRITE_TABLE_ALIASES, que contém só RENAMES reais de tabela base. +const WRITE_TABLE_ALIASES: Record = { + tecnica_gravacao: 'tabela_preco_gravacao_oficial', + customization_price_tiers: 'tabela_preco_gravacao_oficial_faixa', + personalization_techniques: 'tecnicas_gravacao', +}; + +// Whitelist de ESCRITA (nomes como chamados pelos hooks de admin). A autorização +// REAL é por RLS no banco (PR#3 — policies WITH CHECK por role). Esta lista só +// evita escrita acidental em tabela não-prevista, que continua caindo no +// WriteUnavailableError honesto (bridge.ts). +const REST_NATIVE_WRITE_TABLES = new Set([ + 'products', + 'suppliers', + 'categories', + 'print_area_techniques', + 'personalization_techniques', + 'product_variants', + 'variant_supplier_sources', + 'supplier_branches', + 'tecnica_gravacao', + 'tecnica_gravacao_variante', + 'fornecedor_gravacao', + 'collections', + 'collection_products', +]); + +const REST_WRITE_OPS = new Set(['insert', 'update', 'delete', 'upsert', 'batch_insert']); + +export function isRestNativeWriteEligible(options: InvokeOptions): boolean { + if (!REST_WRITE_OPS.has(options.operation)) return false; + return REST_NATIVE_WRITE_TABLES.has(options.table); +} + +function resolveWriteTable(table: string): string { + return WRITE_TABLE_ALIASES[table] ?? table; +} + +// EN→PT no payload de dados (espelha remapFilters), escopado por tabela. +// Evita 400 do PostgREST por coluna inexistente em tabelas SSOT com nomes em PT. +function remapData(table: string, data: Record): Record { + const map = COLUMN_ALIASES_BY_TABLE[table]; + if (!map) return data; + const out: Record = {}; + for (const [k, v] of Object.entries(data)) out[map[k] ?? k] = v; + return out; +} + +type RestWriteBuilder = PromiseLike & { + eq(column: string, value: unknown): RestWriteBuilder; + in(column: string, values: readonly unknown[]): RestWriteBuilder; + is(column: string, value: null): RestWriteBuilder; + select(columns?: string): RestWriteBuilder; +}; + +type RestWriteClient = { + from(table: string): { + insert(values: unknown): RestWriteBuilder; + update(values: unknown): RestWriteBuilder; + delete(): RestWriteBuilder; + upsert(values: unknown): RestWriteBuilder; + }; +}; + +/** + * Executa uma ESCRITA via PostgREST nativo (Plano A). Independente da bridge e do + * kill-switch. Autorização por RLS no banco. Guardas: + * - (A2) update/delete SEM filtro/id → proibido (proteção contra mutação em massa). + * - (A3) sempre tabela BASE (resolveWriteTable), nunca view v_*_public. + * - (A5) remap EN→PT no payload e nos filtros. + * - (A4) `.select()` de volta; insert OK com select-back vazio (RLS de SELECT) ainda é sucesso. + */ +export async function executeRestNativeWrite(options: InvokeOptions): Promise> { + if (!isRestNativeWriteEligible(options)) { + throw new Error(`rest-native: not write-eligible for table=${options.table} op=${options.operation}`); + } + const table = resolveWriteTable(options.table); + + // (A2) proteção contra UPDATE/DELETE sem escopo. + const hasScope = !!options.id || (!!options.filters && Object.keys(options.filters).length > 0); + if ((options.operation === 'update' || options.operation === 'delete') && !hasScope) { + throw new Error( + `rest-native: ${options.operation} em '${table}' sem filtro/id é proibido (proteção contra mutação em massa).`, + ); + } + + const client = supabase as unknown as RestWriteClient; + const tbl = client.from(table); + + const scoped = (b: RestWriteBuilder): RestWriteBuilder => { + let q = b as unknown as RestQuery; + if (options.id) q = q.eq('id', options.id); + q = applyFilters(q, remapFilters(table, options.filters)); + return q as unknown as RestWriteBuilder; + }; + + const payloadOf = (d: unknown): unknown => + Array.isArray(d) + ? (d as Record[]).map((row) => remapData(table, row)) + : remapData(table, (d ?? {}) as Record); + + let builder: RestWriteBuilder; + switch (options.operation) { + case 'insert': + case 'batch_insert': + builder = tbl.insert(payloadOf(options.data)).select(); + break; + case 'upsert': + builder = tbl.upsert(payloadOf(options.data)).select(); + break; + case 'update': + builder = scoped(tbl.update(payloadOf(options.data))).select(); + break; + case 'delete': + builder = scoped(tbl.delete()).select(); + break; + default: + throw new Error(`rest-native: operação de escrita não suportada '${options.operation}'`); + } + + const { data, error } = await builder; + if (error) { + throw new Error(`rest-native write error (${table}/${options.operation}): ${error.message}`); + } + + const rows = mapRows(table, data ?? []) as T[]; + return { records: rows, count: rows.length }; +} + +/** + * Wrapper de escrita: sem retry (evita insert/upsert duplicado em erro transiente). + * Retorna null SOMENTE quando a tabela/op não é write-elegível (o caller decide o + * fallback). Erros reais (RLS negada, validação, rede) PROPAGAM — LOUD — para + * virarem toast.error no caller, nunca no-op silencioso. + */ +export async function tryExecuteRestNativeWrite(options: InvokeOptions): Promise | null> { + if (!isRestNativeWriteEligible(options)) return null; + const t0 = Date.now(); + try { + const result = await executeRestNativeWrite(options); + metrics.success++; + metrics.totalMs += Date.now() - t0; + logger.debug( + `[rest-native] WRITE OK ${options.operation} table=${resolveWriteTable(options.table)} ` + + `rows=${result.records.length} ${Date.now() - t0}ms`, + ); + return result; + } catch (e) { + const msg = (e as Error).message; + metrics.fail++; + metrics.lastError = msg; + metrics.lastErrorAt = Date.now(); + logger.warn(`[rest-native] WRITE FAIL ${options.operation} table=${resolveWriteTable(options.table)}: ${msg}`); + throw e; // LOUD + } +} diff --git a/tests/lib/bridge.test.ts b/tests/lib/bridge.test.ts index 1061ee683..5a6e32ba4 100644 --- a/tests/lib/bridge.test.ts +++ b/tests/lib/bridge.test.ts @@ -2,11 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@/integrations/supabase/client', () => ({ supabase: { - auth: { - getSession: vi.fn().mockResolvedValue({ - data: { session: { access_token: 'mock-token' } }, - }), - }, functions: { invoke: vi.fn(), }, @@ -33,7 +28,7 @@ describe('invokeBridge', () => { it('throws if table is missing for non-batch operations', async () => { await expect( invokeBridge({ operation: 'select' }) - ).rejects.toThrow('tabela não informada'); + ).rejects.toThrow('tabela nao informada'); }); it('allows batch operations without table', async () => { @@ -112,7 +107,7 @@ describe('invokeExternalDb', () => { }); const result = await invokeExternalDb({ - table: 'products', + table: 'audit_logs', operation: 'insert', data: { name: 'Test' }, }); @@ -129,7 +124,7 @@ describe('invokeExternalDb', () => { }); const result = await invokeExternalDb({ - table: 'products', + table: 'audit_logs', operation: 'select', }); @@ -149,9 +144,9 @@ describe('invokeExternalDbDelete', () => { error: null, }); - await invokeExternalDbDelete('products', 'del-1'); + await invokeExternalDbDelete('audit_logs', 'del-1'); expect(mockInvoke).toHaveBeenCalledWith('external-db-bridge', { - body: { table: 'products', operation: 'delete', id: 'del-1' }, + body: { table: 'audit_logs', operation: 'delete', id: 'del-1' }, headers: { Authorization: 'Bearer mock-token' }, }); });