Skip to content
Merged
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
161 changes: 161 additions & 0 deletions src/lib/external-db/__tests__/rest-native-write.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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<Record<string, unknown>>)[0]).toMatchObject({ nome: 'A' });
});
});
82 changes: 72 additions & 10 deletions src/lib/external-db/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Operation> = new Set<Operation>([
'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<T = Record<string, unknown>> {
table: string;
operation: Operation;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -158,7 +195,7 @@ export async function invokeBridge<T>(body: Record<string, unknown>): Promise<Br
throw new Error('Erro na bridge: tentativas esgotadas');
}

// ── Batch bridge ────────────────────────────────────────────────────
// ── Batch bridge ────

const BATCH_MAX_QUERIES = 10;

Expand Down Expand Up @@ -226,7 +263,7 @@ export async function invokeBatchBridge(queries: BatchQuery[]): Promise<BatchRes
}
}

// ── CRUD ────────────────────────────────────────────────────────────
// ── CRUD ────

/**
* Etapa 2: mapeia o Operation do bridge para o BridgeOperation da telemetria.
Expand Down Expand Up @@ -288,16 +325,28 @@ export async function invokeExternalDb<T>(options: InvokeOptions): Promise<Invok
// is ON, it recorded the failed REST attempt and we fall back to the bridge.
}

// Fast path WRITE (Plano A): escrita via PostgREST nativo + RLS, independente da
// bridge/kill-switch. Sucesso → retorna; erro (RLS negada, validação) → PROPAGA
// LOUD (toast.error no caller). Só cai para baixo se a tabela/op não for elegível,
// onde o WriteUnavailableError honesto assume.
if (isRestNativeWriteEligible(options)) {
const writeResult = await tryExecuteRestNativeWrite<T>(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,
operation: options.operation,
});
recordCall(false, null, 'write_bridge_off');
recordKillSwitchHit({ switch_name: KILL_SWITCH_NAME, operation: telemetryOp, target: options.table });
throw new WriteUnavailableError(options.table, options.operation);
} else if (!isRestNativeEligible(options)) {
// (a) SELECT on a table with no REST-native path — config gap, warn level.
reportSilentEmpty({
Expand Down Expand Up @@ -329,13 +378,19 @@ export async function invokeExternalDb<T>(options: InvokeOptions): Promise<Invok
} catch (err) {
if (err instanceof KillSwitchActiveError) {
recordCall(false, null, 'kill_switch_active');
if (isWriteOperation(options.operation)) {
throw new WriteUnavailableError(options.table, options.operation);
}
return { records: [], count: 0 };
}
if (err instanceof Error && isCorsOrNetworkBridgeError(err.message)) {
recordCall(false, null, err.message);
if (isWriteOperation(options.operation)) {
throw new WriteUnavailableError(options.table, options.operation);
}
logger.debug(
`[external-db] Bridge CORS/network error for ${options.table} — returning empty.`,
);
recordCall(false, null, err.message);
return { records: [], count: 0 };
}
// Non-CORS errors still propagate (auth errors, data errors, etc.)
Expand All @@ -351,16 +406,23 @@ export async function invokeExternalDbSingle<T>(options: InvokeOptions): Promise
}

export async function invokeExternalDbDelete(table: string, id: string): Promise<void> {
// 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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/external-db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export {
invokeExternalDbDelete,
invokeBatchBridge,
invokeBridge,
isWriteOperation,
WriteUnavailableError,
} from './bridge';
export type {
InvokeOptions,
Expand Down
Loading
Loading