1031 lines
45 KiB
TypeScript
1031 lines
45 KiB
TypeScript
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;
|
||
});
|