202 lines
9.3 KiB
TypeScript
202 lines
9.3 KiB
TypeScript
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" });
|
|
});
|
|
});
|