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 d91d841f6..d51ed21cd 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'; const KILL_SWITCH_NAME = 'edge_external_db_bridge'; @@ -292,6 +292,15 @@ export async function invokeExternalDb(options: InvokeOptions): Promise(options); + if (writeResult !== null) return writeResult; + } + if (!bridgeEnabled) { if (isWriteOperation(options.operation)) { // (c) write became a no-op while bridge is OFF — actionable, error level. @@ -354,6 +363,11 @@ export async function invokeExternalDbSingle(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) { diff --git a/src/lib/external-db/rest-native.ts b/src/lib/external-db/rest-native.ts index 196be651c..70d1c7c1e 100644 --- a/src/lib/external-db/rest-native.ts +++ b/src/lib/external-db/rest-native.ts @@ -14,7 +14,7 @@ import { logger } from '@/lib/logger'; import { reportSilentEmpty } from './silent-empty-report'; import type { InvokeOptions, InvokeResult } from './bridge'; -// ── Whitelist ──────────────────────────────────────────────────────── +// ── Whitelist ───────────────────────────────────────────────── const REST_NATIVE_SAFE_TABLES = new Set([ // Core catalog @@ -137,7 +137,7 @@ function mapRows(table: string, rows: unknown[]): unknown[] { }); } -// ── Metrics (Etapa 6) ───────────────────────────────────────────────── +// ── Metrics (Etapa 6) ───────────────────────────────────────── interface RestNativeMetrics { success: number; @@ -175,7 +175,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; @@ -197,7 +197,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; @@ -229,7 +229,7 @@ export async function runWithConcurrency( return results; } -// ── Constants ─────────────────────────────────────────────────────── +// ── Constants ───────────────────────────────────────────── const OFFSET_WITHOUT_LIMIT_FALLBACK_UPPER = 999; @@ -263,7 +263,7 @@ type RestNativeClient = { }; }; -// ── Eligibility ───────────────────────────────────────────────────── +// ── Eligibility ───────────────────────────────────────── export function isRestNativeEligible(options: InvokeOptions): boolean { if (options.operation !== 'select') return false; @@ -278,7 +278,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)\.(.+)$/; @@ -337,7 +337,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; @@ -461,3 +461,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' }, }); });