fix(e2e): a suite escrevia no banco de PRODUCAO — tres camadas de conserto

Medido em 2026-08-06. `.env.local` de um checkout de trabalho aponta para a
nuvem, e o playwright sobe o app com `next start`, que o carrega. A suite criava
organizacoes, usuarios e agentes de teste no banco real, passando verde.

A org `e2e-test-org` esta na producao deste projeto desde 2026-04-29. Ela nao
chegou la por acidente de uma sessao: chegou porque esse era o comportamento
normal da suite.

ISOLAR O webServer NAO BASTAVA, e essa foi a descoberta. Os scripts de seed leem
`.env.local` DIRETO DO DISCO, ignorando process.env — entao o env injetado no
servidor nunca os alcancava, e as specs chamam esses seeds sozinhas no meio do
teste. O sintoma que denunciou: o factor TOTP em `.e2e-creds.json` nao existia no
banco local, porque tinha sido criado na nuvem. 93 arquivos tem esse padrao.

As tres camadas:
  1. `.env.e2e` (gerado por `pnpm e2e:env`, nao versionado) + injecao explicita
     no webServer, com guard que RECUSA rodar se o arquivo faltar — cair no
     .env.local em silencio e o modo de falha caro;
  2. `pnpm e2e:build`, porque as tres NEXT_PUBLIC_* sao embutidas no BUNDLE:
     buildar com o env errado e trocar so no start deixaria a URL de producao
     dentro do JavaScript do browser. `node --env-file` nao serve aqui — o next
     build cria Workers e o Node recusa propagar a flag
     (ERR_WORKER_INVALID_EXEC_ARGV). O script prova as DUAS direcoes: host de
     producao ausente do bundle E host local presente (sem o controle positivo,
     "nao achei producao" pode ser um grep que nao acha nada);
  3. `scripts/lib/env-de-teste.ts` — process.env VENCE o arquivo, e todo seed
     ANUNCIA o destino ("escrevendo em LOCAL" / "⚠️ REMOTO"). Um seed que escreve
     em producao acha os mesmos dados de teste de sempre e termina com "✅ Seed
     completo": sem a linha impressa, nada distingue os dois casos.

`seed-e2e-credentials.ts` migrado (e o que a suite chama sempre). Os outros ~14
seeds e as ~78 sondas ficam declarados como divida no handoff.

GANHO ESTRUTURAL do worktree dedicado: aqui nao existe `.env.local`, entao um
script que o leia do disco falha ALTO (ENOENT) em vez de escrever na nuvem. O
isolamento deixa de depender de disciplina.

