Skip to content

fix(seguranca): relatório da comunidade — vazamento entre organizações, RBAC nas policies, MFA de sessão, open redirect e SSRF #328

fix(seguranca): relatório da comunidade — vazamento entre organizações, RBAC nas policies, MFA de sessão, open redirect e SSRF

fix(seguranca): relatório da comunidade — vazamento entre organizações, RBAC nas policies, MFA de sessão, open redirect e SSRF #328

Workflow file for this run

name: e2e
# Issue #63: as specs de tests/e2e/ não rodavam em lugar nenhum. Este job fecha o
# buraco com o subconjunto que não depende de serviço externo — hoje 29 de 33.
#
# A execução é dividida em DUAS invocações do playwright de propósito: o
# limitador de login do produto é por IP (60/300s) e no CI todos os specs vêm do
# mesmo 127.0.0.1. Ver o comentário do passo "E2E — parte 1 de 2".
#
# NÃO-BLOQUEANTE por ausência, não por mordaça: `e2e` não está na lista de
# checks obrigatórios da branch protection, então falhar aqui não segura merge.
# O job em si falha honestamente — a primeira versão deste arquivo usava
# `continue-on-error: true` no job inteiro e reportou VERDE com o `supabase
# start` quebrado e zero teste executado. Verde que não distingue "passou" de
# "nem rodou" é pior que vermelho.
#
# Quando N execuções seguidas passarem limpas, adicionar `e2e` aos obrigatórios.
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
# ─────────────────────────────────────────────────────────────────────────
# A COBERTURA DEIXA DE SER PROSA DIGITADA À MÃO.
#
# Antes, as listas viviam nos dois `run:` e a declaração do que NÃO é coberto
# era um `echo` escrito à mão. As duas fontes divergiram em silêncio: o texto
# dizia "32 de 33 specs" quando o disco tinha 39 e o job rodava 36 — e as
# ausentes eram justamente as novas, incluindo `agente-papeis-operador`, a
# prova de tela do épico dos três papéis, que nunca rodou em job nenhum.
#
# Cobertura parcial silenciosa se lê como cobertura total. Agora há UMA fonte
# por lista, o summary CONTA em vez de afirmar, e
# `tests/unit/e2e-cobertura-completa.test.ts` reprova o build quando um arquivo
# de spec não está em nenhuma das três listas.
#
# FORA_DO_CI exige motivo escrito no próprio nome do bloco abaixo — spec fora
# do gate sem razão declarada é a dívida voltando pela porta de serviço.
# ─────────────────────────────────────────────────────────────────────────
#
# ═══ O 429 DE `olhar-telas-do-epico`: RESOLVIDO DUAS VEZES, EM CAMADAS DIFERENTES ═══
#
# A spec reprovava com **429** em TODAS as 7 telas, falha PRÉ-EXISTENTE NA MAIN (run
# 31343408488, main, 2026-08-10T00:03, lista byte-idêntica).
#
# Duas sessões chegaram à MESMA causa raiz em paralelo, sem se ver, e os dois
# consertos são complementares — nenhum torna o outro dispensável:
#
# - `a60cae2d` (main, PR #220) desligou a telemetria NO AMBIENTE DO CI:
# `SENTRY_DSN: "off"` no build e nas duas partes (e no `perf.yml`). Ver o
# comentário do passo "Build de produção" abaixo — a armadilha é que vazio NÃO
# desliga, seleciona o Sentry da comunidade.
# - `77b4a486` (este PR) consertou O PRODUTO: o SDK do browser mandava duas SESSÕES
# de release health por NAVEGAÇÃO para o DSN da comunidade — de toda instalação
# self-host, não só do CI. `browserSessionIntegration` é default do SDK e
# `integrations: [x]` SOMA aos defaults, então a política "no Sentry da comunidade,
# só erro" estava declarada e não estava em vigor. Detalhe em `lib/sentry/dsn.ts`;
# o gate é `tests/unit/sentry-comunidade-so-erro.test.ts`.
#
# O ingest respondia `429` com `x-sentry-rate-limits: 60::organization:suspended` —
# lista de categorias vazia, isto é, TODAS: a organização estava suspensa por cota, e
# cada tentativa barrada virava erro de console, que é o que esta spec cobra.
#
# FONTE DA VERDADE do ambiente da suíte é `scripts/gerar-env-e2e.sh` (o `.env.e2e`
# que o passo "Publicar" joga no `$GITHUB_ENV`, e que vale também para quem roda
# local). As três linhas `SENTRY_DSN: "off"` de nível de passo FICAM como segunda
# camada — `env:` de passo vence o `$GITHUB_ENV`, então elas protegem o CI se o
# gerador mudar. Duas fontes que CONCORDAM e uma delas declarada como principal;
# apagar as do colega para "limpar duplicação" seria trocar defesa por estética.
#
# Três hipóteses minhas caíram por medição, e o custo delas foi maior que o do
# conserto. As duas primeiras foram descartadas na época:
#
# 1. "a parte 2 está sobrecarregada" — caiu de 69 para 53 testes, falha byte-idêntica.
# 2. "as specs anteriores exaurem o contador" — posta em PRIMEIRO lugar, contador
# limpo, as mesmas 7 telas deram 429, inclusive a primeira.
# 3. "é o fallback em memória do limitador" — foi o que este comentário afirmou como
# "a pista que sobra". MEDIDO no job real: 120 quedas para memória, 103 delas no
# bucket `auth:login:ip` contra teto de 1000. O limitador não barrou nada.
#
# A lição operacional é sobre INSTRUMENTO, não sobre limitador: a mensagem do browser
# para requisição barrada é "Failed to load resource: ... 429" e não diz QUEM
# respondeu; a spec descartava `m.location()` e o relatório não guarda trace, então a
# única cópia do endereço morria ali. Três runs foram gastos adivinhando o dono de um
# 429 cuja URL o próprio teste tinha em mãos. A spec agora registra a URL.
#
# `AUTH_RATE_LIMIT_LOGIN_IP` segue existindo pelo motivo dele (o teto por IP no CI é
# um runner só) e não teve nada a ver com isto. Afrouxar o limitador do produto para
# acomodar teste segue sendo a troca errada — e aqui não foi preciso.
#
# A ordem das listas voltou ao que era: mudá-la foi tentativa que não se sustentou,
# e deixar a mudança de pé com um comentário dizendo "hipótese" seria pior — o
# próximo leitor herdaria uma decisão tomada por um motivo medido FALSO.
#
# O rebalanceamento das duas partes FICA, por outro motivo e sem alegar mais do que
# faz: 6,5 + 8,1 min em vez de 2,7 + 12,2.
env:
SPECS_PARTE_1: >-
smoke.spec.ts auth.spec.ts error-pages.spec.ts
password-recovery.spec.ts signup-journey.spec.ts
rbac-roles.spec.ts inbox-scope.spec.ts reset-password-mfa.spec.ts
degradacao-silenciosa.spec.ts vps-webhook-outbound-ssrf.spec.ts
kanban-owner-filter.spec.ts queue-assign.spec.ts
conversa-vira-lead.spec.ts contato-salva-email.spec.ts
confirmar-dado-do-contato.spec.ts
risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts
agente-papeis-operador.spec.ts prova-painel-provedores.spec.ts
followup-linguagem.spec.ts followup-ramos.spec.ts
SPECS_PARTE_2: >-
agente-novo-e-uso.spec.ts agente-organiza-operacao.spec.ts
central-de-avisos-capacidades.spec.ts escalacao-ciclo.spec.ts
navegacao.spec.ts olhar-telas-do-epico.spec.ts pipelines-gestao.spec.ts
qa-agente-usa-as-maos.spec.ts qa-selo-no-funil-usado.spec.ts
qa-telas-descobertas-w4.spec.ts retorno-anti-morte.spec.ts
followup-builder.spec.ts followup-queue.spec.ts
distribuicao-atendimento.spec.ts followup-journey.spec.ts
webhooks.spec.ts
capacidades-do-agente.spec.ts
escopo-de-funil-do-agente.spec.ts
followup-dossie.spec.ts followup-tempo-adaptativo.spec.ts
gatilho-de-etapa.spec.ts gatilho-de-caso.spec.ts
# Cada item aqui tem o motivo MEDIDO, não presumido — e o gate cobra que a
# lista exista e some com as outras duas.
#
# `vps-fresh-onboarding`: 8 referências a WAHA/Resend/Nuvemshop/Redis. É a P0
# da doutrina de QA Visual e fica fora por dependência de infra.
#
# As CINCO specs da missão "Follow-up Vivo" (`followup-linguagem`,
# `followup-ramos`, `followup-dossie`, `followup-tempo-adaptativo`,
# `gatilho-de-etapa`) entraram nas listas de execução, e não aqui, porque a
# medição não dá motivo para excluí-las: grep de waha/resend/nuvemshop/redis
# devolve **0 em todas as cinco** (controle positivo: `vps-fresh-onboarding`
# devolve 17). Usam o endpoint de cron, como a `followup-journey` que já roda.
#
# ⚠️ O QUE NÃO ESTÁ MEDIDO: elas passam LOCALMENTE, e verde local não é verde
# de CI. Nenhuma delas rodou num job ainda — foram escritas nesta missão e
# ficaram fora de todas as listas, isto é, existiam e nenhum job as invocava.
# A primeira execução do `e2e` neste PR é que decide; se alguma reprovar por
# ambiente, o lugar dela é aqui embaixo COM o motivo medido, nunca uma
# exclusão preventiva "por garantia" — que é exatamente a dívida entrando
# pela porta de serviço que este bloco existe para barrar.
#
# `prova-painel-provedores` esteve aqui e SAIU: em 2026-08-08 o caso F3 exigia
# mais de 50 modelos da OpenRouter e o catálogo chegava ao CI com 2 linhas — a
# spec media a sorte do ambiente. `scripts/seed-e2e-catalogo-openrouter.ts`
# (main, 2026-08-09) semeia o catálogo no job, então a condição que a excluía
# deixou de existir e ela voltou para a lista de execução.
FORA_DO_CI: >-
vps-fresh-onboarding.spec.ts
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: supabase/setup-cli@v3
with:
version: latest
# Sobe Postgres + Auth (GoTrue) + PostgREST + Storage. Auth de verdade é
# requisito: os specs de login exercitam o GoTrue, não um mock.
#
# A pasta de migrations sai do caminho ANTES do start: a cadeia não sobe
# em banco novo (quebra na 0010, `alter table public.contacts` sobre uma
# tabela que ainda não existe) — está no CLAUDE.md e foi confirmado neste
# CI. O que o self-hoster aplica é o `baseline.sql`, então é ele que o
# e2e aplica: o banco do teste fica igual ao de quem instalou o produto.
- name: Subir Supabase local (sem a cadeia de migrations)
run: |
mv supabase/migrations /tmp/migrations-off
mkdir -p supabase/migrations
supabase start
# O baseline é um dump `--schema-only`: ele USA os tipos das extensões
# (public.vector, citext, gin_trgm_ops) mas não as cria. O stack local do
# Supabase traz os binários e não as habilita — mesmo prelúdio que o
# scripts/test-db.sh faz para o Postgres cru, só a parte de extensões
# (roles e schema auth já vêm prontos aqui).
- name: Habilitar extensões exigidas pelo baseline
run: |
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -v ON_ERROR_STOP=1 -q <<'SQL'
create schema if not exists extensions;
create extension if not exists "uuid-ossp" with schema extensions;
create extension if not exists pgcrypto with schema extensions;
create extension if not exists vector with schema public;
create extension if not exists citext with schema public;
create extension if not exists pg_trgm with schema public;
SQL
- name: Aplicar o baseline.sql (o mesmo que o install.sh do kit aplica)
run: |
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" \
-v ON_ERROR_STOP=1 -q -f supabase/baseline.sql
- name: Exportar credenciais do stack local
run: |
supabase status -o env > /tmp/sb.env
{
echo "NEXT_PUBLIC_SUPABASE_URL=$(grep '^API_URL=' /tmp/sb.env | cut -d'"' -f2)"
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=$(grep '^ANON_KEY=' /tmp/sb.env | cut -d'"' -f2)"
echo "SUPABASE_SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' /tmp/sb.env | cut -d'"' -f2)"
} >> "$GITHUB_ENV"
# O `.env.e2e` é o ambiente da suíte, e ele NASCE aqui — não é opcional.
#
# `playwright.config.ts` passou a exigi-lo e a falhar alto quando falta,
# de propósito: sem ele o `next start` carrega o `.env.local`, que num
# checkout de trabalho aponta para PRODUÇÃO. O modo de falha que isso
# substitui é a suíte escrever organizações e usuários de teste no banco
# real, passando verde.
#
# No CI o `.env.local` aponta para o Supabase local, então o risco não é o
# mesmo — mas o contrato é único, e um workflow que contorna o gate seria
# o começo de ele não valer para ninguém.
- name: Gerar o .env.e2e (ambiente da suíte)
run: pnpm e2e:env
# E publica o arquivo no ambiente do JOB, para que servidor e testes leiam
# literalmente a mesma fonte.
#
# Sem isto havia duas: o `.env.e2e` (que o playwright.config injeta no
# `next start`) e um bloco `env:` copiado à mão em cada passo de teste. As
# duas divergiram — `INTERNAL_SECRET` valia `e2e-placeholder…` no servidor
# e `ci-placeholder…` no processo de teste. Sintoma medido: 401 em toda
# chamada a rota interna, 8 specs vermelhas (6 de system-update no
# heartbeat, o drain do anti-SSRF, e o invite-lifecycle).
#
# Medido no Playwright 1.5x: `webServer.env` MESCLA com o ambiente herdado
# (uma var só do shell chega ao servidor) e VENCE nas colisões. Ou seja,
# copiar valores à mão nunca ia empatar com o arquivo — só uma fonte
# empata.
#
# De brinde, o processo de teste passa a ver as chaves de CIFRA de verdade
# (32 bytes, geradas pelo script) em vez do rótulo de 21 caracteres que o
# bloco `env:` fixava e que o app recusa.
- name: Publicar o .env.e2e no ambiente do job
run: grep -vE '^[[:space:]]*(#|$)' .env.e2e >> "$GITHUB_ENV"
# `pnpm e2e:build`, não `pnpm build`: as três `NEXT_PUBLIC_*` são embutidas
# no BUNDLE durante o build. Buildar com um env e trocar só no `next start`
# deixaria a URL errada dentro do JavaScript que roda no browser — servidor
# falando com um banco e cliente com outro, no mesmo teste.
- name: Build de produção (com o ambiente do E2E)
run: pnpm e2e:build
env:
NEXT_TELEMETRY_DISABLED: "1"
# `off`, NÃO vazio. Vazio seleciona o Sentry DA COMUNIDADE — está
# escrito em lib/sentry/dsn.ts:8-10, e é o default de propósito num
# produto self-host. Só "off" desliga.
#
# Sem isto, o SDK do browser tuneliza por /monitoring (next.config.ts:71)
# para o Sentry real do projeto, que devolve 429 por cota. Os 429 caem
# no console, e `olhar-telas-do-epico` — a ÚNICA spec que afirma "não
# cospe erro no console" — reprova nas 7 telas de uma vez.
# Medido em 2026-08-10 capturando a URL: /monitoring?o=4509908078559232,
# que é o org id do DEFAULT_SENTRY_DSN (lib/sentry/dsn.ts:17).
SENTRY_DSN: "off"
- name: Instalar o browser
run: pnpm exec playwright install --with-deps chromium
# `.env.local` é CÓPIA do `.env.e2e`, não uma terceira redação dele.
#
# O arquivo precisa existir porque alguns seeds leem `.env.local` do disco,
# não de `process.env`. Ele era montado à mão aqui, com os valores
# redigitados — e foi a terceira fonte do mesmo `INTERNAL_SECRET`, com o
# terceiro valor. Copiar não pode divergir; redigitar já divergiu.
#
# No CI as duas apontam para o mesmo stack local, então a cópia não
# reintroduz o risco que o `.env.e2e` existe para evitar (o `.env.local` de
# um checkout de trabalho apontar para produção).
#
# O seed cria 1 org + 4 usuários com os 4 papéis e um TOTP verified de
# secret conhecido no admin — sem ele, todo spec que loga fica de fora, que
# era a maior parte da suíte.
- name: Semear credenciais de teste
run: |
cp .env.e2e .env.local
pnpm exec tsx scripts/seed-e2e-credentials.ts
# `SUPABASE_DB_URL` no `.env.local` (e não só no `env:` do passo de teste):
# `seed-e2e-escalacao.ts` abre um `pg.Pool` direto, e o `.env.local` é a
# única fonte que ele lê. Sem a linha, o `pg` cai no default 5432 e morre
# com ECONNREFUSED — o stack local do Supabase publica o Postgres na 54322.
# Medido: foi exatamente assim que a primeira execução deste passo falhou.
#
# A maioria das specs roda o próprio seed num `beforeAll`. Estas TRÊS não
# — ou o cabeçalho manda rodar à mão, ou a fixture é de outra spec.
#
# `seed-e2e-followup-agent` (credencial de IA + sessão de canal) é o caso
# sutil: `followup-builder` o roda no próprio `beforeAll`, mas
# `agente-novo-e-uso` PRECISA das mesmas fixtures e vem ANTES na ordem
# alfabética — em banco fresco os comboboxes de credencial e canal abrem
# com a opção desabilitada e o clique estoura por timeout. Medido: o
# ensaio local passou porque o banco tinha as fixtures de uma rodada
# anterior; o CI, fresco, reprovou. Rodar aqui torna a precondição
# explícita em vez de depender da ordem dos arquivos.
#
# `--env-file` só na segunda, e a diferença não é capricho: o seed de
# capacidades importa `lib/env.ts` (via lib/ai/embed.ts), que lê
# `process.env`; o de escalação parseia o `.env.local` por conta própria.
# Sem a flag o de capacidades morre na validação Zod das 3 vars do Supabase.
- name: Semear as fixtures que as specs não semeiam sozinhas
run: |
pnpm exec tsx scripts/seed-e2e-escalacao.ts
pnpm exec tsx --env-file=.env.local scripts/seed-e2e-capacidades-ausentes.ts
pnpm exec tsx scripts/seed-e2e-followup-agent.ts
# O catálogo da OpenRouter não existe em banco fresco (o baseline
# semeia só anthropic/openai/google; os demais vêm do cron diário, que
# busca na internet). Sem esta linha, `prova-painel-provedores` mede a
# sorte do ambiente em vez da tela.
pnpm exec tsx scripts/seed-e2e-catalogo-openrouter.ts
# O que ficou DE FORA (e por quê) é declarado no passo de summary —
# cobertura parcial silenciosa se lê como cobertura total.
#
# `next start` roda com NODE_ENV=production, e aí toda var marcada
# `required()` no lib/env.ts vira obrigatória, inclusive as de serviço que
# estes specs nem tocam. Os valores abaixo são PLACEHOLDERS FALSOS, só
# para o boot passar da validação Zod — nenhum segredo real entra aqui.
# DUAS INVOCAÇÕES, e não uma — o motivo é medido, não estético.
#
# `lib/auth/rate-limit.ts` limita login a 60 por IP a cada 300s. No CI todo
# spec loga do mesmo 127.0.0.1, e ao subir a suíte de 15 para 29 specs o
# pico passou a ser 69–73 tentativas na janela: a suíte estourava o próprio
# teto do produto. O sintoma não é honesto — o login simplesmente não
# redireciona, e a spec sorteada pelo momento da saturação é a que falha.
# Foi assim que `risk-radar` caiu na main e `rbac-roles` num PR, sem
# nenhuma das duas ter mudado.
#
# Sem Upstash configurado, o limitador cai para memória DO PROCESSO (o log
# diz: "redis incr failed; falling back to in-memory"), e o
# `reuseExistingServer: false` do playwright.config.ts faz cada invocação
# subir o SEU `next start`. Duas invocações = dois processos = dois
# contadores, e o pico por processo volta para ~40.
#
# A alternativa seria afrouxar o teto por env — mexer num controle de
# segurança para acomodar o teste, que é a troca errada.
#
# `--workers=1` não é preciosismo: os specs compartilham UMA conta admin
# com MFA, e código TOTP é de uso único por janela no GoTrue. Com dois
# workers, dois specs gerando o mesmo código no mesmo intervalo de 30s
# fazem o segundo ser recusado como replay. Foi o que derrubava a
# `reset-password-mfa` (issue #80): ela passa sozinha e falha em paralelo.
- name: E2E — parte 1 de 2
run: pnpm exec playwright test --workers=1 $SPECS_PARTE_1
env:
# Só o que NÃO vem do `.env.e2e`: isto é ajuste de CI, não config do
# produto. CI = 1 IP para todos os specs, e o teto de produção
# (60/5min) é irreal aqui. O teto por CONTA (5 falhas) NÃO muda — é
# ele que barra brute force.
AUTH_RATE_LIMIT_LOGIN_IP: "1000"
# ver o comentário do passo de build: vazio NÃO desliga.
SENTRY_DSN: "off"
- name: E2E — parte 2 de 2 (processo novo, contador de login zerado)
run: pnpm exec playwright test --workers=1 $SPECS_PARTE_2
env:
# Só o que NÃO vem do `.env.e2e`: isto é ajuste de CI, não config do
# produto. CI = 1 IP para todos os specs, e o teto de produção
# (60/5min) é irreal aqui. O teto por CONTA (5 falhas) NÃO muda — é
# ele que barra brute force.
AUTH_RATE_LIMIT_LOGIN_IP: "1000"
# ver o comentário do passo de build: vazio NÃO desliga.
SENTRY_DSN: "off"
- name: Declarar o que este job ainda NÃO cobre
if: always()
run: |
# CONTA, não afirma. A versão anterior deste passo era prosa digitada à
# mão e dizia "32 de 33" com 39 arquivos no disco — a única coisa que
# existia para declarar a lacuna estava, ela mesma, desatualizada.
NO_DISCO=$(ls tests/e2e/*.spec.ts | wc -l | tr -d ' ')
RODOU=$(echo $SPECS_PARTE_1 $SPECS_PARTE_2 | wc -w | tr -d ' ')
FORA=$(echo $FORA_DO_CI | wc -w | tr -d ' ')
{
echo "## E2E — cobertura deste job"
echo ""
echo "**Rodou: ${RODOU} de ${NO_DISCO} specs.**"
echo ""
echo "**Não rodou (${FORA}):**"
for s in $FORA_DO_CI; do echo "- \`${s}\` — precisa de WAHA + Redis + Resend + Nuvemshop (P0 da doutrina de QA Visual)"; done
echo ""
echo "A soma é conferida mecanicamente por \`tests/unit/e2e-cobertura-completa.test.ts\`:"
echo "spec no disco que não esteja em nenhuma das três listas reprova o build."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7