Files
camilafagundesandClaude Opus 5 ca8a1112f0 Corrige quatro defeitos de runtime do worker e dos scripts operacionais
Os quatro apareceram ao exercitar o layout de runtime da imagem — apenas
node_modules de produção, src, scripts e migrations — e nenhum aparecia na
suíte de testes nem no build. Os dois primeiros formavam uma armadilha mútua.

1. O CLI do `tsx` cria processo filho (PIDs medidos: 20840 contra 21096).
   O SIGTERM do `docker stop` pararia no pai, o worker nunca rodaria o
   handler, o lock esperaria o TTL de 120s e o `stop_grace_period` só
   atrasaria um SIGKILL. Passa a ser `node --import tsx`, mesmo processo.
2. Top-level await nos scripts falha com `--import tsx`, porque sem
   `"type": "module"` o esbuild gera CJS. `migrate`, `worker`, `seed-staging`,
   `flag`, `cleanup` e `check-bucket` não executariam: a migração não rodaria
   e o worker não subiria. `runScript`/`runWithDb` resolvem sem mudar o
   module type do projeto.
3. `import "server-only"` em `worker-loop.ts`: o pacote lança fora do bundler
   do Next, e o worker morria no start.
4. `@react-pdf/renderer` carregado no import de `process-job.ts` falha com
   ERR_PACKAGE_PATH_NOT_EXPORTED ao resolver a hifenização fora do bundler,
   derrubando worker e cleanup. O PDF sai sob demanda no download, então o
   import passou a ser tardio, dentro do ramo que o usa.

Além disso:

- O loop do worker saiu do script para `worker-loop.ts`, testável sem subir
  processo. O script cuida só de sinais, cadência e bootstrap.
- Desligamento gracioso espera o tick corrente para o `finally` liberar o
  lock. Um job que ainda não começou fica `queued`: nada pela metade.
- Heartbeat por tick, lido pelo healthcheck do contêiner. Processo vivo mas
  travado passa em qualquer verificação de PID.
- O worker recusa iniciar com migration pendente, e relê o contexto do dono a
  cada job: papel e feature flag podem ter mudado desde o enfileiramento.
- `verify-config`, `check-bucket`, `seed-staging` e `smoke-staging` novos. O
  smoke tem fase autenticada que só roda com credencial fornecida pela
  operação e, sem ela, reporta PENDENTE em vez de aprovado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:15 -03:00

260 lines
11 KiB
TypeScript

