import { describe, expect, it } from "vitest"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import { readSpreadsheet } from "@/infra/spreadsheet/parse"; import { assertZipSafe } from "@/domain/spreadsheet-adapter/file-signature"; import { addSource, createJob, createModel, listModels, saveMappings, startProcess, } from "@/server/spreadsheet-adapter/service"; import { cleanupExpired, downloadOutput, runProcess } from "@/server/spreadsheet-adapter/process-job"; import type { ObjectStorage } from "@/infra/storage/memory"; import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world"; function zipEntry(name: string): Buffer { const entry = Buffer.from(name); const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(entry.length, 26); return Buffer.concat([header, entry]); } describe("isolamento e arquivos hostis", () => { it("impede download, listagem e flag forjada entre workspaces", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); const bruno = ctxOf("bruno", "alpha", "agent", true); const diego = ctxOf("diego", "beta", "admin", true); const model = await createModel(deps, ana, { name: "Isolado", filename: "modelo.csv", bytes: Buffer.from("Nome,Valor\n", "utf8"), requiredColumns: ["Nome", "Valor"], }); const job = await createJob(deps, ana, model.id, "job-iso"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome,Valor\nAna,10\n", "utf8"), }); if (!("job" in source)) throw new Error("fonte"); const sourceId = source.job.sources[0]?.id; if (!sourceId) throw new Error("fonte id"); await saveMappings(deps, ana, job.id, sourceId, [ { sourceColumn: "Nome", modelColumn: "Nome" }, { sourceColumn: "Valor", modelColumn: "Valor" }, ]); const done = await startProcess(deps, ana, job.id, sourceId, "proc-iso"); expect(done.status).toBe("completed"); await expect(downloadOutput(deps, bruno, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "NOT_FOUND" }); await expect(downloadOutput(deps, diego, job.id, sourceId, "xlsx")).rejects.toMatchObject({ code: "FEATURE_DISABLED" }); await expect(listModels(deps, diego)).rejects.toMatchObject({ code: "FEATURE_DISABLED" }); await db.withService((query) => query.query(`update public.workspace_module_flags set enabled = false where workspace_id = $1`, [IDS.alpha]), ); await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "FEATURE_DISABLED" }); await db.withService((query) => query.query(`update public.workspace_module_flags set enabled = true where workspace_id = $1`, [IDS.alpha]), ); const outsider = { ...ana, workspaceId: "33333333-3333-4333-8333-333333333333" }; await db.withService((query) => query.query(`insert into public.workspaces (id, name) values ($1, 'Gamma')`, [outsider.workspaceId]), ); await db.withService((query) => query.query( `insert into public.workspace_module_flags (workspace_id, module_key, enabled) values ($1, 'spreadsheet_adapter', true)`, [outsider.workspaceId], ), ); await expect(listModels(deps, outsider)).rejects.toBeInstanceOf(AdapterError); await db.close(); }); it("mantém o arquivo do outro workspace na limpeza", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "TTL", filename: "modelo.csv", bytes: Buffer.from("Nome\n", "utf8"), requiredColumns: ["Nome"], }); const job = await createJob(deps, ana, model.id, "job-ttl"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") }); if (!("job" in source)) throw new Error("fonte"); const sourceId = source.job.sources[0]?.id ?? ""; await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]); await startProcess(deps, ana, job.id, sourceId, "proc-ttl"); const foreign = `workspaces/${IDS.beta}/tmp/outro/source`; await deps.storage.put(foreign, Buffer.from("nao-apagar")); deps.ttlSeconds = -1; await cleanupExpired(deps); await cleanupExpired(deps); expect(await deps.storage.get(foreign)).toEqual(Buffer.from("nao-apagar")); await db.close(); }); it("duas transferências simultâneas do mesmo usuário terminam em um único resultado", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "Concorrencia", filename: "modelo.csv", bytes: Buffer.from("Nome\n", "utf8"), requiredColumns: ["Nome"], }); const job = await createJob(deps, ana, model.id, "job-lock"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") }); if (!("job" in source)) throw new Error("fonte"); const sourceId = source.job.sources[0]?.id ?? ""; await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]); const settled = await Promise.allSettled([ startProcess(deps, ana, job.id, sourceId, "mesmo"), startProcess(deps, ana, job.id, sourceId, "mesmo"), ]); for (const item of settled) { if (item.status === "rejected") expect((item.reason as AdapterError).code).toMatch(/JOB_LOCKED|ALREADY_COMPLETED/); else expect(item.value.status === "completed" || item.value.status === "processing" || item.value.status === "queued").toBe(true); } const again = await startProcess(deps, ana, job.id, sourceId, "mesmo"); expect(again.status).toBe("completed"); await db.close(); }); it("reenfileira falha transitória e conclui na tentativa seguinte", async () => { const db = await createReadyDb(); const deps = depsOf(db); const inner = deps.storage; let failures = 1; const flaky: ObjectStorage = { put: (key, body) => inner.put(key, body), delete: (key) => inner.delete(key), list: (prefix) => inner.list(prefix), async get(key) { if (failures > 0 && key.includes("/tmp/")) { failures -= 1; throw new Error("transient"); } return inner.get(key); }, }; deps.storage = flaky; const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "Retry", filename: "modelo.csv", bytes: Buffer.from("Nome\n", "utf8"), requiredColumns: ["Nome"], }); const job = await createJob(deps, ana, model.id, "job-retry"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") }); if (!("job" in source)) throw new Error("fonte"); const sourceId = source.job.sources[0]?.id ?? ""; await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]); await expect(startProcess(deps, ana, job.id, sourceId, "retry-1")).rejects.toMatchObject({ code: "PROCESS_FAILED" }); const done = await runProcess(deps, ana, job.id, sourceId).then(() => startProcess(deps, ana, job.id, sourceId, "retry-1")); expect(done.status).toBe("completed"); await db.close(); }); it("encerra por timeout sem nova tentativa infinita", async () => { const previous = process.env.SPREADSHEET_JOB_TIMEOUT_MS; process.env.SPREADSHEET_JOB_TIMEOUT_MS = "0"; const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); try { const model = await createModel(deps, ana, { name: "Timeout", filename: "modelo.csv", bytes: Buffer.from("Nome\n", "utf8"), requiredColumns: ["Nome"], }); const job = await createJob(deps, ana, model.id, "job-timeout"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") }); if (!("job" in source)) throw new Error("fonte"); const sourceId = source.job.sources[0]?.id ?? ""; await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]); await expect(startProcess(deps, ana, job.id, sourceId, "timeout-1")).rejects.toMatchObject({ code: "TIMEOUT" }); const rows = await db.withService((query) => query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [job.id]), ); expect(rows[0]?.status).toBe("failed"); } finally { if (previous === undefined) delete process.env.SPREADSHEET_JOB_TIMEOUT_MS; else process.env.SPREADSHEET_JOB_TIMEOUT_MS = previous; await db.close(); } }); it("recusa macro, CSV inválido e célula enorme", async () => { expect(() => assertZipSafe(zipEntry("xl/vbaProject.bin"))).toThrow(/macros/i); await expect(readSpreadsheet("dados.csv", Buffer.from([0xff, 0xfe, 0x41]))).rejects.toMatchObject({ code: "CSV_INVALID" }); const huge = Buffer.from(`Nome\n${"A".repeat(8001)}\n`, "utf8"); await expect(readSpreadsheet("grande.csv", huge)).rejects.toMatchObject({ code: "CELL_TOO_LARGE" }); }); });