From bd0ecabf41330d8e48dbe070cb090b25fd6eb64c Mon Sep 17 00:00:00 2001 From: Ferraz Fagundes Date: Sat, 17 Oct 2026 00:37:09 -0300 Subject: [PATCH] test(smoke): adiciona validacao completa do ambiente local --- docs/spreadsheet-adapter/24-smoke-local.md | 73 ++ package.json | 3 +- scripts/smoke-local.ts | 1030 ++++++++++++++++++++ tsconfig.json | 1 + 4 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 docs/spreadsheet-adapter/24-smoke-local.md create mode 100644 scripts/smoke-local.ts diff --git a/docs/spreadsheet-adapter/24-smoke-local.md b/docs/spreadsheet-adapter/24-smoke-local.md new file mode 100644 index 0000000..fdb4fc4 --- /dev/null +++ b/docs/spreadsheet-adapter/24-smoke-local.md @@ -0,0 +1,73 @@ +# Smoke local do compose de desenvolvimento + +Smoke do ambiente Docker local. Confere o Adaptador depois de atualizar código, reinstalar dependências ou recriar os contêineres, sem Supabase de homologação. + +Não altera `scripts/smoke-staging.ts` nem o comando `npm run smoke:staging`. + +## Separação + +| | Smoke local | Smoke de homologação | +| --- | --- | --- | +| Comando | `npm run smoke:local` | `npm run smoke:staging` | +| Script | `scripts/smoke-local.ts` | `scripts/smoke-staging.ts` | +| Alvo | `http://127.0.0.1:3000` (`ADAPTER_SMOKE_LOCAL_URL` só se for HTTP em loopback) | `ADAPTER_SMOKE_URL` público | +| Identidade | `POST /api/dev/session` com as fixtures de desenvolvimento | Supabase Auth de homologação | +| Banco | Postgres 16 do `docker-compose.yml`, publicado em loopback | Banco de homologação | +| Jornada | modelo XLSX, upload, mapeamento, prévia, worker, XLSX, CSV e PDF | fase 1 anônima; fase 2 só leitura autenticada. Upload e download continuam no runbook | +| Resultado | evidência do compose local | evidência de `hml-ada.elopro.app` | + +Um PASS local não preenche nem aprova os gates de homologação externa do [checklist](21-checklist-validacao.md): deploy real (F7), smoke de homologação (F10) e ensaio de rollback (F11) continuam pendentes até rodarem no ambiente externo. + +## Quando rodar + +Com o compose local no ar: + +```bash +docker compose up --build +docker compose exec web /app/docker/entrypoint.sh migrate +npm run smoke:local +``` + +A migration é um passo anterior e manual. O smoke não executa `migrate`, `seed`, `seed:staging` nem `flag`. + +## O que verifica + +1. `GET /api/health/ready` com HTTP 200 e `status=ready`. +2. `checks.database=ok`. +3. `checks.migrations=ok`, sem ids pendentes. Se houver migration pendente, a jornada não grava. +4. `checks.redis=ok`. +5. Leitura e escrita no volume privado: o contêiner `web` grava, relê e apaga um arquivo em `/app/.data/storage/_smoke-local`. O health de storage só cria diretório; esta prova é separada. +6. `ADAPTER_JOB_MODE=worker` nos contêineres `web` e `worker`. +7. Sessão local emitida e `POST /api/auth/staging/session` respondendo 404. +8. Ana enxerga só o workspace Alpha. +9. `GET /api/spreadsheet-adapter/models` autenticado. +10. Cadastro de um XLSX fictício, upload, mapeamento, prévia, fila do worker e download XLSX, CSV e PDF pelos contratos reais em `src/app/api/spreadsheet-adapter/`. +11. Conteúdo da sentinela fictícia nos três formatos e hash do modelo original inalterado. No PDF a sentinela é lida nos operadores de texto do arquivo gerado: a fonte padrão grava os caracteres em hexadecimal, não como texto solto. +12. Anônimo recebe 401; Diego, no workspace Beta com o módulo desligado, recebe 403 `FEATURE_DISABLED`; Ana não seleciona Beta; Diego não lê, baixa nem exclui o recurso de Alpha; corpo com `workspace_id` de Beta recebe 403 `WORKSPACE_FORBIDDEN`. +13. Logs de `web` e `worker` durante a execução, mais respostas HTTP 5xx. + +Os dados são colunas `Nome` e `Codigo` e um valor `SMOKELOCAL` mais um sufixo da execução. Não há planilha real. + +## Segurança da gravação + +Antes de cadastrar o modelo, o script: + +- recusa `ADAPTER_ENV=staging` ou `production` no processo; +- recusa URL que não seja HTTP em loopback; +- interrompe se `/api/health/ready` reportar outro ambiente; +- lê `DATABASE_URL` só dentro do contêiner `web` e só aceita host `postgres` e database `adaptador`; +- conecta no host pela porta publicada por `docker compose port`, e só se o bind for loopback; +- exige role com `rolsuper` ou `rolbypassrls`, porque a RLS força política e não há `DELETE` de modelo ou de job para `app_user`; +- fotografa modelos, jobs, feature flags e configuração de upload. + +A limpeza, na mesma transação, apaga somente o modelo e as operações criados nesta execução (nome, ids e autor conferidos, ids anteriores excluídos da cláusula). Auditoria removida é só a da Ana, posterior ao início da gravação, ligada a esses ids ou à tentativa de workspace forjado. Se a contagem de auditoria alheia mudar, a transação é desfeita. + +Os arquivos apagados no volume são só as chaves `original`, `source`, `out.csv` e `out.xlsx` desse modelo e dessa operação. O script não roda `docker compose down` nem remove volume. + +A feature flag não é ligada, desligada nem restaurada. Se ela mudar durante a execução, o item correspondente falha. + +Se a limpeza não puder ser garantida, os itens de gravação ficam **PENDENTE** e não entram na conta de aprovados. Código de saída diferente de zero ocorre quando uma verificação executada falha. Pendência sozinha não reprova o processo. + +## Relatório + +Ao terminar, o script imprime executados, aprovados, reprovados, pendentes, tempo, o estado de XLSX, CSV e PDF, as falhas e as pendências. diff --git a/package.json b/package.json index f1ca4e4..6d90585 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "flag": "node --import=tsx scripts/set-flag.ts", "verify:config": "node --import=tsx scripts/verify-config.ts", "check:bucket": "node --import=tsx scripts/check-bucket.ts", - "smoke:staging": "node --import=tsx scripts/smoke-staging.ts" + "smoke:staging": "node --import=tsx scripts/smoke-staging.ts", + "smoke:local": "node --import=tsx scripts/smoke-local.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/smoke-local.ts b/scripts/smoke-local.ts new file mode 100644 index 0000000..53aeae2 --- /dev/null +++ b/scripts/smoke-local.ts @@ -0,0 +1,1030 @@ +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; +}); diff --git a/tsconfig.json b/tsconfig.json index f65e179..4eb4ab4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", + "scripts/smoke-local.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ],