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' });
});
});
16 changes: 15 additions & 1 deletion 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';

const KILL_SWITCH_NAME = 'edge_external_db_bridge';
Expand Down Expand Up @@ -292,6 +292,15 @@ export async function invokeExternalDb<T>(options: InvokeOptions): Promise<Invok
// we fall through to the bridge path.
}

// 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 (isWriteOperation(options.operation)) {
// (c) write became a no-op while bridge is OFF — actionable, error level.
Expand Down Expand Up @@ -354,6 +363,11 @@ 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) {
Expand Down
Loading
Loading