Evidencia observada: spec agente-novo-e-uso 5/5 passed contra o banco LOCAL
(era 0/5 antes, por MFA — o factor do banco nao batia com o do arquivo);
suite unit 1467 passed (155 arquivos); typecheck limpo; guard do playwright
recusa sem .env.e2e (verificado).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7
This commit is contained in:
Rafael Melgaço
2026-08-06 10:41:41 -03:00
co-authored by Claude Opus 5
parent 6c75553272
commit 783ee85b5d
11 changed files with 320 additions and 16 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+2
View File
@@ -19,6 +19,8 @@
"db:migrate": "echo 'TODO: wire supabase db push or pg-migrate' && exit 0",
"db:reset": "supabase db reset",
"test:e2e": "playwright test",
"e2e:build": "bash scripts/e2e-build.sh",
"e2e:env": "bash scripts/gerar-env-e2e.sh",
"test:journeys": "playwright test -c tests/journeys/playwright.config.ts",
"test:unit": "vitest run",
"test:db": "bash scripts/test-db.sh",
+47
View File
@@ -1,5 +1,42 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "@playwright/test";
/**
* Lê o `.env.e2e` — o ambiente LOCAL da suíte.
*
* Falha ALTO se o arquivo não existir, em vez de deixar o app cair no
* `.env.local`: o modo de falha silencioso aqui é a suíte rodar contra o banco
* de PRODUÇÃO, que foi exatamente o que acontecia antes deste arquivo existir
* (medido em 2026-08-06).
*/
function envDoE2E(): Record<string, string> {
let bruto: string;
try {
bruto = readFileSync(".env.e2e", "utf8");
} catch {
throw new Error(
"Falta o .env.e2e — rode `pnpm e2e:env` (precisa do Supabase local de pé).\n" +
"Sem ele o app sob teste carregaria o .env.local, que aponta para PRODUÇÃO.",
);
}
const env: Record<string, string> = {};
for (const linha of bruto.split("\n")) {
const limpa = linha.trim();
if (limpa === "" || limpa.startsWith("#")) continue;
const i = limpa.indexOf("=");
if (i <= 0) continue;
env[limpa.slice(0, i)] = limpa.slice(i + 1);
}
const url = env.NEXT_PUBLIC_SUPABASE_URL ?? "";
// Um `.env.e2e` apontando para fora do localhost é pior que nenhum, porque
// parece seguro.
if (!url.startsWith("http://127.0.0.1") && !url.startsWith("http://localhost")) {
throw new Error(`.env.e2e aponta para um Supabase que não é local (${url}) — recusado.`);
}
return env;
}
// Porta do dev server sob teste. Default 3001; sobrescreva com E2E_PORT quando
// a 3001 já estiver ocupada por outro checkout/worktree.
const PORT = process.env.E2E_PORT ?? "3001";
@@ -35,6 +72,16 @@ export default defineConfig({
// Produção (`next build` antes!): dev-server compila por rota (40-80s) e
// Turbopack dev quebra cookies() fora do request scope — inviável p/ e2e.
command: `pnpm exec next start --port ${PORT}`,
// O ambiente do servidor sob teste vem do `.env.e2e`, INJETADO aqui — e não
// do `.env.local`, que num checkout de trabalho aponta para PRODUÇÃO.
// Variável de ambiente real tem precedência sobre os arquivos `.env*` que o
// Next carrega sozinho, e é isto que impede a suíte de escrever no banco
// real (medido em 2026-08-06: sem esta injeção, ela escrevia).
//
// ⚠️ Isto cobre o SERVIDOR. Os scripts de seed que as specs chamam sozinhas
// liam `.env.local` direto do disco e escapavam daqui — o conserto do outro
// lado é `scripts/lib/env-de-teste.ts`.
env: envDoE2E(),
url: BASE_URL,
// false: reusar um server que já ocupa a porta pode ser OUTRO processo
// (ex.: bundle do Remotion na 3000) — o teste precisa do NOSSO next start.
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# `next build` com o ambiente do E2E (Supabase LOCAL).
#
# ═══ POR QUE NÃO `node --env-file=.env.e2e next build` ═══
#
# Medido: o build morre com
# ERR_WORKER_INVALID_EXEC_ARGV: --env-file= is not allowed in NODE_OPTIONS
# O `next build` cria Workers, e o Node recusa propagar `--env-file` para eles.
# Exportar no shell resolve porque aí são variáveis de ambiente de verdade, que
# os Workers herdam — e que têm precedência sobre os arquivos `.env*` que o Next
# carrega sozinho (é o que impede o `.env.local`, apontado para produção, de
# vencer).
#
# ═══ POR QUE O BUILD PRECISA DISTO E NÃO SÓ O START ═══
#
# As três `NEXT_PUBLIC_*` são embutidas no BUNDLE aqui. Buildar com o
# `.env.local` e depois trocar o env só no `next start` deixaria a URL de
# produção dentro do JavaScript que roda no browser: o servidor falaria com o
# banco local e o cliente com a nuvem, no mesmo teste.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .env.e2e ]; then
echo "==> .env.e2e não existe. Rode 'pnpm e2e:env' (precisa do Supabase local de pé)." >&2
exit 1
fi
# `set -a` exporta tudo que for atribuído até o `set +a`.
set -a
# shellcheck disable=SC1091
. ./.env.e2e
set +a
echo "==> Buildando contra ${NEXT_PUBLIC_SUPABASE_URL}"
pnpm exec next build
# A PROVA, e não a suposição: se a URL de produção sobreviveu em qualquer
# artefato do bundle, o `.env.local` venceu e o teste falaria com a nuvem pela
# metade cliente. Falhar aqui é barato; descobrir depois de escrever no banco
# real, não.
#
# O host vem do PRÓPRIO .env.local, então a guarda continua valendo se alguém
# apontar aquele arquivo para outro projeto.
if [ -f .env.local ]; then
HOST_PROD="$(grep -E '^NEXT_PUBLIC_SUPABASE_URL=' .env.local | cut -d= -f2- | sed -E 's#https?://##; s#/.*##')"
if [ -n "$HOST_PROD" ] && [ "$HOST_PROD" != "127.0.0.1:54321" ]; then
if grep -rqF "$HOST_PROD" .next/static 2>/dev/null; then
echo "==> FALHOU: o bundle do browser contém o host de produção ($HOST_PROD)." >&2
echo " O build pegou o .env.local. NÃO rode a suíte — ela escreveria em produção." >&2
exit 1
fi
echo "==> OK: o host de produção ($HOST_PROD) não aparece no bundle do browser."
fi
fi
# Controle POSITIVO do mesmo grep: se a URL local também não aparecesse, o
# "não achei produção" acima não valeria nada — seria um grep que não acha nada.
HOST_LOCAL="$(printf '%s' "$NEXT_PUBLIC_SUPABASE_URL" | sed -E 's#https?://##; s#/.*##')"
if grep -rqF "$HOST_LOCAL" .next/static 2>/dev/null; then
echo "==> OK (controle): o host local ($HOST_LOCAL) ESTÁ no bundle — o grep está vivo."
else
echo "==> FALHOU (controle): o host local não aparece no bundle." >&2
echo " Sem isto, a ausência de produção acima não prova nada." >&2
exit 1
fi
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Gera o `.env.e2e` — o ambiente da suíte Playwright, apontado para o Supabase
# LOCAL.
#
# ═══ POR QUE ESTE ARQUIVO EXISTE ═══
#
# O `.env.local` de um checkout de trabalho aponta para o Supabase de PRODUÇÃO
# (é com ele que se desenvolve). O `playwright.config.ts` sobe o app com
# `next start`, que carrega `.env.local`. Resultado, medido em 2026-08-06:
# `pnpm test:e2e` escrevia organizações, usuários e agentes de teste **no banco
# real** — o teste passava, e o estrago era invisível.
#
# A saída não é "lembrar de trocar o .env.local antes de testar": é exatamente o
# tipo de disciplina que falha uma vez e ninguém percebe.
#
# ⚠️ ISTO SOZINHO NÃO BASTA. Os scripts de seed liam `.env.local` DIRETO do
# disco, ignorando `process.env` — então nem o env do webServer os alcançava.
# O conserto do outro lado é `scripts/lib/env-de-teste.ts`, que faz `process.env`
# vencer. Os dois juntos é que fecham o caminho.
#
# Uso:
# pnpm e2e:env # (re)cria o .env.e2e
# pnpm e2e:build && pnpm test:e2e # build embute NEXT_PUBLIC_*, ver e2e-build.sh
set -euo pipefail
cd "$(dirname "$0")/.."
if ! npx supabase status >/dev/null 2>&1; then
echo "==> O Supabase local não está de pé. Rode 'npx supabase start' antes." >&2
exit 1
fi
ENVOUT="$(npx supabase status -o env 2>/dev/null)"
ler() { printf '%s\n' "$ENVOUT" | grep "^$1=" | cut -d= -f2- | tr -d '"'; }
API_URL="$(ler API_URL)"
ANON="$(ler ANON_KEY)"
SERVICE="$(ler SERVICE_ROLE_KEY)"
if [ -z "$API_URL" ] || [ -z "$ANON" ] || [ -z "$SERVICE" ]; then
echo "==> Não consegui ler as chaves do stack local (API_URL/ANON_KEY/SERVICE_ROLE_KEY)." >&2
exit 1
fi
# Guarda contra o erro que este arquivo existe para impedir. Se o `supabase
# status` devolver um host remoto (config apontada para um projeto linkado, por
# exemplo), falhar aqui é melhor do que gerar um `.env.e2e` que manda a suíte
# para a nuvem — o modo de falha silencioso é o caro.
case "$API_URL" in
http://127.0.0.1:*|http://localhost:*) ;;
*)
echo "==> RECUSADO: o stack local respondeu com uma URL que não é local: $API_URL" >&2
exit 1
;;
esac
cat > .env.e2e <<EOF
# ── Ambiente do E2E — LOCAL, nunca a nuvem ──────────────────────────────────
# GERADO por 'pnpm e2e:env'. Não versionado (.gitignore cobre '.env*').
# Antes de rodar a suíte: pnpm e2e:build && pnpm test:e2e
NEXT_PUBLIC_SUPABASE_URL=$API_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON
SUPABASE_SERVICE_ROLE_KEY=$SERVICE
SUPABASE_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
# Placeholders: 'next start' roda em NODE_ENV=production, e lib/env.ts exige
# estas vars em produção. As specs não exercitam os serviços por trás delas.
# Valores iguais aos do CI (.github/workflows/e2e.yml), para que local e CI
# falhem pelos mesmos motivos.
INTERNAL_SECRET=e2e-placeholder-nao-e-segredo
CPF_ENCRYPTION_KEY=e2e-placeholder-nao-e-segredo
WAHA_BYO_ENCRYPTION_KEY=e2e-placeholder-nao-e-segredo
AI_CRED_AES_KEY=e2e-placeholder-nao-e-segredo
WAHA_API_BASE_URL=http://127.0.0.1:3999
WAHA_API_KEY=e2e-placeholder-nao-e-segredo
WAHA_WEBHOOK_BASE_URL=http://127.0.0.1:3001
UPSTASH_REDIS_REST_URL=http://127.0.0.1:3998
UPSTASH_REDIS_REST_TOKEN=e2e-placeholder-nao-e-segredo
NEXT_TELEMETRY_DISABLED=1
EOF
echo "==> .env.e2e gerado, apontando para $API_URL"
echo "==> Próximo: pnpm e2e:build && pnpm test:e2e"
+103
View File
@@ -0,0 +1,103 @@
/**
* De onde um script de seed/sonda lê as credenciais do Supabase.
*
* ═══ O DEFEITO QUE ISTO CONSERTA (medido em 2026-08-06) ═══
*
* Os scripts liam `.env.local` **direto do disco**, ignorando `process.env`:
*
* const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
*
* Num checkout de trabalho, `.env.local` aponta para PRODUÇÃO. Consequência: a
* suíte E2E semeava organizações, usuários e agentes de teste no banco real — e
* o `.env.e2e` no `webServer` do Playwright **não alcançava** esses scripts,
* porque eles nunca olharam para o ambiente. O sintoma que denunciou: o factor
* TOTP gravado em `.e2e-creds.json` não existia no banco local, porque tinha
* sido criado na nuvem.
*
* A org `e2e-test-org` está na produção deste projeto desde 2026-04-29. Ela não
* chegou lá por acidente de uma sessão: chegou porque **este era o comportamento
* normal** da suíte.
*
* ═══ A REGRA ═══
*
* `process.env` VENCE o arquivo. É o que permite `set -a; . ./.env.e2e` (ou o
* `env` do Playwright) redirecionar qualquer script sem editar nenhum deles.
* Sem valor no ambiente, cai em `.env.local` — o comportamento de sempre, para
* quem roda uma sonda à mão durante o desenvolvimento.
*/
import fs from "node:fs";
import path from "node:path";
export interface CredenciaisSupabase {
url: string;
serviceRole: string;
/** anon key — alguns seeds fazem signIn como usuário comum (ex.: enroll TOTP). */
anonKey: string;
/** base do app, para links gerados pelo seed. */
appUrl: string;
/** de onde os valores vieram — vai ao log, para o operador não adivinhar. */
origem: "ambiente" | "arquivo";
}
function lerArquivo(arquivo: string): Record<string, string> {
const caminho = path.join(process.cwd(), arquivo);
if (!fs.existsSync(caminho)) return {};
const env: Record<string, string> = {};
for (const linha of fs.readFileSync(caminho, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(linha);
if (m) env[m[1]!] = (m[2] ?? "").replace(/^"(.*)"$/, "$1").trim();
}
return env;
}
/**
* Resolve URL + service role. `process.env` primeiro; `.env.local` depois.
*
* Lança quando não acha — nunca devolve string vazia, que viraria uma chamada
* ao Supabase com credencial vazia e um erro três camadas adiante.
*/
export function credenciaisSupabaseDeTeste(): CredenciaisSupabase {
const doAmbiente = {
url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
serviceRole: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
};
if (doAmbiente.url !== "" && doAmbiente.serviceRole !== "") {
return {
...doAmbiente,
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
origem: "ambiente",
};
}
const arquivo = lerArquivo(".env.local");
const url = arquivo.NEXT_PUBLIC_SUPABASE_URL ?? "";
const serviceRole = arquivo.SUPABASE_SERVICE_ROLE_KEY ?? "";
if (url === "" || serviceRole === "") {
throw new Error(
"Sem credenciais do Supabase: defina NEXT_PUBLIC_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY " +
"no ambiente (ex.: `set -a; . ./.env.e2e; set +a`) ou no .env.local.",
);
}
return {
url,
serviceRole,
anonKey: arquivo.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
appUrl: arquivo.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
origem: "arquivo",
};
}
/**
* Diz em voz alta contra QUAL banco o script vai escrever.
*
* Existe porque o modo de falha caro aqui é silencioso: um seed que escreve na
* produção acha exatamente os mesmos dados de teste de sempre e termina com
* "✅ Seed completo". Uma linha impressa é o que transforma isso em algo que
* alguém pode notar antes de apertar enter na próxima vez.
*/
export function anunciarDestino(script: string, c: CredenciaisSupabase): void {
const local = c.url.startsWith("http://127.0.0.1") || c.url.startsWith("http://localhost");
const rotulo = local ? "LOCAL" : "⚠️ REMOTO";
console.info(`[${script}] escrevendo em ${rotulo}: ${c.url} (origem: ${c.origem})`);
}
+17 -16
View File
@@ -14,20 +14,21 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { generateTotp } from "../tests/e2e/utils/totp";
import { anunciarDestino, credenciaisSupabaseDeTeste } from "./lib/env-de-teste";
// Carrega .env.local manualmente (sem next/env aqui).
const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const SUPABASE_URL = env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_ROLE = env.SUPABASE_SERVICE_ROLE_KEY!;
if (!SUPABASE_URL || !SERVICE_ROLE) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in .env.local");
}
// `process.env` VENCE o `.env.local` (ver scripts/lib/env-de-teste.ts).
//
// A versão anterior lia `.env.local` DIRETO do disco, e por isso a suíte E2E
// semeava org, usuários e agentes no banco de PRODUÇÃO: o `.env.e2e` injetado no
// webServer do Playwright nunca alcançava este script, porque ele não olhava
// para o ambiente. Medido em 2026-08-06 — o factor TOTP em `.e2e-creds.json` não
// existia no banco local porque tinha sido criado na nuvem.
const credenciais = credenciaisSupabaseDeTeste();
anunciarDestino("seed-e2e-credentials", credenciais);
const SUPABASE_URL = credenciais.url;
const SERVICE_ROLE = credenciais.serviceRole;
const ANON_KEY = credenciais.anonKey;
const APP_URL = credenciais.appUrl;
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false },
@@ -204,7 +205,7 @@ async function ensureAdminTotp(adminUserId: string, adminEmail: string): Promise
console.log(`[seed] admin TOTP factor removed (rotating): ${f.id}`);
}
const anon = createClient(SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
const anon = createClient(SUPABASE_URL, ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { error: signInErr } = await anon.auth.signInWithPassword({
@@ -258,9 +259,9 @@ async function main(): Promise<void> {
users,
admin_totp: adminTotp,
default_agent_id: agentId,
app_url: env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
app_url: APP_URL,
supabase_url: SUPABASE_URL,
supabase_anon_key: env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
supabase_anon_key: ANON_KEY,
};
fs.writeFileSync(".e2e-creds.json", JSON.stringify(creds, null, 2));