Skip to content

Webhook Testing Standard

Padrão SSOT para testes de webhooks de provedores externos (Resend, Wasender, MercadoPago, Twilio, etc.). Toda função *-webhook em supabase/functions/ DEVE seguir este padrão.

Princípios

  1. Black-box contra staging — testes batem na Edge Function real deployada (não import direto). Garante que CORS, headers, body parsing e env vars estão corretos no runtime real.
  2. HMAC obrigatório — toda request sem assinatura ou com assinatura inválida deve retornar 401.
  3. Idempotência — mesmo evento processado 2× não duplica side-effect (linha em log/outbox/etc.).
  4. Tolerance window — timestamp fora de janela (default 5min) → 401 (anti-replay).
  5. PII masking — todo log gerado pelo webhook usa helpers de _shared/pii-helpers.ts.
  6. Erro de provider externo nunca vira 500 — webhook responde 200 + log incidente. Provedores (MP, Resend) retentam agressivamente em 5xx, gerando duplicação.

Estrutura padrão

supabase/functions/<provider>-webhook/
├── index.ts
└── __tests__/
    ├── index.test.ts        # smoke + CORS + método
    ├── hmac.test.ts         # assinatura inválida/ausente/expirada
    └── idempotency.test.ts  # mesmo evento 2× = mesmo estado final

Cobertura mínima por suite

index.test.ts — Smoke

  • OPTIONS retorna CORS válido.
  • Método não permitido → 405 ou 200 ignored conforme contrato do provider.
  • Body inválido (JSON malformado) → 400.
  • Resposta sempre tem { success: boolean, ... }.

hmac.test.ts — Assinatura

  • Sem header de assinatura → 401.
  • Assinatura malformada → 401.
  • Assinatura válida mas timestamp > 5min → 401.
  • Assinatura válida + timestamp em janela → 200 (mesmo que evento seja ignorado por outros motivos).
  • Compara em timing-safe (proteção contra timing attack).

idempotency.test.ts — Side-effects

  • Enviar mesmo evento 2× com 1s de intervalo.
  • Verificar via SQL (read-only) que tabela alvo (email_eventos, mensagens_log, etc.) tem exatamente 1 linha — ou que upsert não criou duplicata.
  • Status final do registro é o esperado (não regrediu).

Template de teste (resend-webhook)

typescript
import "https://deno.land/std@0.224.0/dotenv/load.ts";
import { assertEquals } from "https://deno.land/std@0.168.0/testing/asserts.ts";

const SUPABASE_URL = Deno.env.get("VITE_SUPABASE_URL")!;
const ANON = Deno.env.get("VITE_SUPABASE_PUBLISHABLE_KEY")!;
const URL = `${SUPABASE_URL}/functions/v1/resend-webhook`;

Deno.test("resend-webhook: sem svix-* headers → 401", async () => {
  const res = await fetch(URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", "apikey": ANON },
    body: JSON.stringify({ type: "email.delivered", data: { email_id: "x" } }),
  });
  assertEquals(res.status, 401);
  await res.text();
});

Deno.test("resend-webhook: timestamp expirado → 401", async () => {
  const oldTs = String(Math.floor(Date.now() / 1000) - 600); // 10min atrás
  const res = await fetch(URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "apikey": ANON,
      "svix-id": "msg_test",
      "svix-timestamp": oldTs,
      "svix-signature": "v1,xxxx",
    },
    body: "{}",
  });
  assertEquals(res.status, 401);
  await res.text();
});

Helpers compartilhados

Crie helpers em supabase/functions/_shared/__tests__/webhook-helpers.ts para:

  • Gerar assinatura Svix válida com secret de teste.
  • Gerar x-signature MercadoPago (ts=...,v1=...).
  • Aguardar processamento background (poll em tabela alvo com timeout).

Anti-patterns

❌ Importar index.ts direto e chamar Deno.serve — perde validação de CORS e env do runtime. ❌ Mockar crypto.subtle — preferir gerar assinatura real com secret de teste. ❌ Esperar 5xx de webhook em qualquer cenário — retorna 200/4xx sempre, NUNCA 500. ❌ Asserções dependentes de tempo real sem clock.tick — usar timestamps explícitos.

Checklist pré-PR

  • [ ] 3 suites criadas (index, hmac, idempotency).
  • [ ] Todas rodam em CI (deno-tests job em .github/workflows/ci.yml).
  • [ ] Secret de teste documentado em docs/staging/STAGING_REFERENCE.md.
  • [ ] PII masking validado em logs gerados (registrarLog usa helpers).
  • [ ] Atualizado TESTING_BACKLOG.md movendo item para Histórico.

Referências