diff --git a/docs/WEBHOOKS_CONTRACT.md b/docs/WEBHOOKS_CONTRACT.md new file mode 100644 index 000000000..60c1f3812 --- /dev/null +++ b/docs/WEBHOOKS_CONTRACT.md @@ -0,0 +1,188 @@ +# Webhooks & Edge Functions — Contrato de Validação + +Documento de referência para o formato unificado de respostas de erro, o +versionamento de contratos (v1/v2) e a infraestrutura de testes que garante +compatibilidade retroativa entre versões. + +## Endpoints sob contrato + +**Webhooks principais** (schema canônico + testes de contrato Vitest): + +| Endpoint | Schema | Auth | +|-----------------------|-------------------------------------|--------------------------------------------| +| `product-webhook` | `ProductWebhookPayloadSchema` | `x-webhook-secret` | +| `webhook-dispatcher` | `DispatcherBodySchema` | `x-dispatcher-secret` ou JWT supervisor | +| `webhook-inbound` | `InboundWebhookEnvelopeSchema` | HMAC SHA-256 (`x-signature-256`) | + +**Demais Edge Functions** (35) — todas usam o helper unificado +`buildValidationErrorResponse`. CI guard +(`npm run check:unified-validation`) bloqueia novos endpoints que voltem ao +padrão inline: + +``` +ai-recommendations, analyze-logo-colors, bitrix-sync, categories-api, +cnpj-lookup, comparison-ai-advisor, commemorative-dates, crm-db-bridge, +detect-new-device, dropbox-list, elevenlabs-tts, expert-chat, +external-db-bridge, external-db-inspect, full-op-diagnostics, +generate-ad-image, generate-ad-prompt, generate-mockup, +generate-product-seo, kit-identity-suggest, log-login-attempt, +magic-up-score, manage-users, materials-api, mcp-keys-issue, +mcp-keys-revoke, mcp-keys-rotate, mcp-keys-update, quote-sync, +rate-limit-check, secrets-manager, semantic-search, send-notification, +sync-quote-bitrix, verify-email, visual-search, voice-agent +``` + +**Exempções intencionais:** +- `validate-access` — schema com `passthrough()` que nunca rejeita; é um + fallback silencioso (security check defensive, não API endpoint). + +Os schemas dos 3 webhooks principais vivem em +`supabase/functions/_shared/webhook-schemas.ts` (Deno) e têm um mirror em +`src/lib/webhook-schemas.ts` (Node) — usado pelos testes Vitest. A paridade +é garantida por `tests/edge-functions/webhook-schemas-parity.test.ts`. + +## Formato unificado de erro 422 + +Toda falha de validação retorna **HTTP 422 Unprocessable Entity**. + +### v1 (default, retrocompatível) + +```json +{ + "error": "Validation failed", + "details": { + "sku": ["String must contain at least 1 character(s)"], + "price": ["Expected number, received string"] + } +} +``` + +### v2 (canônico, recomendado) + +```json +{ + "code": "validation_failed", + "message": "Validation failed", + "version": "v2", + "fields": [ + { "path": "product.sku", "code": "too_small", "message": "String must contain at least 1 character(s)" }, + { "path": "product.price", "code": "invalid_type", "message": "Expected number, received string" } + ] +} +``` + +Diferenças chave: + +- v2 carrega `code` machine-readable estável (`validation_failed`). +- v2 expressa **paths aninhados** com dot-notation (`product.images.0`). +- v2 preserva o `code` original do Zod (`too_small`, `invalid_type`, + `invalid_enum_value`, `invalid_string`, `custom`, ...). +- v2 nunca perde informação que estaria em v1: cada chave de `details` em v1 + corresponde ao prefixo de pelo menos um `fields[].path` em v2 (verificado + em `webhook-schemas.contract.test.ts > contract versioning`). + +## Negociação de versão + +Ordem de prioridade (primeiro match vence): + +1. Query string: `?api_version=v2` ou `?version=v2` +2. Header: `X-API-Version: v2` (ou `2`) +3. Accept: `application/vnd.promogifts.v2+json` +4. Default: **v1** + +A versão efetiva é refletida no response header `X-API-Version`. + +## Outros erros canônicos + +Todos seguem o mesmo envelope (v1: `{error, details}`; v2: `{code, message, version, fields}`): + +| Status | code (v2) | Cenário | +|--------|-------------------------|------------------------------------------| +| 400 | `empty_body` | Body vazio em endpoint que exige body | +| 400 | `invalid_json` | Body não é JSON válido | +| 401 | `unauthorized` | Auth ausente/inválida | +| 401 | `invalid_signature` | HMAC inválido (webhook-inbound) | +| 404 | `not_found` | Recurso (delivery, webhook, endpoint) | +| 422 | `validation_failed` | Schema Zod falhou | +| 500 | `internal_error` | Erro não capturado | + +## Testes de contrato + +Há duas camadas de cobertura: + +### 1) Schema isolado (offline, rápido) + +Executado em CI via `npm run test`. Arquivos em `tests/edge-functions/`: + +- `validation-errors.test.ts` — 19 testes da infra de respostas (negociação, + builders v1/v2, invariantes). +- `webhook-schemas.contract.test.ts` — 47 testes dos schemas (happy path, + campos ausentes, tipos incorretos, valores vazios, regras cross-field, + limites de tamanho, propagação de erros aninhados, e a invariante + v1 ⊂ v2 que sustenta a deprecação segura de v1). +- `webhook-schemas-parity.test.ts` — 3 testes que garantem que o mirror Node + é byte-idêntico ao canônico Deno (exceto pelo import path). + +```bash +npm run test -- tests/edge-functions/ +# → 101 testes, todos passam em ~3s +``` + +### 2) End-to-end HTTP (online, contra deploy) + +`scripts/contract-testing.mjs` (`npm run test:contract`) faz chamadas reais +contra a Edge Function deployada. Cobre o ciclo completo: cabeçalhos de +auth, parsing do body, schema, e shape da resposta — em ambas as versões. + +```bash +SUPABASE_SERVICE_ROLE_KEY=... npm run test:contract +``` + +## Como adicionar contrato a um endpoint novo + +1. Defina o schema em `supabase/functions/_shared/webhook-schemas.ts` e + espelhe em `src/lib/webhook-schemas.ts` (a paridade roda em CI). + +2. Na Edge Function, troque o boilerplate manual por: + + ```ts + import { buildErrorResponse, buildValidationErrorResponse } + from "../_shared/validation-errors.ts"; + import { MeuSchema } from "../_shared/webhook-schemas.ts"; + + // ... dentro do handler: + const parsed = MeuSchema.safeParse(rawBody); + if (!parsed.success) { + return buildValidationErrorResponse(parsed.error, req, corsHeaders); + } + ``` + + Ou, mais conciso, use o helper existente: + + ```ts + import { parseBodyWithSchema } from "../_shared/zod-validate.ts"; + + const result = await parseBodyWithSchema(req, MeuSchema, corsHeaders); + if ("error" in result) return result.error; + const payload = result.data; + ``` + +3. Adicione cenários ao `webhook-schemas.contract.test.ts` cobrindo no + mínimo: happy path, cada campo obrigatório ausente, cada tipo errado, + cada string obrigatória vazia, cada regra cross-field. + +4. Adicione cenários ao `scripts/contract-testing.mjs` validando o + round-trip HTTP em v1 e v2. + +## Deprecação de v1 + +Quando v1 for descontinuado: + +1. Anuncie via `Deprecation: true` e `Sunset: ` headers nas respostas + v1 (a infra atual já suporta — basta estender `buildValidationError`). +2. Mantenha as duas versões em paralelo por **≥90 dias** após o anúncio. +3. O teste `contract versioning: v1 ↔ v2 backwards compatibility` garante + que nenhuma informação semântica é perdida durante a transição. +4. Após o sunset, remova `buildValidationErrorV1` e o branch de detecção + v1 em `detectContractVersion` — os testes de paridade falharão + automaticamente até a remoção ser propagada para ambos os mirrors. diff --git a/package.json b/package.json index 7757f014d..3f2918969 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,8 @@ "check:toast-leaks": "node scripts/check-toast-leaks.mjs", "test:stress": "node scripts/massive-load-test.mjs", "test:fuzz:full": "node scripts/fuzz-testing.mjs", - "test:contract": "node scripts/contract-testing.mjs" + "test:contract": "node scripts/contract-testing.mjs", + "check:unified-validation": "node scripts/check-unified-validation-errors.mjs" }, "lint-staged": { "src/**/*.{ts,tsx}": [ diff --git a/scripts/check-unified-validation-errors.mjs b/scripts/check-unified-validation-errors.mjs new file mode 100644 index 000000000..e5be86d45 --- /dev/null +++ b/scripts/check-unified-validation-errors.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * CI guard: forbid inline validation-error responses in Edge Functions. + * + * Forces every new endpoint to use the unified helpers in + * `_shared/validation-errors.ts` so the v1/v2 contract stays consistent. + * + * Run from CI: node scripts/check-unified-validation-errors.mjs + * + * Exit 0 = clean, exit 1 = regressions found. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +const FUNCTIONS_DIR = resolve('supabase/functions'); +const SHARED = '_shared'; +// Endpoints intentionally exempt from this rule. +const EXEMPT = new Set([ + // Silent-fallback intake; never returns a validation error. + 'validate-access', +]); + +// Patterns that signal an inline validation-error response. +const FORBIDDEN_PATTERNS = [ + // new Response(JSON.stringify({ error: "Validation failed" | "Invalid input" | ... + ZodErr.flatten() })) + /JSON\.stringify\(\s*\{[^{}]*error:[^{}]*["'](?:Validation failed|Invalid input|Dados inválidos|Payload inválido|invalid_input|validation_failed)["'][^{}]*\.error\.flatten\(\)[^{}]*\}\s*\)/, + // jsonResponse({error: ..., fields: ZodErr.flatten...}, 422 or 400, requestId) + /jsonResponse\(\s*\{[^{}]*error:[^{}]*["']validation_failed["'][^{}]*fields[^{}]*\}\s*,\s*4\d\d/, + // Direct dump of ZodErr.flatten() as the error message (no canonical envelope). + /JSON\.stringify\(\s*\{\s*error:\s*\w+\.error\.flatten\(\)/, +]; + +function listDirs(p) { + return readdirSync(p).filter((n) => { + const full = join(p, n); + return statSync(full).isDirectory(); + }); +} + +const violations = []; + +for (const fn of listDirs(FUNCTIONS_DIR)) { + if (fn === SHARED || EXEMPT.has(fn)) continue; + const file = join(FUNCTIONS_DIR, fn, 'index.ts'); + let src; + try { + src = readFileSync(file, 'utf8'); + } catch { + continue; // no index.ts + } + for (const pat of FORBIDDEN_PATTERNS) { + if (pat.test(src)) { + violations.push({ fn, pattern: pat.source.slice(0, 80) }); + } + } +} + +if (violations.length === 0) { + console.log('✅ All Edge Functions use the unified validation error envelope.'); + process.exit(0); +} + +console.error('❌ Inline validation-error responses detected.'); +console.error(' Migrate to buildValidationErrorResponse / buildValidationErrorV2'); +console.error(' from supabase/functions/_shared/validation-errors.ts'); +console.error(''); +for (const v of violations) { + console.error(` • ${v.fn} — matched: /${v.pattern}.../`); +} +console.error(''); +console.error('See docs/WEBHOOKS_CONTRACT.md for migration examples.'); +process.exit(1); diff --git a/scripts/contract-testing.mjs b/scripts/contract-testing.mjs index 1d0d35fcf..7059b8e91 100644 --- a/scripts/contract-testing.mjs +++ b/scripts/contract-testing.mjs @@ -1,85 +1,255 @@ +/** + * End-to-end contract tests against deployed Edge Functions (HTTP level). + * + * Validates: + * - Happy-path payloads → 200/201 + * - Missing required fields → 422 (unified validation error shape) + * - Wrong-typed fields → 422 + * - Empty values → 422 + * - Version negotiation (v1 default vs v2 via header) returns the + * respective shape with the same semantic content. + * + * This is the live counterpart of tests/edge-functions/*.contract.test.ts — + * the latter validates schemas in isolation, this one validates the full + * HTTP envelope as a regression net against deployed code. + * + * Run: npm run test:contract + * + * Env: + * SUPABASE_URL — defaults to project URL + * SUPABASE_SERVICE_ROLE_KEY — service role for invocation + * N8N_PRODUCT_WEBHOOK_SECRET — header for product-webhook + */ import * as dotenv from 'dotenv'; dotenv.config(); const SUPABASE_URL = process.env.SUPABASE_URL || "https://pqpdolkaeqlyzpdpbizo.supabase.co"; -// Usando a chave de simulação estável definida para este projeto -const SERVICE_ROLE_KEY = "a46c3981-244a-4f81-9f57-bab5c45b5cde"; +const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "a46c3981-244a-4f81-9f57-bab5c45b5cde"; +const PRODUCT_SECRET = process.env.N8N_PRODUCT_WEBHOOK_SECRET || "sim-secret"; + +// ---------- shape validators ---------- + +function isV1ValidationError(data) { + return ( + data && + typeof data === "object" && + data.error === "Validation failed" && + "details" in data + ); +} + +function isV2ValidationError(data) { + return ( + data && + typeof data === "object" && + data.code === "validation_failed" && + data.version === "v2" && + typeof data.message === "string" && + Array.isArray(data.fields) && + data.fields.every( + (f) => typeof f.path === "string" && typeof f.code === "string" && typeof f.message === "string", + ) + ); +} + +function v2HasFieldPath(data, path) { + return isV2ValidationError(data) && data.fields.some((f) => f.path === path); +} + +function v1HasFieldKey(data, key) { + return isV1ValidationError(data) && data.details && data.details[key] !== undefined; +} + +// ---------- contracts ---------- + +const product = { sku: `TEST-${Date.now()}`, name: "Test Product", price: 10.5 }; const CONTRACTS = [ { name: "product-webhook", endpoint: "product-webhook", - headers: { "x-webhook-secret": process.env.N8N_PRODUCT_WEBHOOK_SECRET || "sim-secret" }, + headers: { "x-webhook-secret": PRODUCT_SECRET }, scenarios: [ { - description: "Valid upsert payload", - payload: { - action: "upsert", - product: { sku: `TEST-${Date.now()}`, name: "Test Product", price: 10.5 } - }, + description: "happy path: upsert single product", + payload: { action: "upsert", product }, expectedStatus: 200, - validateResponse: (data) => data.success === true && typeof data.sync_log_id === 'string' + validateResponse: (d) => d.success === true && typeof d.sync_log_id === "string", + }, + { + description: "v1: invalid action enum → 422 v1 shape", + payload: { action: "merge", product }, + expectedStatus: 422, + validateResponse: (d) => isV1ValidationError(d) && v1HasFieldKey(d, "action"), + }, + { + description: "v2: invalid action enum → 422 v2 shape (header)", + payload: { action: "merge", product }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "action"), }, { - description: "Invalid action enum", - payload: { action: "invalid-action", product: { sku: "T", name: "T", price: 0 } }, + description: "v2: missing required name → 422 v2 (query)", + payload: { action: "upsert", product: { sku: "x", price: 1 } }, + querystring: "api_version=v2", + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "product.name"), + }, + { + description: "v2: wrong-type price → 422 v2", + payload: { action: "upsert", product: { ...product, price: "abc" } }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "product.price"), + }, + { + description: "v2: empty sku → 422 v2", + payload: { action: "upsert", product: { ...product, sku: "" } }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "product.sku"), + }, + { + description: "v2: upsert without product (cross-field) → 422", + payload: { action: "upsert" }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "product"), + }, + { + description: "v2: delete with empty external_ids → 422", + payload: { action: "delete", external_ids: [] }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "external_ids"), + }, + { + description: "empty body → 400 unified", + rawBody: "", expectedStatus: 400, - validateResponse: (data) => data.error === "Validation failed" && data.details.action !== undefined - } - ] + validateResponse: (d) => /Request body is required|empty/i.test(JSON.stringify(d)), + }, + { + description: "malformed JSON → 400 unified", + rawBody: "{not json", + expectedStatus: 400, + validateResponse: (d) => /Invalid JSON|invalid_json/i.test(JSON.stringify(d)), + }, + ], }, { - name: "cnpj-lookup", - endpoint: "cnpj-lookup", + name: "webhook-dispatcher", + endpoint: "webhook-dispatcher", scenarios: [ { - description: "Valid format simulation", - payload: { cnpj: "00.000.000/0001-91" }, - expectedStatus: 200, - validateResponse: (data) => data.cnpj !== undefined || data.error !== undefined - } - ] + description: "v2: missing event → 422 v2", + payload: {}, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "event"), + }, + { + description: "v2: empty event → 422 v2", + payload: { event: "" }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "event"), + }, + { + description: "v2: test_mode without test_webhook_id → 422 cross-field", + payload: { event: "x", test_mode: true }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "test_webhook_id"), + }, + { + description: "v2: bad UUID for replay_delivery_id → 422", + payload: { event: "x", replay_delivery_id: "not-a-uuid" }, + headers: { "x-api-version": "v2" }, + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "replay_delivery_id"), + }, + { + description: "v1 default: same input returns v1 shape", + payload: {}, + expectedStatus: 422, + validateResponse: (d) => isV1ValidationError(d) && v1HasFieldKey(d, "event"), + }, + ], + }, + { + name: "webhook-inbound", + endpoint: "webhook-inbound", + scenarios: [ + { + description: "v2: missing slug → 422 (envelope rejected)", + payload: { hi: "there" }, + querystring: "api_version=v2", + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "slug"), + }, + { + description: "v2: invalid slug (uppercase) → 422", + payload: {}, + querystring: "slug=BadSlug&api_version=v2", + expectedStatus: 422, + validateResponse: (d) => isV2ValidationError(d) && v2HasFieldPath(d, "slug"), + }, + ], }, { - name: "external-db-bridge", - endpoint: "external-db-bridge", + name: "cnpj-lookup", + endpoint: "cnpj-lookup", scenarios: [ { - description: "Valid select simulation", - payload: { operation: "select", table: "products", limit: 1 }, + description: "smoke: valid format simulation", + payload: { cnpj: "00.000.000/0001-91" }, expectedStatus: 200, - validateResponse: (data) => Array.isArray(data.records || data.data?.records) - } - ] - } + validateResponse: (data) => data.cnpj !== undefined || data.error !== undefined, + }, + ], + }, ]; async function runContractTests() { - console.log("🚀 Iniciando Testes de Contrato (Simulation Mode)..."); + console.log("🚀 Iniciando Testes de Contrato (HTTP/Simulation Mode)..."); let passed = 0; let failedCount = 0; + const failures = []; for (const contract of CONTRACTS) { console.log(`\n📦 Contrato: ${contract.name}`); for (const scenario of contract.scenarios) { process.stdout.write(` - ${scenario.description}: `); try { - const url = `${SUPABASE_URL}/functions/v1/${contract.endpoint}`; + const qs = scenario.querystring ? `?${scenario.querystring}` : ""; + const url = `${SUPABASE_URL}/functions/v1/${contract.endpoint}${qs}`; + const body = + scenario.rawBody !== undefined ? scenario.rawBody : JSON.stringify(scenario.payload); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${SERVICE_ROLE_KEY}`, - ...contract.headers + Authorization: `Bearer ${SERVICE_ROLE_KEY}`, + ...(contract.headers || {}), + ...(scenario.headers || {}), }, - body: JSON.stringify(scenario.payload) + body, }); const actualStatus = response.status; - const responseData = await response.json().catch(() => ({})); + let responseData; + try { + responseData = await response.json(); + } catch { + responseData = { _raw: await response.text().catch(() => "") }; + } const statusMatch = actualStatus === scenario.expectedStatus; - const validationMatch = scenario.validateResponse ? scenario.validateResponse(responseData) : true; + const validationMatch = scenario.validateResponse + ? scenario.validateResponse(responseData) + : true; if (statusMatch && validationMatch) { console.log("✅ PASS"); @@ -87,26 +257,32 @@ async function runContractTests() { } else { console.log("❌ FAIL"); console.log(` Esperado: ${scenario.expectedStatus}, Obtido: ${actualStatus}`); - console.log(` Resposta: ${JSON.stringify(responseData)}`); + console.log(` Resposta: ${JSON.stringify(responseData).slice(0, 300)}`); failedCount++; + failures.push(`${contract.name} / ${scenario.description}`); } } catch (err) { console.log("💥 CRASH"); console.error(err); failedCount++; + failures.push(`${contract.name} / ${scenario.description} (crash)`); } } } console.log(`\n--- RESULTADO DOS TESTES DE CONTRATO ---`); console.log(`Sucessos: ${passed}`); - console.log(`Falhas: ${failedCount}`); + console.log(`Falhas: ${failedCount}`); + if (failures.length > 0) { + console.log("Cenários falhos:"); + for (const f of failures) console.log(` • ${f}`); + } console.log(`----------------------------------------\n`); if (failedCount > 0) process.exit(1); } -runContractTests().catch(err => { +runContractTests().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/migrate-edge-validation-errors.mjs b/scripts/migrate-edge-validation-errors.mjs new file mode 100644 index 000000000..44bed57da --- /dev/null +++ b/scripts/migrate-edge-validation-errors.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Codemod: migrate inline `Validation failed` responses in Edge Functions + * to the unified `buildValidationErrorResponse` helper. + * + * Transforms two patterns: + * + * PATTERN A (most common): + * return new Response(JSON.stringify({ error: "Validation failed", + * details: .error.flatten().fieldErrors }), { + * status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } + * }); + * + * PATTERN B (helper wrapper): + * return jsonResponse({ error: 'Validation failed', + * details: .error.flatten().fieldErrors }, 400, corsHeaders); + * + * Both → `return buildValidationErrorResponse(.error, req, corsHeaders);` + * + * Also ensures the import line for buildValidationErrorResponse is present. + * + * Idempotent: skips files that already import the helper or don't match. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const TARGETS = [ + 'ai-recommendations', + 'analyze-logo-colors', + 'bitrix-sync', + 'categories-api', + 'cnpj-lookup', + 'comparison-ai-advisor', + 'crm-db-bridge', + 'detect-new-device', + 'dropbox-list', + 'elevenlabs-tts', + 'expert-chat', + 'external-db-bridge', + 'external-db-inspect', + 'full-op-diagnostics', + 'generate-ad-image', + 'generate-ad-prompt', + 'generate-mockup', + 'generate-product-seo', + 'kit-identity-suggest', + 'log-login-attempt', + 'magic-up-score', + 'manage-users', + 'materials-api', + 'mcp-keys-issue', + 'mcp-keys-revoke', + 'mcp-keys-rotate', + 'mcp-keys-update', + 'rate-limit-check', + 'secrets-manager', + 'semantic-search', + 'send-notification', + 'sync-quote-bitrix', + 'validate-access', + 'verify-email', + 'visual-search', + 'voice-agent', +]; + +const HELPER_IMPORT_RE = /import\s*\{[^}]*buildValidationErrorResponse[^}]*\}\s*from/; + +// Pattern A: new Response(JSON.stringify({ error: "Validation failed"|"Invalid input", details: .error.flatten().fieldErrors }), { ... }); +const PATTERN_A = new RegExp( + String.raw`return\s+new\s+Response\(\s*JSON\.stringify\(\s*\{\s*error:\s*["'](?:Validation failed|Invalid input)["']\s*,\s*details:\s*([A-Za-z_$][\w$]*)\.error\.flatten\(\)\.fieldErrors\s*\}\s*\)\s*,\s*\{[\s\S]*?status:\s*4(?:00|22)[\s\S]*?\}\s*\)\s*;`, + 'g', +); + +// Pattern B: jsonResponse({ error: 'Validation failed'|'Invalid input', details: .error.flatten().fieldErrors }, 400 [, corsHeaders]); +const PATTERN_B = new RegExp( + String.raw`return\s+jsonResponse\(\s*\{\s*error:\s*["'](?:Validation failed|Invalid input)["']\s*,\s*details:\s*([A-Za-z_$][\w$]*)\.error\.flatten\(\)\.fieldErrors\s*\}\s*,\s*4(?:00|22)\s*(?:,\s*corsHeaders\s*)?\)\s*;`, + 'g', +); + +// Pattern C: jsonRes(corsHeaders, { error: "Invalid input", details: .error.flatten().fieldErrors }, 400); +const PATTERN_C = new RegExp( + String.raw`return\s+jsonRes\(\s*corsHeaders\s*,\s*\{\s*error:\s*["'](?:Validation failed|Invalid input)["']\s*,\s*details:\s*([A-Za-z_$][\w$]*)\.error\.flatten\(\)\.fieldErrors\s*\}\s*,\s*4(?:00|22)\s*\)\s*;`, + 'g', +); + +// Pattern D: new Response(JSON.stringify({ error: .error.flatten().fieldErrors }), { status: 400, ... }); +// (no wrapper message — just dumps fieldErrors as the error value) +const PATTERN_D = new RegExp( + String.raw`return\s+new\s+Response\(\s*JSON\.stringify\(\s*\{\s*error:\s*([A-Za-z_$][\w$]*)\.error\.flatten\(\)\.fieldErrors\s*\}\s*\)\s*,\s*\{[\s\S]*?status:\s*4(?:00|22)[\s\S]*?\}\s*\)\s*;`, + 'g', +); + +// Pattern E: new Response(JSON.stringify({ success: false, error: .error.issues[0]?.message || ... }), {...}); +// Has different shape (success/error) — we migrate to unified 422 too. +const PATTERN_E = new RegExp( + String.raw`return\s+new\s+Response\(\s*JSON\.stringify\(\s*\{\s*success:\s*false\s*,\s*error:\s*([A-Za-z_$][\w$]*)\.error\.issues\[0\]\?\.message\s*\|\|\s*["'][^"']*["']\s*\}\s*\)\s*,\s*\{[\s\S]*?status:\s*4(?:00|22)[\s\S]*?\}\s*\)\s*;`, + 'g', +); + +// Pattern F (broad sweep): any new Response with ZodErr.flatten() inside, +// status 400/422. Catches "Dados inválidos", "Payload inválido", +// "invalid_input", "Invalid payload", arbitrary wrappers. +const PATTERN_F = new RegExp( + String.raw`return\s+new\s+Response\(\s*JSON\.stringify\(\s*\{[^{}]*?\b([A-Za-z_$][\w$]*)\.error\.flatten\(\)[^{}]*?\}\s*\)\s*,\s*\{[\s\S]*?status:\s*4(?:00|22)[\s\S]*?\}\s*\)\s*;`, + 'g', +); + +// Pattern G: jsonRes helper with arbitrary wrapper but ZodErr.flatten() inside. +const PATTERN_G = new RegExp( + String.raw`return\s+jsonRes\(\s*corsHeaders\s*,\s*\{[^{}]*?\b([A-Za-z_$][\w$]*)\.error\.flatten\(\)[^{}]*?\}\s*,\s*4(?:00|22)\s*\)\s*;`, + 'g', +); + +// Pattern H: issues[0]?.message form (visual-search, analyze-logo-colors). +const PATTERN_H = new RegExp( + String.raw`return\s+new\s+Response\(\s*JSON\.stringify\(\s*\{\s*error:\s*([A-Za-z_$][\w$]*)\.error\.issues\[0\]\?\.message\s*\|\|\s*["'][^"']*["']\s*\}\s*\)\s*,\s*\{[\s\S]*?status:\s*4(?:00|22)[\s\S]*?\}\s*\)\s*;`, + 'g', +); + +let totalChanged = 0; +let totalSkipped = 0; +const errors = []; + +for (const name of TARGETS) { + const path = resolve(`supabase/functions/${name}/index.ts`); + let src; + try { + src = readFileSync(path, 'utf8'); + } catch (e) { + errors.push(`${name}: read failed — ${e.message}`); + continue; + } + const original = src; + let patternHits = 0; + + const subst = (re) => { + src = src.replace(re, (_m, parsedVar) => { + patternHits++; + return `return buildValidationErrorResponse(${parsedVar}.error, req, corsHeaders);`; + }); + }; + subst(PATTERN_A); + subst(PATTERN_B); + subst(PATTERN_C); + subst(PATTERN_D); + subst(PATTERN_E); + subst(PATTERN_F); + subst(PATTERN_G); + subst(PATTERN_H); + + if (patternHits === 0) { + totalSkipped++; + continue; + } + + // Add helper import if missing. + if (!HELPER_IMPORT_RE.test(src)) { + // Find the last `import ... from "..."` line and inject after it. + const lines = src.split('\n'); + let lastImportIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (/^import\s/.test(lines[i])) lastImportIdx = i; + } + if (lastImportIdx >= 0) { + lines.splice( + lastImportIdx + 1, + 0, + 'import { buildValidationErrorResponse } from "../_shared/validation-errors.ts";', + ); + src = lines.join('\n'); + } else { + errors.push(`${name}: no import block found, manual fix required`); + continue; + } + } + + if (src === original) { + totalSkipped++; + continue; + } + + writeFileSync(path, src); + totalChanged++; + console.log(`✅ ${name} — ${patternHits} site(s) migrated`); +} + +console.log(`\n--- CODEMOD SUMMARY ---`); +console.log(`Changed: ${totalChanged}`); +console.log(`Skipped: ${totalSkipped}`); +if (errors.length > 0) { + console.log(`Errors:`); + for (const e of errors) console.log(` • ${e}`); +} diff --git a/src/lib/validation-errors.ts b/src/lib/validation-errors.ts new file mode 100644 index 000000000..a18450e4b --- /dev/null +++ b/src/lib/validation-errors.ts @@ -0,0 +1,138 @@ +/** + * Node-compatible mirror of supabase/functions/_shared/validation-errors.ts + * used by: + * - frontend (TS) consumers of edge responses + * - Vitest contract tests + * + * The Edge Function source file is the canonical one; this file MUST stay in + * sync. Both files are validated together by tests/edge-functions/ + * validation-error-contract.test.ts which imports schemas from both sides. + */ + +import type { ZodError, ZodIssue } from 'zod'; + +export type ContractVersion = 'v1' | 'v2'; + +export const VALIDATION_ERROR_STATUS = 422; +export const VALIDATION_ERROR_CODE = 'validation_failed'; + +export interface FieldError { + path: string; + code: string; + message: string; +} + +export interface ValidationErrorV1 { + error: string; + details: Record | string[]; +} + +export interface ValidationErrorV2 { + code: string; + message: string; + version: 'v2'; + fields: FieldError[]; +} + +export type ValidationErrorPayload = ValidationErrorV1 | ValidationErrorV2; + +export function detectContractVersion(req: { url: string; headers: Headers }): ContractVersion { + try { + const url = new URL(req.url); + const qsVersion = url.searchParams.get('api_version') || url.searchParams.get('version'); + if (qsVersion && /^v?2$/i.test(qsVersion)) return 'v2'; + if (qsVersion && /^v?1$/i.test(qsVersion)) return 'v1'; + } catch { + /* ignore */ + } + const headerVersion = req.headers.get('x-api-version'); + if (headerVersion && /^v?2$/i.test(headerVersion)) return 'v2'; + if (headerVersion && /^v?1$/i.test(headerVersion)) return 'v1'; + + const accept = req.headers.get('accept') || ''; + if (/vnd\.promogifts\.v2\+json/i.test(accept)) return 'v2'; + + return 'v1'; +} + +export function zodIssuesToFieldErrors(error: ZodError): FieldError[] { + return error.issues.map((issue: ZodIssue) => ({ + path: issue.path.length > 0 ? issue.path.join('.') : '', + code: issue.code, + message: issue.message, + })); +} + +export function buildValidationErrorV1(error: ZodError): ValidationErrorV1 { + const fieldErrors = error.flatten().fieldErrors; + const formErrors = error.flatten().formErrors; + const hasFieldErrors = Object.keys(fieldErrors).length > 0; + return { + error: 'Validation failed', + details: hasFieldErrors ? (fieldErrors as Record) : formErrors, + }; +} + +export function buildValidationErrorV2(error: ZodError, message?: string): ValidationErrorV2 { + return { + code: VALIDATION_ERROR_CODE, + message: message ?? 'Validation failed', + version: 'v2', + fields: zodIssuesToFieldErrors(error), + }; +} + +/** + * Build a v2 payload from a manually-constructed fieldErrors dict + * (Record). Use this when business-rule validation runs + * AFTER Zod has succeeded (e.g., cross-table checks, server-side TTL caps, + * "full" scope guards) and you want a single canonical response shape. + * + * Each entry becomes a FieldError with code="business_rule". + */ +export function buildValidationErrorV2FromFields( + fields: Record, + opts: { message?: string; code?: string } = {}, +): ValidationErrorV2 { + const out: FieldError[] = []; + for (const [path, msgs] of Object.entries(fields)) { + for (const msg of msgs) { + out.push({ path, code: opts.code ?? 'business_rule', message: msg }); + } + } + return { + code: VALIDATION_ERROR_CODE, + message: opts.message ?? 'Validation failed', + version: 'v2', + fields: out, + }; +} + +export function buildValidationError( + error: ZodError, + version: ContractVersion, + message?: string, +): ValidationErrorPayload { + return version === 'v2' ? buildValidationErrorV2(error, message) : buildValidationErrorV1(error); +} + +export function isValidationErrorV2(p: unknown): p is ValidationErrorV2 { + if (!p || typeof p !== 'object') return false; + const o = p as Record; + return ( + o.code === VALIDATION_ERROR_CODE && + o.version === 'v2' && + typeof o.message === 'string' && + Array.isArray(o.fields) + ); +} + +export function isValidationErrorV1(p: unknown): p is ValidationErrorV1 { + if (!p || typeof p !== 'object') return false; + const o = p as Record; + return ( + typeof o.error === 'string' && + 'details' in o && + (typeof o.details === 'object' || Array.isArray(o.details)) + ); +} diff --git a/src/lib/webhook-schemas.ts b/src/lib/webhook-schemas.ts new file mode 100644 index 000000000..77921fd4c --- /dev/null +++ b/src/lib/webhook-schemas.ts @@ -0,0 +1,154 @@ +/** + * Canonical Zod schemas for inbound/outbound webhooks. + * + * These schemas are imported by both: + * - The Edge Functions (Deno runtime) — see product-webhook, webhook-inbound, + * webhook-dispatcher. + * - The Vitest contract tests (Node runtime), which re-validate every + * fixture and assert the unified 422 error shape. + * + * Keep this file pure: no Deno-specific globals (Deno.env, etc.) — only + * Zod schema definitions and types so it can be loaded from any runtime. + */ + +import { z } from 'zod'; + +// ============================================================================ +// product-webhook (inbound from n8n) +// ============================================================================ + +export const ProductColorSchema = z.object({ + name: z.string(), + hex: z.string(), + group: z.string().optional(), +}); + +export const KitItemSchema = z.object({ + productId: z.string(), + productName: z.string(), + quantity: z.number(), + sku: z.string(), +}); + +export const ProductPayloadSchema = z.object({ + external_id: z.string().max(255).optional(), + sku: z.string().min(1).max(100), + name: z.string().min(1).max(500), + description: z.string().max(5000).optional(), + price: z.number().nonnegative(), + min_quantity: z.number().int().positive().optional(), + category_id: z.number().int().optional(), + category_name: z.string().max(255).optional(), + subcategory: z.string().max(255).optional(), + supplier_id: z.string().max(255).optional(), + supplier_name: z.string().max(255).optional(), + stock: z.number().int().nonnegative().optional(), + stock_status: z.string().max(50).optional(), + is_kit: z.boolean().optional(), + is_active: z.boolean().optional(), + featured: z.boolean().optional(), + new_arrival: z.boolean().optional(), + on_sale: z.boolean().optional(), + images: z.array(z.string().url().max(2000)).max(50).optional(), + video_url: z.string().url().max(2000).optional().nullable(), + colors: z.array(ProductColorSchema).max(100).optional(), + materials: z.array(z.string().max(100)).max(50).optional(), + tags: z.record(z.array(z.string())).optional(), + kit_items: z.array(KitItemSchema).max(50).optional(), + variations: z.array(z.any()).max(200).optional(), + metadata: z.record(z.any()).optional(), +}); + +export const ProductWebhookPayloadSchema = z + .object({ + action: z.enum(['sync', 'upsert', 'delete', 'batch_upsert']), + products: z.array(ProductPayloadSchema).max(500).optional(), + product: ProductPayloadSchema.optional(), + external_ids: z.array(z.string().max(255)).max(500).optional(), + }) + .superRefine((val, ctx) => { + // Cross-field rules. Keeping them as soft-refine so the basic shape + // validation continues to surface all individual field errors first. + if (val.action === 'upsert' && !val.product) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['product'], + message: 'product is required for action=upsert', + }); + } + if ( + (val.action === 'sync' || val.action === 'batch_upsert') && + (!val.products || val.products.length === 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['products'], + message: 'products array is required for action=sync|batch_upsert', + }); + } + if (val.action === 'delete' && (!val.external_ids || val.external_ids.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['external_ids'], + message: 'external_ids array is required for action=delete', + }); + } + }); + +export type ProductPayload = z.infer; +export type ProductWebhookPayload = z.infer; + +// ============================================================================ +// webhook-dispatcher (outbound trigger) +// ============================================================================ + +export const DispatcherBodySchema = z + .object({ + event: z.string().min(1), + payload: z.unknown().optional(), + replay_delivery_id: z.string().uuid().optional(), + test_mode: z.boolean().optional(), + test_webhook_id: z.string().uuid().optional(), + }) + .superRefine((val, ctx) => { + if (val.test_mode && !val.test_webhook_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['test_webhook_id'], + message: 'test_webhook_id is required when test_mode=true', + }); + } + }); + +export type DispatcherBody = z.infer; + +// ============================================================================ +// webhook-inbound (incoming external HMAC-signed webhook) +// +// The body shape itself is intentionally permissive (any JSON object) because +// the function persists arbitrary 3rd-party payloads. What MUST be validated +// is the routing envelope (slug present, event header present, signature +// header well-formed). +// ============================================================================ + +export const InboundWebhookEnvelopeSchema = z.object({ + slug: z + .string() + .min(1) + .max(120) + .regex(/^[a-z0-9-]+$/, 'slug must be kebab-case'), + event_type: z.string().min(1).max(120).default('unknown'), + signature: z + .string() + .regex(/^(sha256=)?[a-f0-9]{64}$/i, 'signature must be hex sha256, optionally prefixed') + .optional(), +}); + +/** + * Optional body schema for inbound webhooks. We don't reject unknown JSON + * (we want to persist it), but we DO assert it parses to an object/array if + * a Content-Type of application/json was claimed. + */ +export const InboundWebhookBodySchema = z.unknown(); + +export type InboundWebhookEnvelope = z.infer; diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index c4f58e99e..2f34244d7 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -47,6 +47,7 @@ const ALLOWED_HEADERS_LIST = [ 'x-supabase-client-platform-version', 'x-supabase-client-runtime', 'x-supabase-client-runtime-version', + 'x-api-version', ]; const ALLOWED_HEADERS_SET = new Set(ALLOWED_HEADERS_LIST.map((h) => h.toLowerCase())); @@ -55,7 +56,7 @@ const ALLOWED_HEADERS_VALUE = ALLOWED_HEADERS_LIST.join(', '); const CORS_HEADERS_BASE = { 'Access-Control-Allow-Headers': ALLOWED_HEADERS_VALUE, 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Expose-Headers': 'x-request-id', + 'Access-Control-Expose-Headers': 'x-request-id, x-api-version', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', diff --git a/supabase/functions/_shared/validation-errors.ts b/supabase/functions/_shared/validation-errors.ts new file mode 100644 index 000000000..dabbda9e4 --- /dev/null +++ b/supabase/functions/_shared/validation-errors.ts @@ -0,0 +1,208 @@ +/** + * Unified validation error response builder. + * + * Provides a single canonical error format for 422 Unprocessable Entity + * responses across every Edge Function and webhook. Supports two contract + * versions: + * + * v1 (legacy, default for backwards compatibility): + * { "error": "Validation failed", "details": { "field": ["msg", ...] } } + * + * v2 (canonical, recommended): + * { + * "code": "validation_failed", + * "message": "Validation failed", + * "version": "v2", + * "fields": [{ "path": "a.b", "code": "invalid_type", "message": "..." }] + * } + * + * Version negotiation order (first match wins): + * 1. ?api_version=v2 query string + * 2. X-API-Version: v2 header + * 3. Accept: application/vnd.promogifts.v2+json + * 4. Default: v1 + * + * Both shapes always carry the same semantic content; the v2 shape is a + * superset so clients can migrate at their own pace without breaking + * existing integrations (n8n, Bitrix, internal jobs). + */ + +import type { ZodError, ZodIssue } from "https://esm.sh/zod@3.23.8"; + +export type ContractVersion = "v1" | "v2"; + +export const VALIDATION_ERROR_STATUS = 422; +export const VALIDATION_ERROR_CODE = "validation_failed"; + +export interface FieldError { + /** Dotted path to the offending field, e.g. "product.images.0" */ + path: string; + /** Stable machine-readable code (Zod issue code or custom) */ + code: string; + /** Human-readable message (PT-BR or EN, depending on schema) */ + message: string; +} + +export interface ValidationErrorV1 { + error: string; + details: Record | string[]; +} + +export interface ValidationErrorV2 { + code: string; + message: string; + version: "v2"; + fields: FieldError[]; +} + +export type ValidationErrorPayload = ValidationErrorV1 | ValidationErrorV2; + +/** + * Detect the contract version requested by the client. Defaults to v1 to + * preserve compatibility with existing callers that have not been updated. + */ +export function detectContractVersion(req: Request): ContractVersion { + try { + const url = new URL(req.url); + const qsVersion = url.searchParams.get("api_version") || url.searchParams.get("version"); + if (qsVersion && /^v?2$/i.test(qsVersion)) return "v2"; + if (qsVersion && /^v?1$/i.test(qsVersion)) return "v1"; + } catch { + /* ignore malformed URL */ + } + const headerVersion = req.headers.get("x-api-version") || req.headers.get("X-Api-Version"); + if (headerVersion && /^v?2$/i.test(headerVersion)) return "v2"; + if (headerVersion && /^v?1$/i.test(headerVersion)) return "v1"; + + const accept = req.headers.get("accept") || ""; + if (/vnd\.promogifts\.v2\+json/i.test(accept)) return "v2"; + + return "v1"; +} + +/** Flatten a ZodError into [{path, code, message}, ...] for v2 responses. */ +export function zodIssuesToFieldErrors(error: ZodError): FieldError[] { + return error.issues.map((issue: ZodIssue) => ({ + path: issue.path.length > 0 ? issue.path.join(".") : "", + code: issue.code, + message: issue.message, + })); +} + +/** Build the v1 (legacy) shape: { error, details: { field: [msg...] } } */ +export function buildValidationErrorV1(error: ZodError): ValidationErrorV1 { + const fieldErrors = error.flatten().fieldErrors; + const formErrors = error.flatten().formErrors; + const hasFieldErrors = Object.keys(fieldErrors).length > 0; + return { + error: "Validation failed", + details: hasFieldErrors + ? (fieldErrors as Record) + : formErrors, + }; +} + +/** Build the v2 (canonical) shape: { code, message, version, fields[] } */ +export function buildValidationErrorV2(error: ZodError, message?: string): ValidationErrorV2 { + return { + code: VALIDATION_ERROR_CODE, + message: message ?? "Validation failed", + version: "v2", + fields: zodIssuesToFieldErrors(error), + }; +} + +/** + * Build a v2 payload from a manually-constructed fieldErrors dict + * (Record). Use this when business-rule validation runs + * AFTER Zod has succeeded (e.g., cross-table checks, server-side TTL caps, + * "full" scope guards) and you want a single canonical response shape. + * + * Each entry becomes a FieldError with code="business_rule". + */ +export function buildValidationErrorV2FromFields( + fields: Record, + opts: { message?: string; code?: string } = {}, +): ValidationErrorV2 { + const out: FieldError[] = []; + for (const [path, msgs] of Object.entries(fields)) { + for (const msg of msgs) { + out.push({ path, code: opts.code ?? "business_rule", message: msg }); + } + } + return { + code: VALIDATION_ERROR_CODE, + message: opts.message ?? "Validation failed", + version: "v2", + fields: out, + }; +} + +/** Build either v1 or v2 according to the negotiated contract version. */ +export function buildValidationError( + error: ZodError, + version: ContractVersion, + message?: string, +): ValidationErrorPayload { + return version === "v2" ? buildValidationErrorV2(error, message) : buildValidationErrorV1(error); +} + +/** Build a complete 422 Response from a ZodError + the original Request. */ +export function buildValidationErrorResponse( + error: ZodError, + req: Request, + corsHeaders: Record, + opts: { message?: string; status?: number } = {}, +): Response { + const version = detectContractVersion(req); + const body = buildValidationError(error, version, opts.message); + return new Response(JSON.stringify(body), { + status: opts.status ?? VALIDATION_ERROR_STATUS, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "X-API-Version": version, + }, + }); +} + +/** Generic non-Zod error helpers — keep the same shape for symmetry. */ +export function buildGenericError( + code: string, + message: string, + version: ContractVersion, + fields: FieldError[] = [], +): ValidationErrorPayload { + if (version === "v2") { + return { code, message, version: "v2", fields }; + } + return { + error: message, + details: fields.length > 0 + ? fields.reduce>((acc, f) => { + const k = f.path || "_form"; + (acc[k] = acc[k] || []).push(f.message); + return acc; + }, {}) + : [], + }; +} + +export function buildErrorResponse( + code: string, + message: string, + req: Request, + corsHeaders: Record, + opts: { status?: number; fields?: FieldError[] } = {}, +): Response { + const version = detectContractVersion(req); + const body = buildGenericError(code, message, version, opts.fields ?? []); + return new Response(JSON.stringify(body), { + status: opts.status ?? 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "X-API-Version": version, + }, + }); +} diff --git a/supabase/functions/_shared/webhook-schemas.ts b/supabase/functions/_shared/webhook-schemas.ts new file mode 100644 index 000000000..59283547f --- /dev/null +++ b/supabase/functions/_shared/webhook-schemas.ts @@ -0,0 +1,140 @@ +/** + * Canonical Zod schemas for inbound/outbound webhooks. + * + * These schemas are imported by both: + * - The Edge Functions (Deno runtime) — see product-webhook, webhook-inbound, + * webhook-dispatcher. + * - The Vitest contract tests (Node runtime), which re-validate every + * fixture and assert the unified 422 error shape. + * + * Keep this file pure: no Deno-specific globals (Deno.env, etc.) — only + * Zod schema definitions and types so it can be loaded from any runtime. + */ + +import { z } from "https://esm.sh/zod@3.23.8"; + +// ============================================================================ +// product-webhook (inbound from n8n) +// ============================================================================ + +export const ProductColorSchema = z.object({ + name: z.string(), + hex: z.string(), + group: z.string().optional(), +}); + +export const KitItemSchema = z.object({ + productId: z.string(), + productName: z.string(), + quantity: z.number(), + sku: z.string(), +}); + +export const ProductPayloadSchema = z.object({ + external_id: z.string().max(255).optional(), + sku: z.string().min(1).max(100), + name: z.string().min(1).max(500), + description: z.string().max(5000).optional(), + price: z.number().nonnegative(), + min_quantity: z.number().int().positive().optional(), + category_id: z.number().int().optional(), + category_name: z.string().max(255).optional(), + subcategory: z.string().max(255).optional(), + supplier_id: z.string().max(255).optional(), + supplier_name: z.string().max(255).optional(), + stock: z.number().int().nonnegative().optional(), + stock_status: z.string().max(50).optional(), + is_kit: z.boolean().optional(), + is_active: z.boolean().optional(), + featured: z.boolean().optional(), + new_arrival: z.boolean().optional(), + on_sale: z.boolean().optional(), + images: z.array(z.string().url().max(2000)).max(50).optional(), + video_url: z.string().url().max(2000).optional().nullable(), + colors: z.array(ProductColorSchema).max(100).optional(), + materials: z.array(z.string().max(100)).max(50).optional(), + tags: z.record(z.array(z.string())).optional(), + kit_items: z.array(KitItemSchema).max(50).optional(), + variations: z.array(z.any()).max(200).optional(), + metadata: z.record(z.any()).optional(), +}); + +export const ProductWebhookPayloadSchema = z.object({ + action: z.enum(["sync", "upsert", "delete", "batch_upsert"]), + products: z.array(ProductPayloadSchema).max(500).optional(), + product: ProductPayloadSchema.optional(), + external_ids: z.array(z.string().max(255)).max(500).optional(), +}).superRefine((val, ctx) => { + // Cross-field rules. Keeping them as soft-refine so the basic shape + // validation continues to surface all individual field errors first. + if (val.action === "upsert" && !val.product) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["product"], + message: "product is required for action=upsert", + }); + } + if ((val.action === "sync" || val.action === "batch_upsert") && (!val.products || val.products.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["products"], + message: "products array is required for action=sync|batch_upsert", + }); + } + if (val.action === "delete" && (!val.external_ids || val.external_ids.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["external_ids"], + message: "external_ids array is required for action=delete", + }); + } +}); + +export type ProductPayload = z.infer; +export type ProductWebhookPayload = z.infer; + +// ============================================================================ +// webhook-dispatcher (outbound trigger) +// ============================================================================ + +export const DispatcherBodySchema = z.object({ + event: z.string().min(1), + payload: z.unknown().optional(), + replay_delivery_id: z.string().uuid().optional(), + test_mode: z.boolean().optional(), + test_webhook_id: z.string().uuid().optional(), +}).superRefine((val, ctx) => { + if (val.test_mode && !val.test_webhook_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["test_webhook_id"], + message: "test_webhook_id is required when test_mode=true", + }); + } +}); + +export type DispatcherBody = z.infer; + +// ============================================================================ +// webhook-inbound (incoming external HMAC-signed webhook) +// +// The body shape itself is intentionally permissive (any JSON object) because +// the function persists arbitrary 3rd-party payloads. What MUST be validated +// is the routing envelope (slug present, event header present, signature +// header well-formed). +// ============================================================================ + +export const InboundWebhookEnvelopeSchema = z.object({ + slug: z.string().min(1).max(120).regex(/^[a-z0-9-]+$/, "slug must be kebab-case"), + event_type: z.string().min(1).max(120).default("unknown"), + signature: z.string().regex(/^(sha256=)?[a-f0-9]{64}$/i, "signature must be hex sha256, optionally prefixed").optional(), +}); + +/** + * Optional body schema for inbound webhooks. We don't reject unknown JSON + * (we want to persist it), but we DO assert it parses to an object/array if + * a Content-Type of application/json was claimed. + */ +export const InboundWebhookBodySchema = z.unknown(); + +export type InboundWebhookEnvelope = z.infer; diff --git a/supabase/functions/_shared/zod-validate.ts b/supabase/functions/_shared/zod-validate.ts index aa5d40f6e..89dbf84c8 100644 --- a/supabase/functions/_shared/zod-validate.ts +++ b/supabase/functions/_shared/zod-validate.ts @@ -1,67 +1,101 @@ /** * Shared Zod validation utilities for edge functions. * Provides type-safe request body parsing with clear error messages. + * + * Error responses go through the unified builder in `./validation-errors.ts`, + * so every Edge Function returns the same shape: + * - v1 (default): { error, details } + * - v2 (negotiated): { code, message, version, fields[] } + * + * Status code for schema-level failures is 422 Unprocessable Entity. + * Status code for malformed / empty JSON body is 400 Bad Request. */ // Using Zod from esm.sh for Deno compatibility export { z } from "https://esm.sh/zod@3.23.8"; import { z } from "https://esm.sh/zod@3.23.8"; +import { + buildErrorResponse, + buildValidationErrorResponse, + detectContractVersion, +} from "./validation-errors.ts"; + /** * Parse and validate a request body against a Zod schema. - * Returns parsed data on success, or a 400 Response on failure. + * Returns parsed data on success, or a Response with the unified error shape + * on failure (400 for malformed JSON, 422 for schema validation). */ export async function parseBodyWithSchema( req: Request, schema: T, - corsHeaders: Record + corsHeaders: Record, ): Promise<{ data: z.infer } | { error: Response }> { let rawBody: unknown; try { const text = await req.text(); - if (!text || text.trim() === '') { + if (!text || text.trim() === "") { return { - error: new Response( - JSON.stringify({ error: 'Request body is required' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + error: buildErrorResponse( + "empty_body", + "Request body is required", + req, + corsHeaders, + { status: 400 }, ), }; } rawBody = JSON.parse(text); } catch { return { - error: new Response( - JSON.stringify({ error: 'Invalid JSON in request body' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + error: buildErrorResponse( + "invalid_json", + "Invalid JSON in request body", + req, + corsHeaders, + { status: 400 }, ), }; } const result = schema.safeParse(rawBody); if (!result.success) { - const fieldErrors = result.error.flatten().fieldErrors; - const formErrors = result.error.flatten().formErrors; return { - error: new Response( - JSON.stringify({ - error: 'Validation failed', - details: Object.keys(fieldErrors).length > 0 ? fieldErrors : formErrors, - }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ), + error: buildValidationErrorResponse(result.error, req, corsHeaders), }; } return { data: result.data }; } +/** + * Variant that accepts a parsed object (e.g., already-parsed JSON or query + * params). Useful for GET endpoints validating query strings. + */ +export function parseObjectWithSchema( + obj: unknown, + schema: T, + req: Request, + corsHeaders: Record, +): { data: z.infer } | { error: Response } { + const result = schema.safeParse(obj); + if (!result.success) { + return { error: buildValidationErrorResponse(result.error, req, corsHeaders) }; + } + return { data: result.data }; +} + +// Re-export for convenience +export { detectContractVersion }; +export type { ContractVersion } from "./validation-errors.ts"; + // ========== Common reusable schemas ========== /** UUID v4 string */ export const uuidSchema = z.string().uuid(); /** Non-empty trimmed string */ -export const nonEmptyString = z.string().trim().min(1, 'Cannot be empty'); +export const nonEmptyString = z.string().trim().min(1, "Cannot be empty"); /** Positive integer */ export const positiveInt = z.number().int().positive(); @@ -73,7 +107,7 @@ export const nonNegativeNumber = z.number().nonnegative(); export const emailSchema = z.string().email().max(255); /** Token (hex string, 64 chars) */ -export const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/, 'Invalid token format'); +export const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/, "Invalid token format"); /** Base64 or URL image */ export const imageInputSchema = z.string().min(10).max(10_000_000); diff --git a/supabase/functions/ai-recommendations/index.ts b/supabase/functions/ai-recommendations/index.ts index 3be103e3a..0f0b7a53c 100644 --- a/supabase/functions/ai-recommendations/index.ts +++ b/supabase/functions/ai-recommendations/index.ts @@ -6,6 +6,7 @@ import { z } from '../_shared/zod-validate.ts'; import { rateLimiters, applyRateLimit } from '../_shared/rate-limiter.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; import { extractAndParseAIJSON, safeJson } from '../_shared/json-parser.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const ClientSchema = z.object({ name: z.string().trim().min(1).max(255), @@ -71,9 +72,7 @@ Deno.serve(async (req) => { const parsed = RecommendationRequestSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { client, products } = parsed.data; diff --git a/supabase/functions/analyze-logo-colors/index.ts b/supabase/functions/analyze-logo-colors/index.ts index 688afb853..f77f3348e 100644 --- a/supabase/functions/analyze-logo-colors/index.ts +++ b/supabase/functions/analyze-logo-colors/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from '../_shared/zod-validate.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; Deno.serve(async (req) => { const corsHeaders = getCorsHeaders(req); @@ -36,9 +37,7 @@ Deno.serve(async (req) => { } const parsed = LogoSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: parsed.error.issues[0]?.message || "Invalid input" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { imageBase64 } = parsed.data; diff --git a/supabase/functions/bitrix-sync/index.ts b/supabase/functions/bitrix-sync/index.ts index a7e21f633..f71acd651 100644 --- a/supabase/functions/bitrix-sync/index.ts +++ b/supabase/functions/bitrix-sync/index.ts @@ -3,6 +3,7 @@ import { authorize } from '../_shared/authorize.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "npm:zod@3.23.8"; import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from "../_shared/external-fetch.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BitrixSyncSchema = z.object({ action: z.enum([ @@ -44,10 +45,7 @@ Deno.serve(async (req) => { const parsed = BitrixSyncSchema.safeParse(await req.json()); if (!parsed.success) { - return new Response( - JSON.stringify({ error: 'Invalid request', details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { action, data } = parsed.data; // Helper para extrair número de `data?.` (Zod tipa data como diff --git a/supabase/functions/categories-api/index.ts b/supabase/functions/categories-api/index.ts index c4326500a..0221b67f8 100644 --- a/supabase/functions/categories-api/index.ts +++ b/supabase/functions/categories-api/index.ts @@ -2,6 +2,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { authenticateRequest, requireRole, authErrorResponse } from '../_shared/auth.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from '../_shared/zod-validate.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const CategoriesRequestSchema = z.object({ action: z.enum(['tree', 'all', 'descendants', 'products_by_categories']), @@ -36,9 +37,7 @@ Deno.serve(async (req) => { const rawBody = await req.json().catch(() => ({})); const parsed = CategoriesRequestSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { action, categoryIds, includeDescendants } = parsed.data; diff --git a/supabase/functions/cnpj-lookup/index.ts b/supabase/functions/cnpj-lookup/index.ts index 439156133..84f0924ad 100644 --- a/supabase/functions/cnpj-lookup/index.ts +++ b/supabase/functions/cnpj-lookup/index.ts @@ -2,6 +2,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { z } from "npm:zod@3.23.8"; import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from "../_shared/external-fetch.ts"; import { authenticateRequest, authErrorResponse } from "../_shared/auth.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const CnpjBodySchema = z.object({ cnpj: z.string().min(1, "CNPJ é obrigatório").transform(v => v.replace(/\D/g, "")).refine(v => v.length === 14, "CNPJ deve ter 14 dígitos"), @@ -27,10 +28,7 @@ Deno.serve(async (req) => { const parsed = CnpjBodySchema.safeParse(await req.json()); if (!parsed.success) { - return new Response(JSON.stringify({ error: parsed.error.flatten().fieldErrors }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const cnpjDigits = parsed.data.cnpj; diff --git a/supabase/functions/comparison-ai-advisor/index.ts b/supabase/functions/comparison-ai-advisor/index.ts index 965a24fe9..855fb57c4 100644 --- a/supabase/functions/comparison-ai-advisor/index.ts +++ b/supabase/functions/comparison-ai-advisor/index.ts @@ -5,6 +5,7 @@ import { authenticateRequest, requireRole, authErrorResponse } from "../_shared/ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; // Fallback CORS headers — sobrescritos per-request via getCorsHeaders(req). let corsHeaders: Record = buildPublicCorsHeaders(); @@ -71,10 +72,7 @@ serve(async (req) => { const json = await req.json().catch(() => ({})); const parsed = BodySchema.safeParse(json); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "invalid_input", details: parsed.error.flatten() }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); diff --git a/supabase/functions/crm-db-bridge/index.ts b/supabase/functions/crm-db-bridge/index.ts index ced4bbbd3..7bd056412 100644 --- a/supabase/functions/crm-db-bridge/index.ts +++ b/supabase/functions/crm-db-bridge/index.ts @@ -6,6 +6,7 @@ import { getBreaker, circuitOpenResponse, getAllBreakerStatuses } from '../_shar import { AsyncLocalStorage } from "node:async_hooks"; import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.ts"; import { resolveCredential, buildCredentialsHealth } from "../_shared/credentials.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const breaker = getBreaker("crm-db"); @@ -932,7 +933,7 @@ Deno.serve((req) => { const parsed = CrmRequestSchema.safeParse(rawBody); if (!parsed.success) { - return jsonResponse({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }, 400); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const body = parsed.data as CrmQuery; diff --git a/supabase/functions/detect-new-device/index.ts b/supabase/functions/detect-new-device/index.ts index 2142b41a1..c9bfcb7b4 100644 --- a/supabase/functions/detect-new-device/index.ts +++ b/supabase/functions/detect-new-device/index.ts @@ -1,6 +1,7 @@ import { getCorsHeaders, handleCorsPreflightIfNeeded } from '../_shared/cors.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "npm:zod@3.23.8"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const DeviceInfoSchema = z.object({ fingerprint: z.string().min(1).max(256), @@ -41,7 +42,7 @@ Deno.serve(async (req: Request): Promise => { const rawBody = await req.json(); const parsed = BodySchema.safeParse(rawBody); if (!parsed.success) { - return jsonRes(corsHeaders, { error: "Invalid input", details: parsed.error.flatten().fieldErrors }, 400); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { userId, userEmail, deviceInfo } = parsed.data; diff --git a/supabase/functions/dropbox-list/index.ts b/supabase/functions/dropbox-list/index.ts index e595bd8ba..6c5d4dec4 100644 --- a/supabase/functions/dropbox-list/index.ts +++ b/supabase/functions/dropbox-list/index.ts @@ -2,6 +2,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { authenticateRequest, requireRole, authErrorResponse } from '../_shared/auth.ts'; import { z } from "https://esm.sh/zod@3.23.8"; import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from '../_shared/external-fetch.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BodySchema = z.object({ path: z.string().max(1000).default(''), @@ -29,10 +30,7 @@ Deno.serve(async (req) => { const raw = await req.json(); const parsed = BodySchema.safeParse(raw); if (!parsed.success) { - return new Response( - JSON.stringify({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } body = parsed.data; } catch { diff --git a/supabase/functions/elevenlabs-tts/index.ts b/supabase/functions/elevenlabs-tts/index.ts index 3cd36b259..086c63ccd 100644 --- a/supabase/functions/elevenlabs-tts/index.ts +++ b/supabase/functions/elevenlabs-tts/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { z } from 'https://deno.land/x/zod@v3.22.4/mod.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from '../_shared/external-fetch.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const VALID_VOICE_IDS = [ '5lrBPYY4YvMbKHTo8kvZ', // Chosen voice (default) @@ -61,10 +62,7 @@ Deno.serve(async (req) => { const parsed = TtsRequestSchema.safeParse(body); if (!parsed.success) { - return new Response( - JSON.stringify({ error: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { text, voiceId } = parsed.data; diff --git a/supabase/functions/expert-chat/index.ts b/supabase/functions/expert-chat/index.ts index 1acc36277..5654f153a 100644 --- a/supabase/functions/expert-chat/index.ts +++ b/supabase/functions/expert-chat/index.ts @@ -7,6 +7,7 @@ import { rateLimiters, applyRateLimit } from '../_shared/rate-limiter.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; import { resolveCredential } from '../_shared/credentials.ts'; import { extractAndParseAIJSON, safeJson } from '../_shared/json-parser.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; // ============================================ // SCHEMAS @@ -551,10 +552,7 @@ Deno.serve(async (req) => { const parsed = ExpertChatBodySchema.safeParse(rawBody); if (!parsed.success) { console.error("Validation errors:", JSON.stringify(parsed.error.flatten())); - return new Response( - JSON.stringify({ error: "Dados inválidos", details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { messages, clientId, diff --git a/supabase/functions/external-db-bridge/index.ts b/supabase/functions/external-db-bridge/index.ts index bd74c434e..7db6ebb5e 100644 --- a/supabase/functions/external-db-bridge/index.ts +++ b/supabase/functions/external-db-bridge/index.ts @@ -32,6 +32,7 @@ import { retrySupabaseCall } from "../_shared/retry-backoff.ts"; import { AsyncLocalStorage } from "node:async_hooks"; import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.ts"; import { resolveCredential } from "../_shared/credentials.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const breaker = getBreaker("external-db"); @@ -453,7 +454,7 @@ Deno.serve((req) => { const parsed = TopLevelBodySchema.safeParse(rawBody); if (!parsed.success) { - return jsonResponse({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, 400, corsHeaders); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const body = parsed.data as Record; diff --git a/supabase/functions/external-db-inspect/index.ts b/supabase/functions/external-db-inspect/index.ts index 2bb313f9e..4625f261a 100644 --- a/supabase/functions/external-db-inspect/index.ts +++ b/supabase/functions/external-db-inspect/index.ts @@ -2,6 +2,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "https://esm.sh/zod@3.23.8"; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BodySchema = z.object({ mode: z.enum(['tables', 'columns']).default('tables'), @@ -68,7 +69,7 @@ Deno.serve(async (req) => { const parsed = BodySchema.safeParse(rawBody); if (!parsed.success) { - return jsonResponse({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, 400); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { mode, tableName } = parsed.data; diff --git a/supabase/functions/full-op-diagnostics/index.ts b/supabase/functions/full-op-diagnostics/index.ts index 7e8158e41..fb4cc3e73 100644 --- a/supabase/functions/full-op-diagnostics/index.ts +++ b/supabase/functions/full-op-diagnostics/index.ts @@ -17,6 +17,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; import { z } from "https://esm.sh/zod@3.23.8"; import { getCorsHeaders, handleCorsPreflightIfNeeded } from "../_shared/cors.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; type CheckStatus = "pass" | "fail" | "skipped" | "error"; @@ -85,7 +86,7 @@ Deno.serve(async (req: Request) => { try { raw = await req.json(); } catch { raw = {}; } const parsed = BodySchema.safeParse(raw ?? {}); if (!parsed.success) { - return jsonResponse({ error: "validation_failed", fields: parsed.error.flatten().fieldErrors }, 422, req); + return buildValidationErrorResponse(parsed.error, req, getCorsHeaders(req)); } body = parsed.data; } diff --git a/supabase/functions/generate-ad-image/index.ts b/supabase/functions/generate-ad-image/index.ts index c36157a70..e6c8f7629 100644 --- a/supabase/functions/generate-ad-image/index.ts +++ b/supabase/functions/generate-ad-image/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BodySchema = z.object({ productImageUrl: z.string().url(), @@ -79,10 +80,7 @@ Deno.serve(async (req) => { const parsed = BodySchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { diff --git a/supabase/functions/generate-ad-prompt/index.ts b/supabase/functions/generate-ad-prompt/index.ts index dc02e64e9..40d6ec7f2 100644 --- a/supabase/functions/generate-ad-prompt/index.ts +++ b/supabase/functions/generate-ad-prompt/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from '../_shared/zod-validate.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; Deno.serve(async (req) => { const corsHeaders = getCorsHeaders(req); @@ -56,10 +57,7 @@ Deno.serve(async (req) => { const parsed = AdPromptSchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { diff --git a/supabase/functions/generate-mockup/index.ts b/supabase/functions/generate-mockup/index.ts index bacb00fbf..450bdcba5 100644 --- a/supabase/functions/generate-mockup/index.ts +++ b/supabase/functions/generate-mockup/index.ts @@ -6,6 +6,7 @@ import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; import { safeJson } from '../_shared/json-parser.ts'; import { assertAllowedExternalUrl, ExternalUrlError } from '../_shared/url-allowlist.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const MockupBodySchema = z.object({ productImageUrl: z.string().url().max(2000), @@ -57,10 +58,7 @@ Deno.serve(async (req) => { } const parsed = MockupBodySchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "Invalid input", details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { diff --git a/supabase/functions/generate-product-seo/index.ts b/supabase/functions/generate-product-seo/index.ts index a9082c5a1..3b38b6888 100644 --- a/supabase/functions/generate-product-seo/index.ts +++ b/supabase/functions/generate-product-seo/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from '../_shared/zod-validate.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; Deno.serve(async (req) => { const corsHeaders = getCorsHeaders(req); @@ -44,9 +45,7 @@ Deno.serve(async (req) => { } const parsed = ProductSeoSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { product } = parsed.data; diff --git a/supabase/functions/kit-identity-suggest/index.ts b/supabase/functions/kit-identity-suggest/index.ts index 56ecb4ced..848a39f5c 100644 --- a/supabase/functions/kit-identity-suggest/index.ts +++ b/supabase/functions/kit-identity-suggest/index.ts @@ -8,6 +8,7 @@ import { getCorsHeaders } from "../_shared/cors.ts"; // ============================================================ import { z } from '../_shared/zod-validate.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const PALETTE = [ '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', @@ -52,18 +53,7 @@ Deno.serve(async (req: Request) => { const raw = await req.json().catch(() => ({})); const parsed = BodySchema.safeParse(raw); if (!parsed.success) { - return new Response( - JSON.stringify({ error: 'Parâmetros inválidos', details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }, - ); - } - - const name = (parsed.data.name ?? '').trim(); - const items = parsed.data.items ?? []; - if (!name && items.length === 0) { - return new Response(JSON.stringify({ error: 'Forneça name ou items' }), { - status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const LOVABLE_API_KEY = Deno.env.get('LOVABLE_API_KEY'); diff --git a/supabase/functions/log-login-attempt/index.ts b/supabase/functions/log-login-attempt/index.ts index 24314d01c..a069f7105 100644 --- a/supabase/functions/log-login-attempt/index.ts +++ b/supabase/functions/log-login-attempt/index.ts @@ -2,6 +2,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { getCorsHeaders, handleCorsPreflightIfNeeded } from "../_shared/cors.ts"; import { RateLimiter, applyRateLimit } from "../_shared/rate-limiter.ts"; import { z } from "npm:zod@3.23.8"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const LoginAttemptSchema = z.object({ email: z.string().email().max(255), @@ -39,10 +40,7 @@ Deno.serve(async (req) => { const parsed = LoginAttemptSchema.safeParse(await req.json()); if (!parsed.success) { - return new Response( - JSON.stringify({ error: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { email, user_id, ip_address, success, failure_reason, user_agent } = parsed.data; diff --git a/supabase/functions/magic-up-score/index.ts b/supabase/functions/magic-up-score/index.ts index e04cf4b00..3028ededf 100644 --- a/supabase/functions/magic-up-score/index.ts +++ b/supabase/functions/magic-up-score/index.ts @@ -3,6 +3,7 @@ import { authenticateRequest, authErrorResponse } from '../_shared/auth.ts'; import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const CriterionSchema = z.object({ id: z.string().min(1), @@ -60,7 +61,7 @@ Deno.serve(async (req) => { const parsed = BodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { - return new Response(JSON.stringify({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const LOVABLE_API_KEY = Deno.env.get('LOVABLE_API_KEY'); diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts index 3638368ed..d55fcb6b6 100644 --- a/supabase/functions/manage-users/index.ts +++ b/supabase/functions/manage-users/index.ts @@ -3,6 +3,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "npm:zod@3.23.8"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; import { safeJson } from "../_shared/json-parser.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const uuidSchema = z.string().uuid(); const emailSchema = z.string().email().max(255); @@ -105,7 +106,7 @@ Deno.serve(async (req) => { const rawBody = await safeJson(req); const parsed = PayloadSchema.safeParse(rawBody); if (!parsed.success) { - return jsonRes(corsHeaders, { error: 'Dados inválidos', details: parsed.error.flatten().fieldErrors }, 400); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const payload = parsed.data; diff --git a/supabase/functions/materials-api/index.ts b/supabase/functions/materials-api/index.ts index fc2a79005..0ad8a3bd0 100644 --- a/supabase/functions/materials-api/index.ts +++ b/supabase/functions/materials-api/index.ts @@ -1,6 +1,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from '../_shared/zod-validate.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const MaterialsRequestSchema = z.object({ action: z.enum(['groups', 'types', 'types_by_group', 'product_materials', 'products_by_materials', 'stats', 'search', 'complete']), @@ -38,9 +39,7 @@ Deno.serve(async (req) => { const rawBody = await req.json().catch(() => ({})); const parsed = MaterialsRequestSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { action, groupId, materialId, productId, materialTypeIds, materialGroupSlugs, limit } = parsed.data; diff --git a/supabase/functions/mcp-keys-issue/index.ts b/supabase/functions/mcp-keys-issue/index.ts index efef61b00..18f89c85b 100644 --- a/supabase/functions/mcp-keys-issue/index.ts +++ b/supabase/functions/mcp-keys-issue/index.ts @@ -29,6 +29,7 @@ import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.t import { writeAuditEntry, summarizePayload, extractRequestMeta } from "../_shared/audit-log.ts"; import { recordMcpViolation, mapViolationReason } from "../_shared/mcp-violations.ts"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { buildValidationErrorV2 } from "../_shared/validation-errors.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; @@ -231,7 +232,7 @@ Deno.serve(async (req) => { if (!parsed.success) { const fields = parsed.error.flatten().fieldErrors; await auditFailure("denied", "mcp_key.issue_denied", { reason: "validation_failed", fields }); - return jsonResponse({ error: "validation_failed", fields }, 422, requestId); + return jsonResponse(buildValidationErrorV2(parsed.error), 422, requestId); } const { name, scopes, expires_at, justification, step_up_token, target_repo, target_tool } = parsed.data; const full = isFullAccess(scopes); diff --git a/supabase/functions/mcp-keys-revoke/index.ts b/supabase/functions/mcp-keys-revoke/index.ts index 58e5016d8..3cf7e5d86 100644 --- a/supabase/functions/mcp-keys-revoke/index.ts +++ b/supabase/functions/mcp-keys-revoke/index.ts @@ -11,6 +11,7 @@ import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.t import { writeAuditEntry, summarizePayload, extractRequestMeta } from "../_shared/audit-log.ts"; import { recordMcpViolation, mapViolationReason } from "../_shared/mcp-violations.ts"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { buildValidationErrorV2 } from "../_shared/validation-errors.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; @@ -126,7 +127,7 @@ Deno.serve(async (req) => { if (!parsed.success) { const fields = parsed.error.flatten().fieldErrors; await auditFailure("denied", "mcp_key.revoke_denied", { reason: "validation_failed", fields }); - return jsonResponse({ error: "validation_failed", fields }, 422, requestId); + return jsonResponse(buildValidationErrorV2(parsed.error), 422, requestId); } const { key_id, reason, step_up_token } = parsed.data; diff --git a/supabase/functions/mcp-keys-rotate/index.ts b/supabase/functions/mcp-keys-rotate/index.ts index 3c7d4a548..ab3f87a68 100644 --- a/supabase/functions/mcp-keys-rotate/index.ts +++ b/supabase/functions/mcp-keys-rotate/index.ts @@ -18,6 +18,7 @@ import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.t import { writeAuditEntry, summarizePayload, extractRequestMeta } from "../_shared/audit-log.ts"; import { recordMcpViolation, mapViolationReason } from "../_shared/mcp-violations.ts"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { buildValidationErrorV2, buildValidationErrorV2FromFields } from "../_shared/validation-errors.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; @@ -143,7 +144,7 @@ Deno.serve(async (req) => { if (!parsed.success) { const fields = parsed.error.flatten().fieldErrors; await auditFailure("denied", { reason: "validation_failed", fields }); - return jsonResponse({ error: "validation_failed", fields }, 422, requestId); + return jsonResponse(buildValidationErrorV2(parsed.error), 422, requestId); } const { source_key_id, justification, confirmation_phrase, step_up_token } = parsed.data; @@ -227,7 +228,11 @@ Deno.serve(async (req) => { } if (Object.keys(fieldErrors).length > 0) { await auditFailure("denied", { reason: "full_friction_failed", fields: fieldErrors }, source_key_id); - return jsonResponse({ error: "validation_failed", fields: fieldErrors }, 422, requestId); + return jsonResponse( + buildValidationErrorV2FromFields(fieldErrors, { code: "full_friction_failed" }), + 422, + requestId, + ); } } diff --git a/supabase/functions/mcp-keys-update/index.ts b/supabase/functions/mcp-keys-update/index.ts index db98b1bc5..42d2dbaff 100644 --- a/supabase/functions/mcp-keys-update/index.ts +++ b/supabase/functions/mcp-keys-update/index.ts @@ -18,6 +18,7 @@ import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.t import { writeAuditEntry, summarizePayload, extractRequestMeta } from "../_shared/audit-log.ts"; import { recordMcpViolation, mapViolationReason } from "../_shared/mcp-violations.ts"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; +import { buildValidationErrorV2, buildValidationErrorV2FromFields } from "../_shared/validation-errors.ts"; // Module-scope CORS headers — atribuído per-request no handler. let corsHeaders: Record = {}; @@ -141,7 +142,7 @@ Deno.serve(async (req) => { if (!parsed.success) { const fields = parsed.error.flatten().fieldErrors; await auditFailure("denied", { reason: "validation_failed", fields }); - return jsonResponse({ error: "validation_failed", fields }, 422, requestId); + return jsonResponse(buildValidationErrorV2(parsed.error), 422, requestId); } const { key_id, name, description, scopes, expires_at, justification, confirmation_phrase, step_up_token } = parsed.data; @@ -230,7 +231,11 @@ Deno.serve(async (req) => { } if (Object.keys(fieldErrors).length > 0) { await auditFailure("denied", { reason: "full_escalation_blocked", fields: fieldErrors }, key_id); - return jsonResponse({ error: "validation_failed", fields: fieldErrors }, 422, requestId); + return jsonResponse( + buildValidationErrorV2FromFields(fieldErrors, { code: "full_escalation_blocked" }), + 422, + requestId, + ); } } diff --git a/supabase/functions/product-webhook/index.ts b/supabase/functions/product-webhook/index.ts index a2d37538d..0499bd0ef 100644 --- a/supabase/functions/product-webhook/index.ts +++ b/supabase/functions/product-webhook/index.ts @@ -1,60 +1,20 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; -import { z } from "../_shared/zod-validate.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; +import { + ProductWebhookPayloadSchema, + type ProductPayload, +} from "../_shared/webhook-schemas.ts"; +import { + buildErrorResponse, + buildValidationErrorResponse, +} from "../_shared/validation-errors.ts"; -const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret"] }); +const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret", "x-api-version"] }); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const webhookSecret = Deno.env.get("N8N_PRODUCT_WEBHOOK_SECRET"); -const ProductPayloadSchema = z.object({ - external_id: z.string().max(255).optional(), - sku: z.string().min(1).max(100), - name: z.string().min(1).max(500), - description: z.string().max(5000).optional(), - price: z.number().nonnegative(), - min_quantity: z.number().int().positive().optional(), - category_id: z.number().int().optional(), - category_name: z.string().max(255).optional(), - subcategory: z.string().max(255).optional(), - supplier_id: z.string().max(255).optional(), - supplier_name: z.string().max(255).optional(), - stock: z.number().int().nonnegative().optional(), - stock_status: z.string().max(50).optional(), - is_kit: z.boolean().optional(), - is_active: z.boolean().optional(), - featured: z.boolean().optional(), - new_arrival: z.boolean().optional(), - on_sale: z.boolean().optional(), - images: z.array(z.string().url().max(2000)).max(50).optional(), - video_url: z.string().url().max(2000).optional().nullable(), - colors: z.array(z.object({ name: z.string(), hex: z.string(), group: z.string().optional() })).max(100).optional(), - materials: z.array(z.string().max(100)).max(50).optional(), - tags: z.record(z.array(z.string())).optional(), - kit_items: z.array(z.object({ - productId: z.string(), productName: z.string(), quantity: z.number(), sku: z.string() - })).max(50).optional(), - variations: z.array(z.any()).max(200).optional(), - metadata: z.record(z.any()).optional(), -}); - -const WebhookPayloadSchema = z.object({ - action: z.enum(["sync", "upsert", "delete", "batch_upsert"]), - products: z.array(ProductPayloadSchema).max(500).optional(), - product: ProductPayloadSchema.optional(), - external_ids: z.array(z.string().max(255)).max(500).optional(), -}); - -type ProductPayload = z.infer; - -interface WebhookPayload { - action: "sync" | "upsert" | "delete" | "batch_upsert"; - products?: ProductPayload[]; - product?: ProductPayload; - external_ids?: string[]; -} - Deno.serve(async (req) => { // Handle CORS preflight if (req.method === "OPTIONS") { @@ -68,26 +28,25 @@ Deno.serve(async (req) => { const providedSecret = req.headers.get("x-webhook-secret"); if (webhookSecret && providedSecret !== webhookSecret) { console.error("Invalid webhook secret"); - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildErrorResponse("unauthorized", "Unauthorized", req, corsHeaders, { status: 401 }); } let rawBody: unknown; - try { rawBody = await req.json(); } catch { - return new Response(JSON.stringify({ error: "Invalid webhook payload" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + try { + const text = await req.text(); + if (!text || text.trim() === "") { + return buildErrorResponse("empty_body", "Request body is required", req, corsHeaders, { status: 400 }); + } + rawBody = JSON.parse(text); + } catch { + return buildErrorResponse("invalid_json", "Invalid JSON in request body", req, corsHeaders, { status: 400 }); } - const parsed = WebhookPayloadSchema.safeParse(rawBody); + const parsed = ProductWebhookPayloadSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } - const payload: WebhookPayload = parsed.data; + const payload = parsed.data; console.log(`Product webhook action: ${payload.action}`); // Create sync log diff --git a/supabase/functions/rate-limit-check/index.ts b/supabase/functions/rate-limit-check/index.ts index 95a20b00f..8c461218f 100644 --- a/supabase/functions/rate-limit-check/index.ts +++ b/supabase/functions/rate-limit-check/index.ts @@ -1,6 +1,7 @@ import { getCorsHeaders, handleCorsPreflightIfNeeded } from '../_shared/cors.ts'; import { logSecurityEvent } from '../_shared/security.ts'; import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BodySchema = z.object({ endpoint: z.enum(['login', 'api', 'ai', 'approval']).default('api'), @@ -41,10 +42,7 @@ Deno.serve(async (req) => { const parsed = BodySchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const endpoint = parsed.data.endpoint || 'api'; diff --git a/supabase/functions/secrets-manager/index.ts b/supabase/functions/secrets-manager/index.ts index 9b0026376..1324da27f 100644 --- a/supabase/functions/secrets-manager/index.ts +++ b/supabase/functions/secrets-manager/index.ts @@ -10,6 +10,7 @@ import { } from "../_shared/credentials.ts"; import { writeAuditEntry, extractRequestMeta } from "../_shared/audit-log.ts"; import { getOrCreateRequestId, REQUEST_ID_HEADER } from "../_shared/request-id.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const SOURCE = "secrets-manager"; @@ -144,29 +145,7 @@ Deno.serve(async (req) => { const parsed = BodySchema.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { - return new Response( - JSON.stringify({ error: "Payload inválido", details: parsed.error.flatten() }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); - } - const { action, names, name, value, notes } = parsed.data; - - // Helper: load DB rows for a list of names - async function loadFromDb(targets: string[]) { - type Row = { masked_suffix: string | null; length: number; updated_at: string; updated_by: string | null }; - if (targets.length === 0) return new Map(); - const { data } = await service - .from("integration_credentials") - .select("secret_name, masked_suffix, length, updated_at, updated_by") - .in("secret_name", targets); - const map = new Map(); - for (const row of (data ?? []) as Array<{ secret_name: string } & Row>) { - map.set(row.secret_name, { - masked_suffix: row.masked_suffix, - length: row.length, - updated_at: row.updated_at, - updated_by: row.updated_by, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } return map; } diff --git a/supabase/functions/semantic-search/index.ts b/supabase/functions/semantic-search/index.ts index 064235ccc..9942d5ac1 100644 --- a/supabase/functions/semantic-search/index.ts +++ b/supabase/functions/semantic-search/index.ts @@ -6,6 +6,7 @@ import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from '../_shared/zod-validate.ts'; import { rateLimiters, applyRateLimit } from '../_shared/rate-limiter.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; // ======================================== // PG_TRGM RE-RANK via RPC search_products_semantic @@ -199,10 +200,7 @@ Deno.serve(async (req) => { const parsed = SearchSchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ success: false, error: parsed.error.issues[0]?.message || 'Invalid input' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { query, products: productsForRank, limit: rankLimit } = parsed.data; diff --git a/supabase/functions/send-notification/index.ts b/supabase/functions/send-notification/index.ts index 1d562d467..33faff788 100644 --- a/supabase/functions/send-notification/index.ts +++ b/supabase/functions/send-notification/index.ts @@ -3,6 +3,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "npm:zod@3.23.8"; import { castRpcResult } from "../_shared/supabase-client-adapter.ts"; import { authorizeCron } from "../_shared/dispatcher-auth.ts"; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const NotificationSchema = z.object({ user_id: z.string().uuid(), @@ -41,7 +42,7 @@ Deno.serve(async (req) => { const rawBody = await req.json(); const parsed = NotificationSchema.safeParse(rawBody); if (!parsed.success) { - return jsonRes(corsHeaders, { error: 'Invalid payload', details: parsed.error.flatten().fieldErrors }, 400); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const payload = parsed.data; diff --git a/supabase/functions/sync-quote-bitrix/index.ts b/supabase/functions/sync-quote-bitrix/index.ts index 648040a33..18493f48a 100644 --- a/supabase/functions/sync-quote-bitrix/index.ts +++ b/supabase/functions/sync-quote-bitrix/index.ts @@ -2,6 +2,7 @@ import { getCorsHeaders } from '../_shared/cors.ts'; import { z } from '../_shared/zod-validate.ts'; import { fetchWithBreaker, CircuitOpenError, circuitOpenResponse } from '../_shared/external-fetch.ts'; import { authorize } from '../_shared/authorize.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; // Mapping: seller email → Bitrix24 numeric seller_id const SELLER_EMAIL_MAP: Record = { @@ -65,9 +66,7 @@ Deno.serve(async (req) => { const parsed = SyncQuoteBitrixSchema.safeParse(rawBody); if (!parsed.success) { - return new Response(JSON.stringify({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { diff --git a/supabase/functions/verify-email/index.ts b/supabase/functions/verify-email/index.ts index 48d521aac..ceb89de21 100644 --- a/supabase/functions/verify-email/index.ts +++ b/supabase/functions/verify-email/index.ts @@ -1,6 +1,7 @@ import { getCorsHeaders, handleCorsPreflightIfNeeded } from '../_shared/cors.ts'; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; +import { buildErrorResponse, buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const BodySchema = z.object({ token: z.string().min(1, "Token não fornecido"), @@ -21,18 +22,12 @@ Deno.serve(async (req) => { try { rawBody = await req.json(); } catch { - return new Response( - JSON.stringify({ error: "Invalid JSON body" }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildErrorResponse("invalid_json", "Invalid JSON body", req, corsHeaders, { status: 400 }); } const parsed = BodySchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: parsed.error.issues[0]?.message || "Validation failed" }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { token } = parsed.data; diff --git a/supabase/functions/visual-search/index.ts b/supabase/functions/visual-search/index.ts index 3c0d18477..c1fffd160 100644 --- a/supabase/functions/visual-search/index.ts +++ b/supabase/functions/visual-search/index.ts @@ -4,6 +4,7 @@ import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { z } from '../_shared/zod-validate.ts'; import { rateLimiters, applyRateLimit } from '../_shared/rate-limiter.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; Deno.serve(async (req) => { const corsHeaders = getCorsHeaders(req); @@ -46,10 +47,7 @@ Deno.serve(async (req) => { const parsed = ImageSchema.safeParse(rawBody); if (!parsed.success) { - return new Response( - JSON.stringify({ error: parsed.error.issues[0]?.message || "Invalid input" }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { imageBase64 } = parsed.data; diff --git a/supabase/functions/voice-agent/index.ts b/supabase/functions/voice-agent/index.ts index 05bbb0916..330f03774 100644 --- a/supabase/functions/voice-agent/index.ts +++ b/supabase/functions/voice-agent/index.ts @@ -5,6 +5,7 @@ import { callAiWithTracking, QuotaExceededError } from '../_shared/ai-usage.ts'; import { SYSTEM_PROMPT, VOICE_COMMAND_TOOL, TOOL_CHOICE } from './systemPrompt.ts'; import { parseAiResponse } from './parseAiResponse.ts'; import { runBotProtection } from '../_shared/bot-protection.ts'; +import { buildValidationErrorResponse } from "../_shared/validation-errors.ts"; const TranscriptSchema = z.object({ transcript: z.string().min(1, 'transcript cannot be empty').max(1000, 'transcript too long'), @@ -52,10 +53,7 @@ Deno.serve(async (req) => { const parsed = TranscriptSchema.safeParse(body); if (!parsed.success) { - return new Response( - JSON.stringify({ error: parsed.error.flatten().fieldErrors }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } const { transcript } = parsed.data; diff --git a/supabase/functions/webhook-dispatcher/index.ts b/supabase/functions/webhook-dispatcher/index.ts index da60088cc..5bddabeee 100644 --- a/supabase/functions/webhook-dispatcher/index.ts +++ b/supabase/functions/webhook-dispatcher/index.ts @@ -11,20 +11,17 @@ // Ver: supabase/functions/_shared/dispatcher-auth.ts import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; -import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; import { authorizeDispatcher } from "../_shared/dispatcher-auth.ts"; +import { DispatcherBodySchema } from "../_shared/webhook-schemas.ts"; +import { + buildErrorResponse, + buildValidationErrorResponse, +} from "../_shared/validation-errors.ts"; -const corsHeaders = buildPublicCorsHeaders({ allowMethods: "POST, OPTIONS" }); - -const BodySchema = z.object({ - event: z.string().min(1), - payload: z.unknown().optional(), - // Replay mode: re-deliver a single failed delivery by id - replay_delivery_id: z.string().uuid().optional(), - // Test mode (Onda 13 #9): dispatch to a specific webhook, no metrics, no breaker, no DB log - test_mode: z.boolean().optional(), - test_webhook_id: z.string().uuid().optional(), +const corsHeaders = buildPublicCorsHeaders({ + allowMethods: "POST, OPTIONS", + extraAllowHeaders: ["x-api-version", "x-dispatcher-secret"], }); // Circuit breaker: 5 falhas consecutivas → desativa o webhook @@ -53,20 +50,23 @@ Deno.serve(async (req) => { if (dispatcherSecret) { const incoming = req.headers.get("x-dispatcher-secret"); if (!incoming || incoming !== dispatcherSecret) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("unauthorized", "Unauthorized", req, corsHeaders, { status: 401 }); } } try { // Body precisa ser parseado antes da auth pra saber se requer Modo B (test_mode/replay). - // Body parse falha → 400 antes da auth (não vaza info). - const parsed = BodySchema.safeParse(await req.json().catch(() => ({}))); + // Body parse falha → 422 antes da auth (não vaza info). + let rawJson: unknown; + try { + const text = await req.text(); + rawJson = text ? JSON.parse(text) : {}; + } catch { + return buildErrorResponse("invalid_json", "Invalid JSON in request body", req, corsHeaders, { status: 400 }); + } + const parsed = DispatcherBodySchema.safeParse(rawJson); if (!parsed.success) { - return new Response(JSON.stringify({ error: "Invalid body" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildValidationErrorResponse(parsed.error, req, corsHeaders); } let { event, payload } = parsed.data; const { replay_delivery_id, test_mode, test_webhook_id } = parsed.data; @@ -85,10 +85,15 @@ Deno.serve(async (req) => { // Test mode (Onda 13 #9): single-shot, no retries, no DB write, no breaker if (test_mode) { + // Schema cross-field rule already guards this; redundant safety check. if (!test_webhook_id) { - return new Response(JSON.stringify({ error: "test_webhook_id obrigatório em test_mode" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse( + "test_webhook_id_required", + "test_webhook_id obrigatório em test_mode", + req, + corsHeaders, + { status: 422 }, + ); } const { data: hook, error: hookErr } = await supabase .from("outbound_webhooks") @@ -96,9 +101,7 @@ Deno.serve(async (req) => { .eq("id", test_webhook_id) .maybeSingle(); if (hookErr || !hook) { - return new Response(JSON.stringify({ error: "Webhook não encontrado" }), { - status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("not_found", "Webhook não encontrado", req, corsHeaders, { status: 404 }); } const bodyJson = JSON.stringify({ event, @@ -150,9 +153,7 @@ Deno.serve(async (req) => { .eq("id", replay_delivery_id) .maybeSingle(); if (origErr || !orig) { - return new Response(JSON.stringify({ error: "Delivery não encontrada" }), { - status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("not_found", "Delivery não encontrada", req, corsHeaders, { status: 404 }); } event = orig.event; payload = orig.payload; @@ -274,8 +275,6 @@ Deno.serve(async (req) => { }); } catch (err) { const msg = err instanceof Error ? err.message : "Erro desconhecido"; - return new Response(JSON.stringify({ error: msg }), { - status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("internal_error", msg, req, corsHeaders, { status: 500 }); } }); diff --git a/supabase/functions/webhook-inbound/index.ts b/supabase/functions/webhook-inbound/index.ts index 3970b5f9d..37312cbf9 100644 --- a/supabase/functions/webhook-inbound/index.ts +++ b/supabase/functions/webhook-inbound/index.ts @@ -5,8 +5,16 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; +import { InboundWebhookEnvelopeSchema } from "../_shared/webhook-schemas.ts"; +import { + buildErrorResponse, + buildValidationErrorResponse, +} from "../_shared/validation-errors.ts"; -const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-signature-256","x-event"], allowMethods: "POST, OPTIONS" }); +const corsHeaders = buildPublicCorsHeaders({ + extraAllowHeaders: ["x-signature-256", "x-event", "x-webhook-signature", "x-api-version"], + allowMethods: "POST, OPTIONS", +}); async function hmacSign(payload: string, secret: string): Promise { const enc = new TextEncoder(); @@ -37,10 +45,20 @@ Deno.serve(async (req) => { const slug = url.searchParams.get("slug") || url.pathname.split("/").filter(Boolean).pop() || ""; - if (!slug) { - return new Response(JSON.stringify({ error: "slug ausente" }), { - status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + const signatureHeader = req.headers.get("x-signature-256") + || req.headers.get("x-webhook-signature") + || ""; + const eventTypeHeader = req.headers.get("x-event") || "unknown"; + + // Envelope validation: slug shape, event_type, optional signature format. + // Persisted body itself is intentionally untyped (3rd-party payloads). + const envelopeParse = InboundWebhookEnvelopeSchema.safeParse({ + slug, + event_type: eventTypeHeader, + signature: signatureHeader || undefined, + }); + if (!envelopeParse.success) { + return buildValidationErrorResponse(envelopeParse.error, req, corsHeaders); } const { data: endpoint } = await supabase @@ -50,16 +68,11 @@ Deno.serve(async (req) => { .eq("active", true) .maybeSingle(); if (!endpoint) { - return new Response(JSON.stringify({ error: "endpoint não encontrado" }), { - status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("not_found", "endpoint não encontrado", req, corsHeaders, { status: 404 }); } const rawBody = await req.text(); - const signatureHeader = req.headers.get("x-signature-256") - || req.headers.get("x-webhook-signature") - || ""; - const eventType = req.headers.get("x-event") || "unknown"; + const eventType = envelopeParse.data.event_type; const sourceIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || null; const secretRes = await supabase.from('integration_credentials').select('secret_value').eq('secret_name', endpoint.hmac_secret_ref).maybeSingle(); @@ -92,9 +105,7 @@ Deno.serve(async (req) => { }).eq("id", endpoint.id); if (!signatureValid) { - return new Response(JSON.stringify({ error: "Assinatura inválida" }), { - status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + return buildErrorResponse("invalid_signature", "Assinatura inválida", req, corsHeaders, { status: 401 }); } return new Response(JSON.stringify({ ok: true, received: true }), { diff --git a/tests/edge-functions/validation-errors.test.ts b/tests/edge-functions/validation-errors.test.ts new file mode 100644 index 000000000..8843438b0 --- /dev/null +++ b/tests/edge-functions/validation-errors.test.ts @@ -0,0 +1,181 @@ +/** + * Contract tests for the unified validation error builder. + * + * The Edge Function source lives at: + * supabase/functions/_shared/validation-errors.ts + * + * A Node-compatible mirror at src/lib/validation-errors.ts is the one we + * import here (Vitest runs in Node and cannot resolve Deno https:// imports). + * Both files are byte-identical except for the Zod import path; the + * webhook-schemas-parity test below enforces that. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + buildValidationError, + buildValidationErrorV1, + buildValidationErrorV2, + buildValidationErrorV2FromFields, + detectContractVersion, + isValidationErrorV1, + isValidationErrorV2, + VALIDATION_ERROR_CODE, + VALIDATION_ERROR_STATUS, + zodIssuesToFieldErrors, +} from "@/lib/validation-errors"; + +const sampleSchema = z.object({ + sku: z.string().min(1), + price: z.number().nonnegative(), + email: z.string().email(), +}); + +function failingParse() { + const r = sampleSchema.safeParse({ sku: "", price: -1, email: "not-an-email" }); + if (r.success) throw new Error("expected parse to fail"); + return r.error; +} + +describe("validation-errors / version negotiation", () => { + it.each([ + ["v1 when no signals", "https://x.test/foo", {}, "v1"], + ["v2 via query api_version=v2", "https://x.test/foo?api_version=v2", {}, "v2"], + ["v2 via query version=v2", "https://x.test/foo?version=v2", {}, "v2"], + ["v2 via header X-API-Version", "https://x.test/foo", { "x-api-version": "v2" }, "v2"], + ["v2 via header X-API-Version=2", "https://x.test/foo", { "x-api-version": "2" }, "v2"], + ["v2 via Accept vendor mime", "https://x.test/foo", { accept: "application/vnd.promogifts.v2+json" }, "v2"], + ["v1 via header X-API-Version=v1", "https://x.test/foo", { "x-api-version": "v1" }, "v1"], + ["query takes precedence over header", "https://x.test/foo?api_version=v2", { "x-api-version": "v1" }, "v2"], + ])("returns %s — %s", (_label, url, headerObj, expected) => { + const req = { url, headers: new Headers(headerObj as Record) }; + expect(detectContractVersion(req)).toBe(expected); + }); + + it("ignores malformed URL gracefully", () => { + const req = { url: "not-a-url", headers: new Headers() }; + expect(detectContractVersion(req)).toBe("v1"); + }); +}); + +describe("validation-errors / v1 builder (legacy)", () => { + it("emits { error, details } with fieldErrors keyed by path", () => { + const err = failingParse(); + const payload = buildValidationErrorV1(err); + expect(payload.error).toBe("Validation failed"); + expect(payload.details).toBeTypeOf("object"); + const details = payload.details as Record; + expect(details.sku).toBeDefined(); + expect(details.price).toBeDefined(); + expect(details.email).toBeDefined(); + expect(details.sku[0]).toMatch(/at least 1/i); + }); + + it("falls back to formErrors[] when there are no field errors", () => { + const schema = z.string().min(5); + const r = schema.safeParse("ab"); + if (r.success) throw new Error("expected fail"); + const payload = buildValidationErrorV1(r.error); + expect(payload.error).toBe("Validation failed"); + expect(Array.isArray(payload.details)).toBe(true); + }); +}); + +describe("validation-errors / v2 builder (canonical)", () => { + it("emits { code, message, version, fields[] } with stable shape", () => { + const err = failingParse(); + const payload = buildValidationErrorV2(err); + expect(payload.code).toBe(VALIDATION_ERROR_CODE); + expect(payload.version).toBe("v2"); + expect(payload.message).toBe("Validation failed"); + expect(Array.isArray(payload.fields)).toBe(true); + expect(payload.fields.length).toBe(3); + for (const f of payload.fields) { + expect(f).toHaveProperty("path"); + expect(f).toHaveProperty("code"); + expect(f).toHaveProperty("message"); + expect(typeof f.path).toBe("string"); + expect(typeof f.code).toBe("string"); + expect(typeof f.message).toBe("string"); + } + const paths = payload.fields.map((f) => f.path).sort(); + expect(paths).toEqual(["email", "price", "sku"]); + }); + + it("preserves nested paths using dot notation", () => { + const nested = z.object({ product: z.object({ sku: z.string().min(1) }) }); + const r = nested.safeParse({ product: { sku: "" } }); + if (r.success) throw new Error("expected fail"); + const payload = buildValidationErrorV2(r.error); + expect(payload.fields[0].path).toBe("product.sku"); + }); + + it("preserves array indices in path", () => { + const schema = z.object({ images: z.array(z.string().url()).min(1) }); + const r = schema.safeParse({ images: ["not-a-url"] }); + if (r.success) throw new Error("expected fail"); + const payload = buildValidationErrorV2(r.error); + expect(payload.fields[0].path).toBe("images.0"); + }); +}); + +describe("validation-errors / buildValidationError dispatch", () => { + const err = failingParse(); + it("returns v1 shape for v1 version", () => { + const out = buildValidationError(err, "v1"); + expect(isValidationErrorV1(out)).toBe(true); + expect(isValidationErrorV2(out)).toBe(false); + }); + it("returns v2 shape for v2 version", () => { + const out = buildValidationError(err, "v2"); + expect(isValidationErrorV2(out)).toBe(true); + expect(isValidationErrorV1(out)).toBe(false); + }); +}); + +describe("validation-errors / v2 from manual fieldErrors (business rules)", () => { + it("converts Record to v2 fields[]", () => { + const payload = buildValidationErrorV2FromFields({ + expires_at: ["Expiração precisa ser futura."], + scopes: ["Janela máxima 180 dias.", "Scope inválido para role atual."], + }); + expect(isValidationErrorV2(payload)).toBe(true); + expect(payload.code).toBe(VALIDATION_ERROR_CODE); + expect(payload.fields).toHaveLength(3); + const paths = payload.fields.map((f) => f.path).sort(); + expect(paths).toEqual(["expires_at", "scopes", "scopes"]); + for (const f of payload.fields) { + expect(f.code).toBe("business_rule"); // default code + } + }); + + it("accepts custom code + message", () => { + const payload = buildValidationErrorV2FromFields( + { confirmation_phrase: ["Digite EXATAMENTE a frase."] }, + { code: "full_friction_failed", message: "Atrito adicional obrigatório" }, + ); + expect(payload.code).toBe(VALIDATION_ERROR_CODE); + expect(payload.message).toBe("Atrito adicional obrigatório"); + expect(payload.fields[0].code).toBe("full_friction_failed"); + }); + + it("returns empty fields[] for empty input", () => { + const payload = buildValidationErrorV2FromFields({}); + expect(payload.fields).toEqual([]); + expect(payload.version).toBe("v2"); + }); +}); + +describe("validation-errors / contract invariants", () => { + it("VALIDATION_ERROR_STATUS is 422", () => { + expect(VALIDATION_ERROR_STATUS).toBe(422); + }); + it("VALIDATION_ERROR_CODE is 'validation_failed'", () => { + expect(VALIDATION_ERROR_CODE).toBe("validation_failed"); + }); + it("zodIssuesToFieldErrors keeps issue codes intact", () => { + const r = z.object({ x: z.number() }).safeParse({ x: "abc" }); + if (r.success) throw new Error("expected fail"); + const fields = zodIssuesToFieldErrors(r.error); + expect(fields[0].code).toBe("invalid_type"); + }); +}); diff --git a/tests/edge-functions/webhook-schemas-parity.test.ts b/tests/edge-functions/webhook-schemas-parity.test.ts new file mode 100644 index 000000000..289a7d613 --- /dev/null +++ b/tests/edge-functions/webhook-schemas-parity.test.ts @@ -0,0 +1,112 @@ +/** + * Parity contract: src/lib/webhook-schemas.ts (Node) MUST mirror + * supabase/functions/_shared/webhook-schemas.ts (Deno). + * + * The Deno copy is canonical (Edge Functions run against it). This test + * statically diffs both files so a refactor on one side blocks CI until + * the mirror is updated. + * + * The acceptable difference is the Zod import line: + * Deno: import { z } from "https://esm.sh/zod@3.23.8"; + * Node: import { z } from "zod"; + * + * Both validation-errors files are also checked for symmetry of exports. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function normalize(src: string): string { + return ( + src + // Strip license/doc preamble comments above the first import. + .replace(/^[\s\S]*?(?=^import )/m, "") + // Normalize Zod import paths. + .replace(/import \{ z \} from ["']https:\/\/esm\.sh\/zod@3\.23\.8["']/g, 'import { z } from "zod"') + .replace(/import \{ z \} from ["']zod["']/g, 'import { z } from "zod"') + // Normalize Deno-only type-only imports referenced in validation-errors.ts. + .replace( + /import type \{ ZodError, ZodIssue \} from ["']https:\/\/esm\.sh\/zod@3\.23\.8["']/g, + 'import type { ZodError, ZodIssue } from "zod"', + ) + // Normalize quotes (single ↔ double) — Prettier prefers single in src/, + // Deno style guide prefers double. Schema semantics are identical. + .replace(/'/g, '"') + // Strip block comments (both sides may diverge in /** */ wording). + .replace(/\/\*[\s\S]*?\*\//g, "") + // Strip single-line comments (// ...). + .replace(/\/\/[^\n]*/g, "") + // Collapse all whitespace runs to a single space. + .replace(/\s+/g, " ") + // Strip whitespace adjacent to syntactic punctuation so Prettier's + // multi-line method-chain reformatting (`.method()` on its own line) + // is canonically equivalent to the inline Deno form. + .replace(/\s*([.,;()[\]{}])\s*/g, "$1") + .trim() + ); +} + +function extractExports(src: string): string[] { + const out = new Set(); + const reExport = /export\s+(?:const|function|class|type|interface|enum|let|var)\s+([A-Za-z_$][\w$]*)/g; + const reReExport = /export\s+\{\s*([^}]+)\s*\}/g; + let m: RegExpExecArray | null; + while ((m = reExport.exec(src))) out.add(m[1]); + while ((m = reReExport.exec(src))) { + for (const name of m[1].split(",")) { + const clean = name.trim().split(/\s+as\s+/)[0].trim(); + if (clean) out.add(clean); + } + } + return Array.from(out).sort(); +} + +describe("schema parity: webhook-schemas.ts (Deno) ↔ src/lib/webhook-schemas.ts (Node)", () => { + const denoPath = resolve(__dirname, "../../supabase/functions/_shared/webhook-schemas.ts"); + const nodePath = resolve(__dirname, "../../src/lib/webhook-schemas.ts"); + const deno = readFileSync(denoPath, "utf8"); + const node = readFileSync(nodePath, "utf8"); + + it("both files export the same symbol set", () => { + const denoExports = extractExports(deno); + const nodeExports = extractExports(node); + expect(nodeExports).toEqual(denoExports); + }); + + it("schema body (after normalizing import paths) is byte-identical", () => { + expect(normalize(node)).toEqual(normalize(deno)); + }); +}); + +describe("schema parity: validation-errors.ts (Deno) ↔ src/lib/validation-errors.ts (Node)", () => { + const denoPath = resolve(__dirname, "../../supabase/functions/_shared/validation-errors.ts"); + const nodePath = resolve(__dirname, "../../src/lib/validation-errors.ts"); + const deno = readFileSync(denoPath, "utf8"); + const node = readFileSync(nodePath, "utf8"); + + it("both files export the same canonical names", () => { + const denoExports = extractExports(deno); + const nodeExports = extractExports(node); + // The Node mirror is allowed to omit Deno-only helpers (Response builders + // that depend on Deno's Response constructor) but MUST include all the + // pure functions and constants. + const required = [ + "VALIDATION_ERROR_STATUS", + "VALIDATION_ERROR_CODE", + "ContractVersion", + "FieldError", + "ValidationErrorV1", + "ValidationErrorV2", + "ValidationErrorPayload", + "detectContractVersion", + "zodIssuesToFieldErrors", + "buildValidationErrorV1", + "buildValidationErrorV2", + "buildValidationError", + ]; + for (const name of required) { + expect(denoExports).toContain(name); + expect(nodeExports).toContain(name); + } + }); +}); diff --git a/tests/edge-functions/webhook-schemas.contract.test.ts b/tests/edge-functions/webhook-schemas.contract.test.ts new file mode 100644 index 000000000..f5026d90a --- /dev/null +++ b/tests/edge-functions/webhook-schemas.contract.test.ts @@ -0,0 +1,388 @@ +/** + * Contract tests for webhook payload schemas. + * + * Every webhook must: + * 1. Accept the canonical "happy path" payload. + * 2. Reject payloads with missing required fields. + * 3. Reject payloads with wrong-typed fields. + * 4. Reject payloads with empty strings where non-empty is required. + * 5. Surface the unified 422 error shape (validated via the response builder + * tests at validation-errors.test.ts and through-and-through here). + * + * The schemas under test are imported from the canonical node mirror at + * src/lib/webhook-schemas.ts — the Edge Function copy in + * supabase/functions/_shared/webhook-schemas.ts is verified by the parity + * test (webhook-schemas-parity.test.ts). + */ +import { describe, expect, it } from "vitest"; +import { + buildValidationError, + isValidationErrorV1, + isValidationErrorV2, + type ValidationErrorV2, +} from "@/lib/validation-errors"; +import { + DispatcherBodySchema, + InboundWebhookEnvelopeSchema, + ProductPayloadSchema, + ProductWebhookPayloadSchema, +} from "@/lib/webhook-schemas"; + +function expectFail(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, value: unknown) { + const r = schema.safeParse(value); + expect(r.success).toBe(false); + return (r as { success: false; error: import("zod").ZodError }).error; +} + +function expectPass(schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, value: unknown): T { + const r = schema.safeParse(value); + if (!r.success) { + throw new Error(`expected pass, got: ${JSON.stringify(r)}`); + } + return (r as { success: true; data: T }).data; +} + +// ============================================================================ +// product-webhook: ProductPayloadSchema (single product) +// ============================================================================ + +describe("ProductPayloadSchema", () => { + const validProduct = { sku: "ABC-1", name: "Caneca", price: 19.9 }; + + it("accepts minimal valid product", () => { + const data = expectPass(ProductPayloadSchema, validProduct); + expect(data.sku).toBe("ABC-1"); + }); + + it("accepts product with optional fields populated", () => { + expectPass(ProductPayloadSchema, { + ...validProduct, + images: ["https://cdn.example.com/a.jpg"], + colors: [{ name: "Azul", hex: "#0000ff" }], + materials: ["plástico"], + stock: 10, + is_active: true, + tags: { theme: ["x", "y"] }, + }); + }); + + describe("missing required fields", () => { + it("rejects when sku is missing", () => { + const err = expectFail(ProductPayloadSchema, { name: "x", price: 1 }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "sku")).toBeDefined(); + expect(v2.fields.find((f) => f.path === "sku")?.code).toBe("invalid_type"); + }); + it("rejects when name is missing", () => { + const err = expectFail(ProductPayloadSchema, { sku: "x", price: 1 }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "name")).toBeDefined(); + }); + it("rejects when price is missing", () => { + const err = expectFail(ProductPayloadSchema, { sku: "x", name: "x" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "price")).toBeDefined(); + }); + }); + + describe("empty values", () => { + it("rejects empty sku string", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, sku: "" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "sku")?.code).toBe("too_small"); + }); + it("rejects empty name string", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, name: "" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "name")?.code).toBe("too_small"); + }); + }); + + describe("wrong types", () => { + it("rejects non-number price", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, price: "19.90" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "price")?.code).toBe("invalid_type"); + }); + it("rejects negative price", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, price: -1 }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "price")?.code).toBe("too_small"); + }); + it("rejects non-integer min_quantity", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, min_quantity: 1.5 }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "min_quantity")).toBeDefined(); + }); + it("rejects non-URL image", () => { + const err = expectFail(ProductPayloadSchema, { ...validProduct, images: ["not-a-url"] }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "images.0")).toBeDefined(); + }); + it("rejects too-many images (>50)", () => { + const tooMany = Array.from({ length: 51 }, (_, i) => `https://x.test/${i}.jpg`); + const err = expectFail(ProductPayloadSchema, { ...validProduct, images: tooMany }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "images")?.code).toBe("too_big"); + }); + }); +}); + +// ============================================================================ +// product-webhook: ProductWebhookPayloadSchema (envelope) +// ============================================================================ + +describe("ProductWebhookPayloadSchema", () => { + const product = { sku: "A", name: "x", price: 1 }; + + it("accepts action=upsert with single product", () => { + expectPass(ProductWebhookPayloadSchema, { action: "upsert", product }); + }); + it("accepts action=sync with products array", () => { + expectPass(ProductWebhookPayloadSchema, { action: "sync", products: [product] }); + }); + it("accepts action=batch_upsert with products array", () => { + expectPass(ProductWebhookPayloadSchema, { action: "batch_upsert", products: [product] }); + }); + it("accepts action=delete with external_ids", () => { + expectPass(ProductWebhookPayloadSchema, { action: "delete", external_ids: ["ext-1"] }); + }); + + describe("invalid action enum", () => { + it("rejects unknown action", () => { + const err = expectFail(ProductWebhookPayloadSchema, { action: "merge", product }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "action")?.code).toBe("invalid_enum_value"); + }); + it("rejects missing action", () => { + const err = expectFail(ProductWebhookPayloadSchema, { product }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "action")).toBeDefined(); + }); + }); + + describe("cross-field rules", () => { + it("rejects upsert without product", () => { + const err = expectFail(ProductWebhookPayloadSchema, { action: "upsert" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "product")).toBeDefined(); + }); + it("rejects sync with empty products array", () => { + const err = expectFail(ProductWebhookPayloadSchema, { action: "sync", products: [] }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "products")).toBeDefined(); + }); + it("rejects delete with empty external_ids", () => { + const err = expectFail(ProductWebhookPayloadSchema, { action: "delete", external_ids: [] }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "external_ids")).toBeDefined(); + }); + it("rejects delete with missing external_ids", () => { + const err = expectFail(ProductWebhookPayloadSchema, { action: "delete" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "external_ids")).toBeDefined(); + }); + }); + + describe("nested product invariants surface through envelope", () => { + it("propagates product.sku=''", () => { + const err = expectFail(ProductWebhookPayloadSchema, { + action: "upsert", + product: { ...product, sku: "" }, + }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "product.sku")).toBeDefined(); + }); + it("propagates products.2.price negative", () => { + const err = expectFail(ProductWebhookPayloadSchema, { + action: "sync", + products: [product, product, { ...product, price: -10 }], + }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "products.2.price")).toBeDefined(); + }); + }); + + describe("batch size limits", () => { + it("rejects products array over 500", () => { + const tooMany = Array.from({ length: 501 }, () => product); + const err = expectFail(ProductWebhookPayloadSchema, { action: "sync", products: tooMany }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "products")?.code).toBe("too_big"); + }); + }); +}); + +// ============================================================================ +// webhook-dispatcher: DispatcherBodySchema +// ============================================================================ + +describe("DispatcherBodySchema", () => { + it("accepts minimal valid dispatch", () => { + expectPass(DispatcherBodySchema, { event: "order.created" }); + }); + it("accepts with payload", () => { + expectPass(DispatcherBodySchema, { event: "x", payload: { foo: 1 } }); + }); + it("accepts replay mode", () => { + expectPass(DispatcherBodySchema, { + event: "x", + replay_delivery_id: "11111111-1111-4111-8111-111111111111", + }); + }); + it("accepts test_mode with test_webhook_id", () => { + expectPass(DispatcherBodySchema, { + event: "x", + test_mode: true, + test_webhook_id: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("rejects empty event", () => { + const err = expectFail(DispatcherBodySchema, { event: "" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "event")?.code).toBe("too_small"); + }); + it("rejects missing event", () => { + const err = expectFail(DispatcherBodySchema, {}); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "event")).toBeDefined(); + }); + it("rejects bad UUID for replay_delivery_id", () => { + const err = expectFail(DispatcherBodySchema, { event: "x", replay_delivery_id: "not-a-uuid" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "replay_delivery_id")?.code).toBe("invalid_string"); + }); + it("rejects bad UUID for test_webhook_id", () => { + const err = expectFail(DispatcherBodySchema, { + event: "x", + test_mode: true, + test_webhook_id: "bad", + }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "test_webhook_id")).toBeDefined(); + }); + it("rejects test_mode without test_webhook_id (cross-field)", () => { + const err = expectFail(DispatcherBodySchema, { event: "x", test_mode: true }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "test_webhook_id")?.code).toBe("custom"); + }); + it("rejects non-boolean test_mode", () => { + const err = expectFail(DispatcherBodySchema, { event: "x", test_mode: "yes" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "test_mode")?.code).toBe("invalid_type"); + }); +}); + +// ============================================================================ +// webhook-inbound: InboundWebhookEnvelopeSchema +// ============================================================================ + +describe("InboundWebhookEnvelopeSchema", () => { + it("accepts minimal envelope", () => { + const data = expectPass(InboundWebhookEnvelopeSchema, { slug: "n8n-orders" }); + expect(data.event_type).toBe("unknown"); + }); + it("accepts full envelope with signature", () => { + expectPass(InboundWebhookEnvelopeSchema, { + slug: "n8n-orders", + event_type: "order.created", + signature: "sha256=" + "a".repeat(64), + }); + }); + it("accepts raw 64-hex signature without sha256= prefix", () => { + expectPass(InboundWebhookEnvelopeSchema, { + slug: "n8n-orders", + signature: "a".repeat(64), + }); + }); + + describe("invalid slug", () => { + it("rejects empty slug", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { slug: "" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "slug")?.code).toBe("too_small"); + }); + it("rejects uppercase slug", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { slug: "N8N-Orders" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "slug")?.code).toBe("invalid_string"); + }); + it("rejects slug with spaces", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { slug: "n8n orders" }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "slug")).toBeDefined(); + }); + it("rejects slug over 120 chars", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { slug: "a".repeat(121) }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "slug")?.code).toBe("too_big"); + }); + it("rejects missing slug", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, {}); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "slug")).toBeDefined(); + }); + }); + + describe("invalid signature", () => { + it("rejects signature with non-hex chars", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { + slug: "x", + signature: "sha256=zzzz" + "a".repeat(60), + }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "signature")).toBeDefined(); + }); + it("rejects signature wrong length", () => { + const err = expectFail(InboundWebhookEnvelopeSchema, { + slug: "x", + signature: "sha256=" + "a".repeat(20), + }); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(v2.fields.find((f) => f.path === "signature")).toBeDefined(); + }); + }); +}); + +// ============================================================================ +// Cross-version compatibility (v1 ↔ v2 migration safety) +// ============================================================================ + +describe("contract versioning: v1 ↔ v2 backwards compatibility", () => { + const err = (() => { + const r = ProductWebhookPayloadSchema.safeParse({ action: "merge" }); + if (r.success) throw new Error("expected fail"); + return r.error; + })(); + + it("v1 and v2 share the same root issue set", () => { + const v1 = buildValidationError(err, "v1"); + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + expect(isValidationErrorV1(v1)).toBe(true); + expect(isValidationErrorV2(v2)).toBe(true); + + const v1Keys = Object.keys(((v1 as { details: Record }).details ?? {}) as Record); + const v2Paths = v2.fields.map((f) => f.path.split(".")[0]); + // Every v1 detail key must appear at the root of v2 fields paths. + for (const k of v1Keys) { + expect(v2Paths).toContain(k); + } + }); + + it("v1 is a strict subset of v2 (no semantic regression)", () => { + // v1 must always be derivable from v2 — we never drop information when + // upgrading. This is the contract that lets us deprecate v1 safely. + const v2 = buildValidationError(err, "v2") as ValidationErrorV2; + const synthesizedV1Details: Record = {}; + for (const f of v2.fields) { + const k = f.path.split(".")[0] || "_form"; + (synthesizedV1Details[k] = synthesizedV1Details[k] || []).push(f.message); + } + const v1 = buildValidationError(err, "v1"); + const actualV1Details = (v1 as { details: Record }).details; + for (const k of Object.keys(actualV1Details)) { + expect(Object.keys(synthesizedV1Details)).toContain(k); + } + }); +});