Files
Ferraz Fagundes bd0ecabf41
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled
test(smoke): adiciona validacao completa do ambiente local
2026-10-17 00:37:09 -03:00

1031 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { inflateRawSync, inflateSync } from "node:zlib";
import ExcelJS from "exceljs";
import postgres from "postgres";
import { runScript } from "./_bootstrap";
/**
* Smoke do compose local de desenvolvimento.
*
* Não substitui `scripts/smoke-staging.ts` e não fala com homologação.
* Grava só depois de provar que a limpeza alcança exclusivamente as linhas
* e os arquivos desta execução. Não aplica migration, não muda feature flag,
* não apaga modelo preexistente e não remove volume.
*/
type Check = { name: string; ok: boolean; detail: string; pending?: boolean };
type HealthBody = {
status?: string;
environment?: string;
checks?: Record<string, string>;
reasons?: Record<string, string>;
configIssues?: string[];
};
type ModelDto = { id?: string; name?: string; format?: string };
type JobDto = {
id?: string;
status?: string;
errorCode?: string | null;
sources?: { id?: string; status?: string }[];
};
type ModelSnap = { id: string; status: string; name: string };
type FlagSnap = { workspace_id: string; module_key: string; enabled: string };
type SettingSnap = { workspace_id: string; allow_non_admin_model_upload: string };
const ALPHA = "11111111-1111-4111-8111-111111111111";
const BETA = "22222222-2222-4222-8222-222222222222";
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const DIEGO = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const WRITE_CHECKS = [
"cadastro de modelo XLSX fictício",
"upload de planilha fictícia",
"mapeamento de colunas",
"pré-visualização",
"processamento pelo worker",
"exportação XLSX, CSV e PDF",
"integridade dos arquivos",
"recurso de outro workspace é recusado",
] as const;
const base = (process.env.ADAPTER_SMOKE_LOCAL_URL ?? "http://127.0.0.1:3000").replace(/\/$/, "");
const checks: Check[] = [];
const serverErrors: string[] = [];
const startedAt = new Date();
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
const modelName = `Smoke local ${stamp}-${suffix}`;
const sentinel = `SMOKELOCAL${suffix.toUpperCase()}`;
const created: { modelId: string; jobIds: string[]; sourceId: string; keys: string[] } = {
modelId: "",
jobIds: [],
sourceId: "",
keys: [],
};
const exportsReport: Record<"xlsx" | "csv" | "pdf", string> = {
xlsx: "não executado",
csv: "não executado",
pdf: "não executado",
};
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}`);
}
function redact(value: string): string {
return value
.replace(/postgres(?:ql)?:\/\/[^\s'"]+/gi, "postgres://***")
.replace(/adaptador_local/g, "***")
.replace(/segredo-apenas-local/g, "***")
.replace(new RegExp(sentinel, "g"), "[conteudo]")
.slice(0, 300);
}
function safeError(error: unknown): string {
return redact(error instanceof Error ? error.message : "erro");
}
function assertUuid(value: string, label: string): void {
if (!UUID.test(value)) throw new Error(`${label} inválido`);
}
function loopbackUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("URL inválida");
}
const host = url.hostname.replace(/^\[|\]$/g, "");
if (url.protocol !== "http:" || (host !== "127.0.0.1" && host !== "localhost")) {
throw new Error("a URL não é HTTP em loopback");
}
return url;
}
function run(command: string, args: string[], timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(command, args, { cwd: process.cwd(), windowsHide: true });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill();
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
if (stdout.length < 200_000) stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
if (stderr.length < 200_000) stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ code: 1, stdout, stderr: error.message });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code: code ?? 1, stdout, stderr });
});
});
}
function compose(args: string[], timeoutMs = 20_000) {
return run("docker", ["compose", "-f", "docker-compose.yml", ...args], timeoutMs);
}
async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown }> {
const headers = new Headers(init?.headers);
if (!headers.has("accept")) headers.set("accept", "application/json");
const response = await fetch(`${base}${path}`, {
redirect: "manual",
...init,
headers,
signal: init?.signal ?? AbortSignal.timeout(30_000),
});
if (response.status >= 500) serverErrors.push(`${init?.method ?? "GET"} ${path} ${response.status}`);
const text = await response.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
/* resposta não-JSON */
}
return { status: response.status, body };
}
async function fetchBytes(path: string, cookie: string): Promise<{ status: number; body: Buffer; contentType: string; disposition: string }> {
const response = await fetch(`${base}${path}`, {
redirect: "manual",
headers: { cookie, accept: "*/*" },
signal: AbortSignal.timeout(60_000),
});
if (response.status >= 500) serverErrors.push(`GET ${path} ${response.status}`);
return {
status: response.status,
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type") ?? "",
disposition: response.headers.get("content-disposition") ?? "",
};
}
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("; ");
}
function errorCode(body: unknown): string {
if (!body || typeof body !== "object" || !("error" in body)) return "";
const code = (body as { error?: { code?: string } }).error?.code;
return code ?? "";
}
async function xlsxFile(rows: string[][]): Promise<Buffer> {
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet("Dados");
for (const row of rows) sheet.addRow(row);
return Buffer.from(await book.xlsx.writeBuffer()) as Buffer;
}
async function xlsxContains(body: Buffer, needle: string): Promise<boolean> {
const book = new ExcelJS.Workbook();
await book.xlsx.load(body as unknown as Parameters<ExcelJS.Workbook["xlsx"]["load"]>[0]);
for (const sheet of book.worksheets) {
for (const row of sheet.getSheetValues()) {
if (!Array.isArray(row)) continue;
for (const cell of row) {
if (cell != null && String(cell).includes(needle)) return true;
}
}
}
return false;
}
function inflatedPdfText(body: Buffer): string[] {
const latin = body.toString("latin1");
const texts: string[] = [];
const marker = /\/FlateDecode\b/g;
let found: RegExpExecArray | null;
while ((found = marker.exec(latin))) {
const window = latin.slice(found.index, found.index + 400);
const stream = /stream\r?\n/.exec(window);
if (!stream) continue;
const dataStart = found.index + stream.index + stream[0].length;
const header = latin.slice(Math.max(0, found.index - 160), dataStart);
const lengthMatch = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(header);
const raw = lengthMatch
? Buffer.from(latin.slice(dataStart, dataStart + Number(lengthMatch[1])), "latin1")
: Buffer.from(latin.slice(dataStart, latin.indexOf("endstream", dataStart)).replace(/\r?\n$/, ""), "latin1");
if (raw.length === 0) continue;
for (const inflate of [inflateSync, inflateRawSync]) {
try {
texts.push(inflate(raw).toString("latin1"));
break;
} catch {
/* o stream pode ser deflate cru ou zlib */
}
}
}
return texts;
}
function pdfContains(body: Buffer, needle: string): boolean {
if (body.subarray(0, 5).toString("latin1") !== "%PDF-") return false;
if (!body.includes(Buffer.from("%%EOF"))) return false;
if (body.includes(Buffer.from(needle))) return true;
const needleHex = Buffer.from(needle, "utf8").toString("hex").toUpperCase();
for (const text of inflatedPdfText(body)) {
if (text.includes(needle)) return true;
const hex = [...text.matchAll(/<([0-9A-Fa-f]+)>/g)].map((item) => item[1]).join("").toUpperCase();
if (hex.includes(needleHex)) return true;
}
return false;
}
function ownedStorageKey(key: string, jobId: string | null): boolean {
const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
if (new RegExp(`^workspaces/${ALPHA}/models/${created.modelId}/original$`, "i").test(key)) return true;
if (!jobId || !UUID.test(jobId)) return false;
return new RegExp(`^workspaces/${ALPHA}/tmp/${jobId}/${uuid}/(?:source|out\\.csv|out\\.xlsx)$`, "i").test(key);
}
type Isolation = {
sql: postgres.Sql;
models: ModelSnap[];
jobs: string[];
flags: FlagSnap[];
settings: SettingSnap[];
outsideModels: number;
outsideJobs: number;
activeModels: number;
names: string[];
};
async function countOf(sql: postgres.Sql | postgres.TransactionSql, text: string, params: unknown[]): Promise<number> {
const rows = await sql.unsafe<{ total: number }[]>(text, params as postgres.ParameterOrJSON<never>[]);
return Number(rows[0]?.total ?? 0);
}
async function prepareIsolation(): Promise<{ isolation: Isolation | null; reason: string }> {
const published = await compose(["port", "postgres", "5432"]);
const mapping = published.stdout.trim();
const hostPort = /^(127\.0\.0\.1|localhost):(\d+)$/.exec(mapping);
if (published.code !== 0 || !hostPort) {
return { isolation: null, reason: "Postgres do compose não está publicado só em loopback; gravação recusada" };
}
const printed = await compose(["exec", "-T", "web", "printenv", "DATABASE_URL"]);
if (printed.code !== 0 || !printed.stdout.trim()) {
return { isolation: null, reason: "não foi possível confirmar o banco do contêiner web; gravação recusada" };
}
let container: URL;
try {
container = new URL(printed.stdout.trim());
} catch {
return { isolation: null, reason: "banco do contêiner ilegível; gravação recusada" };
}
if (container.protocol !== "postgres:" && container.protocol !== "postgresql:") {
return { isolation: null, reason: "banco do contêiner não é Postgres; gravação recusada" };
}
if (container.hostname !== "postgres" || container.pathname !== "/adaptador") {
return { isolation: null, reason: "banco do contêiner não é o serviço postgres/adaptador do compose local; gravação recusada" };
}
container.hostname = "127.0.0.1";
container.port = hostPort[2];
const sql = postgres(container.href, { max: 1, prepare: false, connect_timeout: 5, connection: { statement_timeout: 20_000 } });
try {
const role = await sql.unsafe<{ rolsuper: boolean; rolbypassrls: boolean }[]>(
`select rolsuper, rolbypassrls from pg_roles where rolname = current_user`,
);
if (!role[0]?.rolsuper && !role[0]?.rolbypassrls) {
await sql.end({ timeout: 5 });
return { isolation: null, reason: "a conexão local não ignora RLS; a limpeza física não é garantida e a gravação foi recusada" };
}
const models = await sql.unsafe<ModelSnap[]>(
`select id::text as id, status, name from public.spreadsheet_models where workspace_id = $1 order by id`,
[ALPHA],
);
const jobs = await sql.unsafe<{ id: string }[]>(
`select id::text as id from public.spreadsheet_jobs where workspace_id = $1 order by id`,
[ALPHA],
);
const flags = await sql.unsafe<FlagSnap[]>(
`select workspace_id::text as workspace_id, module_key, enabled::text as enabled
from public.workspace_module_flags order by 1, 2`,
);
const settings = await sql.unsafe<SettingSnap[]>(
`select workspace_id::text as workspace_id, allow_non_admin_model_upload::text as allow_non_admin_model_upload
from public.spreadsheet_adapter_settings order by 1`,
);
const outsideModels = await countOf(sql, `select count(*)::int as total from public.spreadsheet_models where workspace_id <> $1`, [ALPHA]);
const outsideJobs = await countOf(sql, `select count(*)::int as total from public.spreadsheet_jobs where workspace_id <> $1`, [ALPHA]);
const activeModels = await countOf(
sql,
`select count(*)::int as total from public.spreadsheet_models where workspace_id = $1 and status = 'active'`,
[ALPHA],
);
return {
isolation: {
sql,
models,
jobs: jobs.map((job) => job.id),
flags,
settings,
outsideModels,
outsideJobs,
activeModels,
names: [],
},
reason: "",
};
} catch (error) {
await sql.end({ timeout: 5 }).catch(() => undefined);
return { isolation: null, reason: `isolamento não confirmado (${safeError(error)}); gravação recusada` };
}
}
async function cleanup(isolation: Isolation, auditSince: Date): Promise<void> {
if (!created.modelId) return;
assertUuid(created.modelId, "modelo");
for (const jobId of created.jobIds) assertUuid(jobId, "operação");
if (created.sourceId) assertUuid(created.sourceId, "fonte");
if (!/^Smoke local \d{8}T\d{6}Z-[a-z0-9]{6}$/.test(modelName)) throw new Error("nome de modelo fora do padrão da execução");
const keyRows = await isolation.sql.unsafe<{ key: string | null; job_id: string | null }[]>(
`select storage_key as key, null::text as job_id from public.spreadsheet_models where id = $1
union all
select s.temp_storage_key, j.id::text from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where j.model_id = $1 and j.user_id = $2 and j.workspace_id = $3
union all
select s.output_csv_key, j.id::text from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where j.model_id = $1 and j.user_id = $2 and j.workspace_id = $3
union all
select s.output_xlsx_key, j.id::text from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where j.model_id = $1 and j.user_id = $2 and j.workspace_id = $3`,
[created.modelId, ANA, ALPHA],
);
const keys: string[] = [];
for (const row of keyRows) {
if (!row.key) continue;
if (!ownedStorageKey(row.key, row.job_id)) throw new Error("chave de armazenamento fora dos registros desta execução");
if (!keys.includes(row.key)) keys.push(row.key);
}
const resourceIds = [created.modelId, created.sourceId, ...created.jobIds].filter(Boolean);
await isolation.sql.begin(async (tx) => {
const preservedBefore = await countOf(
tx,
`select count(*)::int as total from public.audit_log
where workspace_id = $1
and not (
actor_id = $2
and created_at >= $3::timestamptz
and (
resource_id = any($4::text[])
or (action = 'security.workspace_forgery' and metadata->>'attempted_workspace_id' = $5)
)
)`,
[ALPHA, ANA, auditSince.toISOString(), resourceIds, BETA],
);
const jobs = await tx.unsafe<{ id: string }[]>(
`delete from public.spreadsheet_jobs
where model_id = $1
and user_id = $2
and workspace_id = $3
and not (id = any($4::uuid[]))
returning id::text as id`,
[created.modelId, ANA, ALPHA, isolation.jobs],
);
const removedJobs = new Set(jobs.map((job) => job.id));
if (created.jobIds.some((jobId) => !removedJobs.has(jobId))) {
throw new Error("a limpeza da operação não bateu com os ids desta execução");
}
const models = await tx.unsafe<{ id: string }[]>(
`delete from public.spreadsheet_models
where id = $1
and workspace_id = $2
and created_by = $3
and name = $4
and not (id = any($5::uuid[]))
returning id`,
[created.modelId, ALPHA, ANA, modelName, isolation.models.map((model) => model.id)],
);
if (models.length !== 1) throw new Error("a limpeza do modelo não bateu com o registro desta execução");
const audits = await tx.unsafe<{ id: string }[]>(
`delete from public.audit_log
where workspace_id = $1
and actor_id = $2
and created_at >= $3::timestamptz
and (
resource_id = any($4::text[])
or (action = 'security.workspace_forgery' and metadata->>'attempted_workspace_id' = $5)
)
returning id`,
[ALPHA, ANA, auditSince.toISOString(), resourceIds, BETA],
);
if (audits.length > 20) throw new Error("limpeza de auditoria excedeu o teto; transação desfeita");
const preservedAfter = await countOf(
tx,
`select count(*)::int as total from public.audit_log
where workspace_id = $1
and not (
actor_id = $2
and created_at >= $3::timestamptz
and (
resource_id = any($4::text[])
or (action = 'security.workspace_forgery' and metadata->>'attempted_workspace_id' = $5)
)
)`,
[ALPHA, ANA, auditSince.toISOString(), resourceIds, BETA],
);
if (preservedBefore !== preservedAfter) throw new Error("auditoria alheia seria removida; transação desfeita");
const modelsNow = await tx.unsafe<ModelSnap[]>(
`select id::text as id, status, name from public.spreadsheet_models where workspace_id = $1`,
[ALPHA],
);
for (const previous of isolation.models) {
const found = modelsNow.find((model) => model.id === previous.id);
if (!found || found.status !== previous.status || found.name !== previous.name) {
throw new Error("modelo preexistente mudou; transação desfeita");
}
}
if (modelsNow.some((model) => model.id === created.modelId)) throw new Error("modelo da execução ainda está no banco");
const jobsNow = await tx.unsafe<{ id: string }[]>(
`select id::text as id from public.spreadsheet_jobs where workspace_id = $1`,
[ALPHA],
);
for (const previous of isolation.jobs) {
if (!jobsNow.some((job) => job.id === previous)) throw new Error("operação preexistente sumiu; transação desfeita");
}
if (created.jobIds.some((jobId) => jobsNow.some((job) => job.id === jobId))) {
throw new Error("operação da execução ainda está no banco");
}
const outsideModels = await countOf(tx, `select count(*)::int as total from public.spreadsheet_models where workspace_id <> $1`, [ALPHA]);
const outsideJobs = await countOf(tx, `select count(*)::int as total from public.spreadsheet_jobs where workspace_id <> $1`, [ALPHA]);
if (outsideModels < isolation.outsideModels || outsideJobs < isolation.outsideJobs) {
throw new Error("registro de outro workspace seria removido; transação desfeita");
}
});
for (const key of keys) {
const full = `/app/.data/storage/${key}`;
const removed = await compose(["exec", "-T", "web", "rm", "-f", full]);
if (removed.code !== 0) throw new Error("falha ao apagar arquivo desta execução");
const gone = await compose(["exec", "-T", "web", "sh", "-c", 'test ! -e "$1"', "sh", full]);
if (gone.code !== 0) throw new Error("arquivo desta execução continua no volume");
}
created.keys = keys;
}
function logMessage(line: string): string {
const index = line.indexOf("|");
return (index >= 0 ? line.slice(index + 1) : line).trim();
}
function isCriticalLog(message: string): boolean {
if (!message || message.startsWith("[worker] pronto")) return false;
if (message.startsWith("[worker]") && /recebido|encerrado/.test(message)) return false;
if (message.startsWith("worker ")) return true;
return /STORAGE_NOT_WRITABLE|PROCESS_FAILED|UnhandledPromise|ECONNREFUSED|TypeError|ReferenceError|\bERROR\b|⨯/.test(message);
}
runScript("smoke-local", async () => {
const hostEnv = process.env.ADAPTER_ENV?.trim();
if (hostEnv === "staging" || hostEnv === "production") {
console.error("smoke:local recusou executar: ADAPTER_ENV do processo não é development.");
process.exit(1);
}
try {
loopbackUrl(base);
} catch (error) {
console.error(`smoke:local recusou executar: ${safeError(error)}. Use http://127.0.0.1:3000.`);
process.exit(1);
}
console.log(`== smoke local em ${base} ==`);
let environment: string | undefined;
let health: HealthBody | undefined;
try {
const ready = await fetchJson("/api/health/ready");
health = ready.body as HealthBody;
environment = health?.environment;
record(
"health/ready",
ready.status === 200 && health?.status === "ready",
`status ${ready.status} ready=${health?.status ?? "?"} environment=${environment ?? "?"}`,
);
} catch (error) {
record("health/ready", false, safeError(error));
}
if (environment && environment !== "development") {
console.error(`smoke:local recusou continuar: o servidor reportou environment=${environment}.`);
process.exit(1);
}
if (!environment) {
pending("banco conectado", "health/ready não informou o ambiente");
pending("migrations atualizadas", "health/ready não informou o ambiente");
pending("redis acessível", "health/ready não informou o ambiente");
for (const name of WRITE_CHECKS) pending(name, "ambiente local não confirmado");
pending("limpeza dos registros da execução", "ambiente local não confirmado");
pending("feature flag do módulo preservada", "ambiente local não confirmado");
} else {
const checksBody = health?.checks ?? {};
const reasons = health?.reasons ?? {};
record("banco conectado", checksBody.database === "ok", `database=${checksBody.database ?? "?"}`);
record(
"migrations atualizadas",
checksBody.migrations === "ok" && !reasons.migrations,
reasons.migrations ? `pendentes: ${reasons.migrations}` : `migrations=${checksBody.migrations ?? "?"}`,
);
record("redis acessível", checksBody.redis === "ok", `redis=${checksBody.redis ?? "?"} ${reasons.redis ?? ""}`.trim());
const probe = await compose([
"exec",
"-T",
"web",
"sh",
"-c",
'set -eu; dir=/app/.data/storage/_smoke-local; test "$dir" = /app/.data/storage/_smoke-local; mkdir -p "$dir"; printf smoke-local > "$dir/probe"; test "$(cat "$dir/probe")" = smoke-local; rm -f "$dir/probe"; rmdir "$dir"; test ! -e "$dir"',
]);
record(
"storage com leitura e escrita",
probe.code === 0,
probe.code === 0 ? "prova gravada e removida em /app/.data/storage/_smoke-local" : redact(probe.stderr || probe.stdout || "prova de storage falhou"),
);
const webMode = await compose(["exec", "-T", "web", "printenv", "ADAPTER_JOB_MODE"]);
const workerMode = await compose(["exec", "-T", "worker", "printenv", "ADAPTER_JOB_MODE"]);
const webValue = webMode.code === 0 ? webMode.stdout.trim() : "indisponível";
const workerValue = workerMode.code === 0 ? workerMode.stdout.trim() : "indisponível";
record(
"modo de processamento worker",
webMode.code === 0 && workerMode.code === 0 && webValue === "worker" && workerValue === "worker",
`web=${webValue || "?"} worker=${workerValue || "?"}`,
);
}
let anaCookie = "";
let diegoCookie = "";
if (environment === "development") {
try {
const dev = await fetch(`${base}/api/dev/session`, {
method: "POST",
redirect: "manual",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ userId: ANA, workspaceId: ALPHA }),
signal: AbortSignal.timeout(30_000),
});
const jar = new Map<string, string>();
collectCookies(dev, jar);
anaCookie = cookieHeader(jar);
record("autenticação local de desenvolvimento", dev.status === 200 && jar.has("adapter_dev_session"), `status ${dev.status}`);
} catch (error) {
record("autenticação local de desenvolvimento", false, safeError(error));
}
try {
const staging = await fetchJson("/api/auth/staging/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "smoke-local@example.invalid", password: "nao-e-credencial" }),
});
record("login de homologação indisponível", staging.status === 404, `status ${staging.status}`);
} catch (error) {
record("login de homologação indisponível", false, safeError(error));
}
if (anaCookie) {
try {
const workspaces = await fetchJson("/api/auth/workspace", { headers: { cookie: anaCookie } });
const memberships = (workspaces.body as { memberships?: { workspaceId?: string }[] }).memberships ?? [];
const alphaOnly = workspaces.status === 200 && memberships.length === 1 && memberships[0]?.workspaceId === ALPHA;
record("acesso autorizado ao workspace de teste", alphaOnly, `status ${workspaces.status} associações=${memberships.length}`);
} catch (error) {
record("acesso autorizado ao workspace de teste", false, safeError(error));
}
try {
const models = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: anaCookie } });
const list = (models.body as { models?: ModelDto[] }).models;
record("consulta aos modelos cadastrados", models.status === 200 && Array.isArray(list), `status ${models.status} total=${Array.isArray(list) ? list.length : "?"}`);
} catch (error) {
record("consulta aos modelos cadastrados", false, safeError(error));
}
} else {
pending("acesso autorizado ao workspace de teste", "sessão local não foi emitida");
pending("consulta aos modelos cadastrados", "sessão local não foi emitida");
}
try {
const anon = await fetchJson("/api/spreadsheet-adapter/models");
const diegoLogin = await fetch(`${base}/api/dev/session`, {
method: "POST",
redirect: "manual",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ userId: DIEGO, workspaceId: BETA }),
signal: AbortSignal.timeout(30_000),
});
const diegoJar = new Map<string, string>();
collectCookies(diegoLogin, diegoJar);
diegoCookie = cookieHeader(diegoJar);
const diegoModels = diegoCookie
? await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: diegoCookie } })
: { status: 0, body: {} };
const forgedWorkspace = anaCookie
? await fetchJson("/api/auth/workspace", {
method: "POST",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({ workspaceId: BETA }),
})
: { status: 0, body: {} };
const rejected =
anon.status === 401 &&
diegoLogin.status === 200 &&
diegoModels.status === 403 &&
errorCode(diegoModels.body) === "FEATURE_DISABLED" &&
forgedWorkspace.status === 403;
record(
"rejeição de acesso entre workspaces",
rejected,
`anônimo=${anon.status} diego=${diegoModels.status}/${errorCode(diegoModels.body) || "?"} forjado=${forgedWorkspace.status}`,
);
} catch (error) {
record("rejeição de acesso entre workspaces", false, safeError(error));
}
}
const isolationResult = environment === "development" ? await prepareIsolation() : { isolation: null, reason: "ambiente local não confirmado" };
const isolation = isolationResult.isolation;
let existingNames: string[] = [];
if (isolation && anaCookie) {
try {
const listed = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: anaCookie } });
existingNames = ((listed.body as { models?: ModelDto[] }).models ?? []).map((model) => model.name ?? "").filter(Boolean);
isolation.names = existingNames;
} catch (error) {
isolation.names = [];
existingNames = [];
record("consulta prévia para limpeza", false, safeError(error));
}
}
const migrationsOk = checks.some((check) => check.name === "migrations atualizadas" && check.ok);
const storageOk = checks.some((check) => check.name === "storage com leitura e escrita" && check.ok);
const sessionOk = Boolean(anaCookie) && checks.some((check) => check.name === "autenticação local de desenvolvimento" && check.ok);
let writeBlock = "";
if (!isolation) writeBlock = isolationResult.reason;
else if (!sessionOk) writeBlock = "sessão local indisponível; gravação recusada";
else if (!storageOk) writeBlock = "storage sem prova de escrita; gravação recusada para não deixar arquivo órfão";
else if (!migrationsOk) writeBlock = "migrations não estão atualizadas; o smoke não aplica migration e não grava";
else if (isolation.activeModels >= 10) writeBlock = "Alpha já tem 10 modelos ativos; o smoke não exclui modelo preexistente";
if (writeBlock) {
for (const name of WRITE_CHECKS) pending(name, writeBlock);
pending("limpeza dos registros da execução", writeBlock);
} else if (isolation) {
const auditSinceRows = await isolation.sql.unsafe<{ t: Date | string }[]>(`select now() as t`);
const auditSince = new Date(auditSinceRows[0].t);
if (Number.isNaN(auditSince.getTime())) throw new Error("relógio do banco indisponível");
let modelBytes: Buffer = Buffer.alloc(0);
try {
modelBytes = await xlsxFile([["Nome", "Codigo"]]);
const sourceBytes = await xlsxFile([
["Nome", "Codigo"],
["Registro ficticio", sentinel],
]);
const modelForm = new FormData();
modelForm.append("name", modelName);
modelForm.append("requiredColumns", JSON.stringify(["Nome", "Codigo"]));
modelForm.append(
"file",
new File([new Uint8Array(modelBytes)], "modelo-ficticio.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
);
const createdModel = await fetch(`${base}/api/spreadsheet-adapter/models`, {
method: "POST",
headers: { cookie: anaCookie, accept: "application/json" },
body: modelForm,
signal: AbortSignal.timeout(30_000),
});
const modelPayload = (await createdModel.json()) as { model?: ModelDto };
if (createdModel.status >= 500) serverErrors.push(`POST /api/spreadsheet-adapter/models ${createdModel.status}`);
created.modelId = modelPayload.model?.id ?? "";
const modelOk =
createdModel.status === 201 &&
modelPayload.model?.format === "xlsx" &&
modelPayload.model?.name === modelName &&
UUID.test(created.modelId);
record("cadastro de modelo XLSX fictício", modelOk, `status ${createdModel.status} formato=${modelPayload.model?.format ?? "?"}`);
if (modelOk) {
const job = await fetchJson("/api/spreadsheet-adapter/jobs", {
method: "POST",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({ modelId: created.modelId, idempotencyKey: `smoke-local-job-${suffix}` }),
});
const jobId = (job.body as { job?: JobDto }).job?.id ?? "";
if (job.status === 201 && UUID.test(jobId)) created.jobIds.push(jobId);
const sourceForm = new FormData();
sourceForm.append("jobId", jobId);
sourceForm.append(
"file",
new File([new Uint8Array(sourceBytes)], "fonte-ficticia.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
);
const uploaded = await fetch(`${base}/api/spreadsheet-adapter/sources/validate`, {
method: "POST",
headers: { cookie: anaCookie, accept: "application/json" },
body: sourceForm,
signal: AbortSignal.timeout(30_000),
});
const uploadedBody = (await uploaded.json()) as { job?: JobDto; code?: string };
if (uploaded.status >= 500) serverErrors.push(`POST /api/spreadsheet-adapter/sources/validate ${uploaded.status}`);
created.sourceId = uploadedBody.job?.sources?.[0]?.id ?? "";
record(
"upload de planilha fictícia",
uploaded.status === 200 && UUID.test(created.sourceId),
`status ${uploaded.status} code=${uploadedBody.code ?? "ok"}`,
);
const mapped = await fetchJson(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, {
method: "PUT",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({
sourceId: created.sourceId,
mappings: [
{ sourceColumn: "Nome", modelColumn: "Nome" },
{ sourceColumn: "Codigo", modelColumn: "Codigo" },
],
}),
});
const mappedSource = (mapped.body as { job?: JobDto }).job?.sources?.find((source) => source.id === created.sourceId);
record("mapeamento de colunas", mapped.status === 200 && mappedSource?.status === "mapped", `status ${mapped.status} fonte=${mappedSource?.status ?? "?"}`);
const preview = await fetchJson(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, {
method: "POST",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({ sourceId: created.sourceId }),
});
const previewBody = preview.body as { preview?: boolean; rows?: string[][]; rowCount?: number };
const previewHasSentinel = (previewBody.rows ?? []).some((row) => row.some((cell) => cell === sentinel));
record(
"pré-visualização",
preview.status === 200 && previewBody.preview === true && previewHasSentinel && (previewBody.rowCount ?? 0) >= 1,
`status ${preview.status} linhas=${previewBody.rowCount ?? 0}`,
);
const queued = await fetchJson(`/api/spreadsheet-adapter/jobs/${jobId}/process`, {
method: "POST",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({ sourceId: created.sourceId, idempotencyKey: `smoke-local-proc-${suffix}` }),
});
let status = (queued.body as { job?: JobDto }).job?.status ?? "";
const enqueued = queued.status === 200 && (status === "queued" || status === "processing");
if (enqueued) {
for (let attempt = 0; attempt < 45 && status !== "completed" && status !== "failed"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const polled = await fetchJson(`/api/spreadsheet-adapter/jobs/${jobId}`, { headers: { cookie: anaCookie } });
status = (polled.body as { job?: JobDto }).job?.status ?? status;
}
}
record(
"processamento pelo worker",
enqueued && status === "completed",
`inicial=${(queued.body as { job?: JobDto }).job?.status ?? "?"} final=${status || "?"} code=${errorCode(queued.body) || "ok"}`,
);
const files: Record<"xlsx" | "csv" | "pdf", { status: number; body: Buffer; contentType: string; disposition: string }> = {
xlsx: { status: 0, body: Buffer.alloc(0), contentType: "", disposition: "" },
csv: { status: 0, body: Buffer.alloc(0), contentType: "", disposition: "" },
pdf: { status: 0, body: Buffer.alloc(0), contentType: "", disposition: "" },
};
if (status === "completed") {
for (const format of ["xlsx", "csv", "pdf"] as const) {
files[format] = await fetchBytes(
`/api/spreadsheet-adapter/jobs/${jobId}/download?format=${format}&sourceId=${created.sourceId}`,
anaCookie,
);
}
}
const exported =
files.xlsx.status === 200 &&
files.csv.status === 200 &&
files.pdf.status === 200 &&
files.xlsx.body.length > 0 &&
files.csv.body.length > 0 &&
files.pdf.body.length > 0 &&
files.xlsx.contentType.includes("spreadsheetml") &&
files.csv.contentType.includes("text/csv") &&
files.pdf.contentType.includes("application/pdf") &&
files.xlsx.disposition.includes(".xlsx") &&
files.csv.disposition.includes(".csv") &&
files.pdf.disposition.includes(".pdf");
record(
"exportação XLSX, CSV e PDF",
exported,
`xlsx=${files.xlsx.status}/${files.xlsx.body.length} csv=${files.csv.status}/${files.csv.body.length} pdf=${files.pdf.status}/${files.pdf.body.length}`,
);
exportsReport.xlsx = files.xlsx.status === 200 ? `${files.xlsx.body.length} bytes` : `HTTP ${files.xlsx.status}`;
exportsReport.csv = files.csv.status === 200 ? `${files.csv.body.length} bytes` : `HTTP ${files.csv.status}`;
exportsReport.pdf = files.pdf.status === 200 ? `${files.pdf.body.length} bytes` : `HTTP ${files.pdf.status}`;
const csvOk = files.csv.body.includes(sentinel) && files.csv.body.includes("Nome");
const xlsxOk = files.xlsx.body.subarray(0, 2).toString() === "PK" && (await xlsxContains(files.xlsx.body, sentinel));
const pdfOk = pdfContains(files.pdf.body, sentinel);
let originalOk = false;
if (modelOk) {
const stored = await compose([
"exec",
"-T",
"web",
"sha256sum",
`/app/.data/storage/workspaces/${ALPHA}/models/${created.modelId}/original`,
]);
const hash = stored.stdout.trim().split(/\s+/)[0] ?? "";
originalOk = stored.code === 0 && hash === createHash("sha256").update(modelBytes).digest("hex");
}
record(
"integridade dos arquivos",
Boolean(csvOk && xlsxOk && pdfOk && originalOk),
`csv=${csvOk} xlsx=${xlsxOk} pdf=${pdfOk} modelo_original=${originalOk}`,
);
if (csvOk) exportsReport.csv += ", conteúdo conferido";
if (xlsxOk) exportsReport.xlsx += ", conteúdo conferido";
if (pdfOk) exportsReport.pdf += ", conteúdo conferido";
const foreignJob = await fetchJson(`/api/spreadsheet-adapter/jobs/${jobId}`, { headers: { cookie: diegoCookie } });
const foreignDownload = await fetchBytes(
`/api/spreadsheet-adapter/jobs/${jobId}/download?format=csv&sourceId=${created.sourceId}`,
diegoCookie,
);
const foreignDelete = await fetchJson(`/api/spreadsheet-adapter/models/${created.modelId}`, {
method: "DELETE",
headers: { cookie: diegoCookie, "content-type": "application/json" },
body: "{}",
});
const forgedJob = await fetchJson("/api/spreadsheet-adapter/jobs", {
method: "POST",
headers: { cookie: anaCookie, "content-type": "application/json" },
body: JSON.stringify({
modelId: created.modelId,
workspace_id: BETA,
idempotencyKey: `smoke-local-forge-${suffix}`,
}),
});
if (forgedJob.status === 201) {
const extra = (forgedJob.body as { job?: JobDto }).job?.id ?? "";
if (UUID.test(extra)) created.jobIds.push(extra);
}
const resourceRejected =
foreignJob.status === 403 &&
foreignDownload.status === 403 &&
foreignDelete.status === 403 &&
forgedJob.status === 403 &&
errorCode(forgedJob.body) === "WORKSPACE_FORBIDDEN";
record(
"recurso de outro workspace é recusado",
resourceRejected,
`leitura=${foreignJob.status} download=${foreignDownload.status} exclusão=${foreignDelete.status} forja=${forgedJob.status}/${errorCode(forgedJob.body) || "?"}`,
);
} else {
pending("upload de planilha fictícia", "modelo não foi cadastrado");
pending("mapeamento de colunas", "modelo não foi cadastrado");
pending("pré-visualização", "modelo não foi cadastrado");
pending("processamento pelo worker", "modelo não foi cadastrado");
pending("exportação XLSX, CSV e PDF", "modelo não foi cadastrado");
pending("integridade dos arquivos", "modelo não foi cadastrado");
pending("recurso de outro workspace é recusado", "modelo não foi cadastrado");
}
} catch (error) {
record("jornada local", false, safeError(error));
}
if (!created.modelId) {
try {
const found = await isolation.sql.unsafe<{ id: string }[]>(
`select id::text as id from public.spreadsheet_models
where workspace_id = $1 and created_by = $2 and name = $3 and not (id = any($4::uuid[]))`,
[ALPHA, ANA, modelName, isolation.models.map((model) => model.id)],
);
if (found.length > 1) record("limpeza dos registros da execução", false, "mais de um modelo da execução; limpeza recusada");
else if (found.length === 1 && UUID.test(found[0].id)) created.modelId = found[0].id;
} catch (error) {
record("limpeza dos registros da execução", false, safeError(error));
}
}
if (created.modelId && !checks.some((check) => check.name === "limpeza dos registros da execução")) {
try {
await cleanup(isolation, auditSince);
const listed = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: anaCookie } });
const names = ((listed.body as { models?: ModelDto[] }).models ?? []).map((model) => model.name ?? "");
const removed = !names.includes(modelName);
const kept = existingNames.every((name) => names.includes(name));
record(
"limpeza dos registros da execução",
removed && kept,
removed && kept ? `arquivos removidos=${created.keys.length}; modelos anteriores preservados` : "o modelo da execução ou um modelo anterior não confere depois da limpeza",
);
} catch (error) {
record("limpeza dos registros da execução", false, safeError(error));
}
} else if (!checks.some((check) => check.name === "limpeza dos registros da execução")) {
pending("limpeza dos registros da execução", "nenhum registro foi criado");
}
}
if (isolation) {
try {
const flags = await isolation.sql.unsafe<FlagSnap[]>(
`select workspace_id::text as workspace_id, module_key, enabled::text as enabled
from public.workspace_module_flags order by 1, 2`,
);
const settings = await isolation.sql.unsafe<SettingSnap[]>(
`select workspace_id::text as workspace_id, allow_non_admin_model_upload::text as allow_non_admin_model_upload
from public.spreadsheet_adapter_settings order by 1`,
);
const sameFlags = JSON.stringify(flags) === JSON.stringify(isolation.flags);
const sameSettings = JSON.stringify(settings) === JSON.stringify(isolation.settings);
record("feature flag do módulo preservada", sameFlags && sameSettings, sameFlags && sameSettings ? "inalterada" : "houve mudança; o smoke não restaura flag");
} catch (error) {
record("feature flag do módulo preservada", false, safeError(error));
}
await isolation.sql.end({ timeout: 5 }).catch(() => undefined);
} else if (environment === "development") {
pending("feature flag do módulo preservada", isolationResult.reason);
}
for (const name of WRITE_CHECKS) {
if (!checks.some((check) => check.name === name)) pending(name, "não executado");
}
if (environment === "development") {
const logs = await compose(["logs", "--since", new Date(startedAt.getTime() - 1000).toISOString(), "web", "worker"], 30_000);
if (logs.code !== 0) {
record("ausência de erros críticos", false, "logs dos contêineres indisponíveis; ausência não comprovada");
} else {
const lines = `${logs.stdout}\n${logs.stderr}`
.split(/\r?\n/)
.map(logMessage)
.filter(isCriticalLog)
.map((line) => redact(line));
const critical = [...serverErrors.map((item) => `HTTP ${item}`), ...lines];
record(
"ausência de erros críticos",
critical.length === 0,
critical.length === 0 ? "nenhum erro crítico nas respostas nem nos logs de web e worker" : critical.slice(0, 8).join(" | "),
);
}
}
const passed = checks.filter((check) => check.ok);
const failed = checks.filter((check) => !check.ok && !check.pending);
const pendentes = checks.filter((check) => check.pending);
const elapsed = ((Date.now() - startedAt.getTime()) / 1000).toFixed(1);
console.log("");
console.log(`executados=${checks.length} aprovados=${passed.length} reprovados=${failed.length} pendentes=${pendentes.length} tempo=${elapsed}s`);
console.log("Formatos de exportação:");
console.log(` XLSX: ${exportsReport.xlsx}`);
console.log(` CSV: ${exportsReport.csv}`);
console.log(` PDF: ${exportsReport.pdf}`);
if (failed.length > 0) {
console.log("Falhas:");
for (const check of failed) console.log(` - ${check.name}: ${check.detail}`);
} else {
console.log("Falhas: nenhuma verificação executada falhou.");
}
if (pendentes.length > 0) {
console.log("Pendentes:");
for (const check of pendentes) console.log(` - ${check.name}: ${check.detail}`);
}
console.log("Este resultado não aprova gate de homologação externa.");
if (failed.length > 0) process.exitCode = 1;
});