import { runScript } from "./_bootstrap";
/**
* Smoke de homologação contra o ambiente em pé.
*
* Fase 1 (sempre): saúde, recusa sem sessão, cabeçalhos, ausência de dev auth.
* Fase 2 (só com credencial fornecida): login real no Supabase Auth de
* homologação, resolução de workspace e leitura autenticada do módulo.
*
* Nenhuma credencial é inventada aqui. A fase 2 só roda quando a operação
* fornece as variáveis abaixo, com uma conta criada no painel do Supabase:
*
* ADAPTER_SMOKE_URL URL pública, ex. https://hml-ada.elopro.app
* ADAPTER_SMOKE_EMAIL e-mail de uma conta de teste de homologação
* ADAPTER_SMOKE_PASSWORD senha dessa conta
* ADAPTER_SMOKE_WORKSPACE (opcional) UUID do workspace esperado
*
* Sem ADAPTER_SMOKE_EMAIL/PASSWORD a fase 2 é reportada como PENDENTE, nunca
* como aprovada. O envio de planilha, o processamento e o download seguem no
* roteiro manual do runbook: exigem arquivo e a flag do módulo ligada.
*/
type Check = { name: string; ok: boolean; detail: string; pending?: boolean };
const base = (process.env.ADAPTER_SMOKE_URL ?? "").replace(/\/$/, "");
const email = process.env.ADAPTER_SMOKE_EMAIL;
const password = process.env.ADAPTER_SMOKE_PASSWORD;
const expectedWorkspace = process.env.ADAPTER_SMOKE_WORKSPACE;
if (!base) {
console.error("Defina ADAPTER_SMOKE_URL com a URL pública do ambiente.");
process.exit(1);
}
const checks: Check[] = [];
function record(name: string, ok: boolean, detail: string) {
checks.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`);
}
function pending(name: string, detail: string) {
checks.push({ name, ok: false, detail, pending: true });
console.log(`PENDENTE ${name} — ${detail}`);
}
async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown; response: Response }> {
const response = await fetch(`${base}${path}`, {
redirect: "manual",
...init,
headers: { accept: "application/json", ...(init?.headers ?? {}) },
});
const text = await response.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
/* resposta não-JSON entra como texto */
}
return { status: response.status, body, response };
}
/** Junta os cookies de Set-Cookie para a próxima requisição. */
function collectCookies(response: Response, jar: Map<string, string>): void {
const raw = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
for (const line of raw) {
const [pair] = line.split(";");
const index = pair.indexOf("=");
if (index > 0) jar.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
}
}
function cookieHeader(jar: Map<string, string>): string {
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
runScript("smoke-staging", async () => {
// ---------------------------------------------------------------- fase 1
console.log(`== fase 1: ambiente em ${base} ==`);
try {
const live = await fetchJson("/api/health/live");
const body = live.body as { status?: string };
record("health/live", live.status === 200 && body?.status === "live", `status ${live.status}`);
} catch (error) {
record("health/live", false, error instanceof Error ? error.message : "falha de rede");
}
let environment: string | undefined;
try {
const ready = await fetchJson("/api/health/ready");
const body = ready.body as {
status?: string;
environment?: string;
checks?: Record<string, string>;
reasons?: Record<string, string>;
configIssues?: string[];
};
environment = body?.environment;
record(
"health/ready",
ready.status === 200 && body?.status === "ready",
`status ${ready.status} checks=${JSON.stringify(body?.checks ?? {})} reasons=${JSON.stringify(body?.reasons ?? {})}`,
);
record("ambiente é staging", body?.environment === "staging", `environment=${body?.environment ?? "?"}`);
record(
"sem pendência de configuração",
(body?.configIssues ?? []).length === 0,
(body?.configIssues ?? []).join("; ") || "nenhuma",
);
} catch (error) {
record("health/ready", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const anon = await fetchJson("/api/spreadsheet-adapter/models");
record("API sem sessão recusa", anon.status === 401, `status ${anon.status}`);
} catch (error) {
record("API sem sessão recusa", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const page = await fetch(`${base}/adaptador`, { redirect: "manual" });
const location = page.headers.get("location") ?? "";
record(
"/adaptador sem sessão redireciona ao login",
page.status >= 300 && page.status < 400 && location.includes("/login"),
`status ${page.status} location=${location || "(vazio)"}`,
);
} catch (error) {
record("/adaptador sem sessão redireciona ao login", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const response = await fetch(`${base}/api/health/live`, { redirect: "manual" });
const required = [
"content-security-policy",
"strict-transport-security",
"x-content-type-options",
"x-frame-options",
"referrer-policy",
"permissions-policy",
];
const missing = required.filter((header) => !response.headers.get(header));
record("cabeçalhos de segurança", missing.length === 0, missing.length ? `faltam: ${missing.join(", ")}` : "todos presentes");
} catch (error) {
record("cabeçalhos de segurança", false, error instanceof Error ? error.message : "falha de rede");
}
// Dev auth não pode existir em homologação pública, em nenhuma circunstância.
try {
const dev = await fetch(`${base}/api/dev/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: "00000000-0000-4000-8000-000000000000",
workspaceId: "00000000-0000-4000-8000-000000000000",
}),
});
record("dev auth indisponível", dev.status === 404, `status ${dev.status}`);
} catch (error) {
record("dev auth indisponível", false, error instanceof Error ? error.message : "falha de rede");
}
// ---------------------------------------------------------------- fase 2
console.log("");
console.log("== fase 2: login real no Supabase Auth de homologação ==");
if (!email || !password) {
pending(
"login real",
"defina ADAPTER_SMOKE_EMAIL e ADAPTER_SMOKE_PASSWORD de uma conta criada no painel do Supabase de homologação",
);
pending("resolução de workspace autenticada", "depende do login real");
pending("leitura autenticada do módulo", "depende do login real");
} else {
const jar = new Map<string, string>();
let logged = false;
try {
const login = await fetch(`${base}/api/auth/staging/session`, {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
collectCookies(login, jar);
logged = login.status === 200 && jar.size > 0;
record("login real", logged, `status ${login.status} cookies=${jar.size}`);
} catch (error) {
record("login real", false, error instanceof Error ? error.message : "falha de rede");
}
if (logged) {
try {
const workspaces = await fetchJson("/api/auth/workspace", { headers: { cookie: cookieHeader(jar) } });
const body = workspaces.body as { memberships?: { workspaceId: string; workspaceName: string; role: string }[] };
const list = body?.memberships ?? [];
record(
"resolução de workspace autenticada",
workspaces.status === 200 && list.length > 0,
`status ${workspaces.status} associações=${list.length}`,
);
if (expectedWorkspace) {
record(
"workspace esperado está associado",
list.some((item) => item.workspaceId.toLowerCase() === expectedWorkspace.toLowerCase()),
`esperado=${expectedWorkspace}`,
);
}
if (list.length > 0) {
const chosen = expectedWorkspace ?? list[0].workspaceId;
const select = await fetch(`${base}/api/auth/workspace`, {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json", cookie: cookieHeader(jar) },
body: JSON.stringify({ workspaceId: chosen }),
});
collectCookies(select, jar);
record("seleção de workspace aceita", select.status === 200, `status ${select.status}`);
}
} catch (error) {
record("resolução de workspace autenticada", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const models = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: cookieHeader(jar) } });
// 200 com o módulo ligado; 403 FEATURE_DISABLED é resposta correta
// enquanto a feature flag do workspace continua desligada.
const body = models.body as { error?: { code?: string } };
const aceitavel = models.status === 200 || body?.error?.code === "FEATURE_DISABLED";
record(
"leitura autenticada do módulo",
aceitavel,
`status ${models.status} code=${body?.error?.code ?? "ok"}`,
);
} catch (error) {
record("leitura autenticada do módulo", false, error instanceof Error ? error.message : "falha de rede");
}
} else {
pending("resolução de workspace autenticada", "login não concluiu");
pending("leitura autenticada do módulo", "login não concluiu");
}
}
// ---------------------------------------------------------------- resumo
const failed = checks.filter((check) => !check.ok && !check.pending);
const pendentes = checks.filter((check) => check.pending);
console.log("");
console.log(`${checks.length - failed.length - pendentes.length}/${checks.length} aprovadas, ${pendentes.length} pendentes, ${failed.length} falhas.`);
console.log(`ambiente reportado: ${environment ?? "desconhecido"}`);
console.log("");
console.log("Continua fora deste script e obrigatório no runbook:");
console.log(" - envio de modelo e de fonte pela interface");
console.log(" - processamento assíncrono com o worker e download XLSX, CSV e PDF");
console.log(" - troca de workspace e sessão expirada no navegador");
console.log(" - evento controlado no Sentry, confirmado sem conteúdo de célula");
console.log(" - ensaio de rollback de imagem");
if (failed.length > 0) process.exit(1);
});