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; reasons?: Record; 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): 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 { 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 { 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 { const book = new ExcelJS.Workbook(); await book.xlsx.load(body as unknown as Parameters[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 { const rows = await sql.unsafe<{ total: number }[]>(text, params as postgres.ParameterOrJSON[]); 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( `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( `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( `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 { 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( `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(); 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(); 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( `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( `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; });