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>
260 lines
11 KiB
TypeScript
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);
|
|
});
|