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>
This commit is contained in:
1 parent
d43f370359
commit
ca8a1112f0
13 files changed
+835
-126
No files matched your search
@@ -0,0 +1,259 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user