feat(import): harden workbook parser boundaries

This commit is contained in:
2026-03-31 22:48:30 +02:00
parent 3e8b1702bc
commit db50e2e555
20 changed files with 936 additions and 174 deletions
@@ -1,4 +1,3 @@
import * as XLSX from "xlsx";
import {
EstimateExportFormat,
EstimateStatus,
@@ -144,8 +143,8 @@ function buildSource(): EstimateExportSource {
}
describe("estimate export serializer", () => {
it("creates a structured JSON export payload", () => {
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.JSON);
it("creates a structured JSON export payload", async () => {
const payload = await serializeEstimateExport(buildSource(), EstimateExportFormat.JSON);
expect(payload.encoding).toBe("utf8");
expect(payload.mimeType).toBe("application/json; charset=utf-8");
@@ -154,9 +153,16 @@ describe("estimate export serializer", () => {
expect(payload.previewText).toContain('"schemaVersion": 1');
});
it("creates a multi-sheet xlsx export payload", () => {
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX);
const workbook = XLSX.read(payload.content, { type: "base64" });
it("creates a multi-sheet xlsx export payload", async () => {
const payload = await serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX);
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const workbookBytes = Uint8Array.from(Buffer.from(payload.content, "base64"));
const workbookBuffer = workbookBytes.buffer.slice(
workbookBytes.byteOffset,
workbookBytes.byteOffset + workbookBytes.byteLength,
);
await workbook.xlsx.load(workbookBuffer);
expect(payload.encoding).toBe("base64");
expect(payload.sheetNames).toEqual([
@@ -167,7 +173,7 @@ describe("estimate export serializer", () => {
"Resources",
"Metrics",
]);
expect(workbook.SheetNames).toContain("DemandLines");
expect(workbook.getWorksheet("DemandLines")).toBeDefined();
expect(payload.byteLength).toBeGreaterThan(100);
});
});
@@ -1,4 +1,3 @@
import * as XLSX from "xlsx";
import {
EstimateExportFormat,
type EstimateExportArtifactPayload,
@@ -8,6 +7,8 @@ import {
} from "@capakraken/shared";
import { summarizeEstimateDemandLines } from "./metrics.js";
type ExcelJsModule = typeof import("exceljs");
type ExportProjectRef = {
id: string;
name: string;
@@ -109,6 +110,18 @@ type ExportMetric = {
updatedAt: Date;
};
type ExportSheetRow = Record<string, unknown>;
let _excelJs: ExcelJsModule | null = null;
async function getExcelJS() {
if (!_excelJs) {
_excelJs = await import("exceljs");
}
return _excelJs;
}
export interface EstimateExportSource {
estimate: {
id: string;
@@ -508,6 +521,52 @@ function base64ByteLength(content: string) {
return Math.floor((content.length * 3) / 4) - padding;
}
function buildSheetColumns(rows: ExportSheetRow[]) {
return Array.from(
rows.reduce((keys, row) => {
for (const key of Object.keys(row)) {
keys.add(key);
}
return keys;
}, new Set<string>()),
);
}
function toWorksheetCellValue(value: unknown): boolean | Date | number | string {
if (value == null) {
return "";
}
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
return stringifyValue(value);
}
function appendWorksheetFromRows(
workbook: InstanceType<ExcelJsModule["Workbook"]>,
sheetName: string,
rows: ExportSheetRow[],
): void {
const worksheet = workbook.addWorksheet(sheetName);
const columns = buildSheetColumns(rows);
if (columns.length === 0) {
return;
}
worksheet.addRow(columns);
for (const row of rows) {
worksheet.addRow(columns.map((column) => toWorksheetCellValue(row[column])));
}
}
function buildTextPayload(
format: EstimateExportFormat,
content: string,
@@ -536,17 +595,18 @@ function buildTextPayload(
};
}
function buildXlsxPayload(
async function buildXlsxPayload(
source: EstimateExportSource,
summary: EstimateExportSummary,
): EstimateExportArtifactPayload {
): Promise<EstimateExportArtifactPayload> {
const overviewRows = buildOverviewRows(source, summary);
const assumptionRows = buildAssumptionRows(source.version.assumptions);
const scopeRows = buildScopeRows(source.version.scopeItems);
const demandRows = buildDemandRows(source);
const resourceRows = buildResourceRows(source.version.resourceSnapshots);
const metricRows = buildMetricRows(source.version.metrics);
const workbook = XLSX.utils.book_new();
const ExcelJS = await getExcelJS();
const workbook = new ExcelJS.Workbook();
const sheets = [
{ name: "Overview", rows: overviewRows },
{ name: "Assumptions", rows: assumptionRows },
@@ -557,17 +617,11 @@ function buildXlsxPayload(
] as const;
for (const sheet of sheets) {
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet(sheet.rows),
sheet.name,
);
appendWorksheetFromRows(workbook, sheet.name, sheet.rows);
}
const content = XLSX.write(workbook, {
type: "base64",
bookType: "xlsx",
});
const buffer = await workbook.xlsx.writeBuffer();
const content = Buffer.from(buffer).toString("base64");
return {
schemaVersion: 1,
@@ -593,10 +647,10 @@ function buildXlsxPayload(
};
}
export function serializeEstimateExport(
export async function serializeEstimateExport(
source: EstimateExportSource,
format: EstimateExportFormat,
): EstimateExportArtifactPayload {
): Promise<EstimateExportArtifactPayload> {
const summary = buildSummary(source);
if (format === EstimateExportFormat.JSON) {