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
@@ -29,6 +29,7 @@ function createCommitDb(overrides: Record<string, unknown> = {}) {
},
stagedVacation: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedAvailabilityRule: {
@@ -94,6 +95,9 @@ function createCommitDb(overrides: Record<string, unknown> = {}) {
findUnique: vi.fn().mockResolvedValue({ id: "batch_1", status: "STAGED", summary: {} }),
update: vi.fn().mockResolvedValue({}),
},
stagedVacation: {
count: vi.fn().mockResolvedValue(0),
},
stagedUnresolvedRecord: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -233,11 +237,11 @@ describe("commitDispoImportBatch", () => {
{
id: "sv_1",
resourceExternalId: "ada.director",
vacationType: "PUBLIC_HOLIDAY",
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-01T00:00:00.000Z"),
note: "New Year",
holidayName: "New Year",
vacationType: "ANNUAL",
startDate: new Date("2026-01-08T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
note: "Winter vacation",
holidayName: null,
isHalfDay: false,
halfDayPart: null,
},
@@ -705,4 +709,18 @@ describe("commitDispoImportBatch", () => {
}),
);
});
it("rejects staged PUBLIC_HOLIDAY rows until holiday calendars are synchronized", async () => {
const { db, tx } = createCommitDb();
db.stagedVacation.count.mockResolvedValue(2);
await expect(
commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
}),
).rejects.toThrow(
'Import batch "batch_1" still contains 2 staged PUBLIC_HOLIDAY row(s). Public holidays must be synchronized through holiday calendars before commit.',
);
});
});
@@ -230,8 +230,8 @@ describe("dispo import", () => {
});
expect(report.resourceCount).toBeGreaterThan(500);
expect(report.canCommitWithStrictSourceData).toBe(true);
expect(report.canCommitWithFallbacks).toBe(true);
expect(report.canCommitWithStrictSourceData).toBe(false);
expect(report.canCommitWithFallbacks).toBe(false);
expect(report.issues.find((issue) => issue.code === "FALLBACK_EMAIL_REQUIRED")).toBeUndefined();
expect(report.issues.find((issue) => issue.code === "FALLBACK_LCR_REQUIRED")).toBeUndefined();
expect(report.issues.find((issue) => issue.code === "FALLBACK_UCR_REQUIRED")).toBeUndefined();
@@ -247,6 +247,10 @@ describe("dispo import", () => {
);
expect(report.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "PUBLIC_HOLIDAY_IMPORT_REQUIRES_CALENDAR_SYNC",
severity: "blocker",
}),
expect.objectContaining({
code: "UNRESOLVED_RECORDS_PRESENT",
severity: "warning",
@@ -740,7 +744,7 @@ describe("dispo import", () => {
expect(result.counts.stagedResources).toBeGreaterThan(800);
expect(result.counts.stagedRosterResources).toBeGreaterThan(500);
expect(result.counts.stagedAssignments).toBeGreaterThan(1000);
expect(result.readiness.canCommitWithStrictSourceData).toBe(true);
expect(result.readiness.canCommitWithStrictSourceData).toBe(false);
expect(result.readiness.issues).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -754,7 +758,7 @@ describe("dispo import", () => {
data: expect.objectContaining({
summary: expect.objectContaining({
readiness: expect.objectContaining({
canCommitWithStrictSourceData: true,
canCommitWithStrictSourceData: false,
}),
}),
}),
@@ -5,12 +5,23 @@ import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
MAX_DISPO_WORKBOOK_BYTES,
MAX_DISPO_WORKBOOK_COLUMNS,
MAX_DISPO_WORKBOOK_ROWS,
readWorksheetMatrix,
} from "../use-cases/dispo-import/read-workbook.js";
const referenceWorkbookPath = fileURLToPath(
new URL("../../../../samples/Dispov2/MandatoryDispoCategories_V3.xlsx", import.meta.url),
);
const chargeabilityWorkbookPath = fileURLToPath(
new URL(
"../../../../samples/Dispov2/20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0.943_4Hartmut.xlsx",
import.meta.url,
),
);
const planningWorkbookPath = fileURLToPath(
new URL("../../../../samples/Dispov2/DISPO_2026.xlsx", import.meta.url),
);
const tempDirectories: string[] = [];
@@ -28,6 +39,18 @@ async function makeTempDirectory(): Promise<string> {
return directory;
}
async function writeWorkbook(filePath: string, rows: unknown[][], sheetName = "Sheet1"): Promise<void> {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetName);
for (const row of rows) {
worksheet.addRow(row);
}
await workbook.xlsx.writeFile(filePath);
}
describe("readWorksheetMatrix", () => {
it("reads trusted xlsx worksheets through the hardened reader", async () => {
const rows = await readWorksheetMatrix(referenceWorkbookPath, "EID-Attr");
@@ -36,6 +59,21 @@ describe("readWorksheetMatrix", () => {
expect(rows.some((row) => row.length > 0)).toBe(true);
});
it("tolerates workbook tables that contain unsupported exceljs date group filters", async () => {
const rows = await readWorksheetMatrix(chargeabilityWorkbookPath, "ChgFC");
expect(rows.length).toBeGreaterThan(300);
expect(rows[0]?.length).toBeGreaterThan(5);
});
it("accepts real dispo planning worksheets within the supported width envelope", async () => {
const rows = await readWorksheetMatrix(planningWorkbookPath, "Dispo");
expect(rows.length).toBeGreaterThan(500);
expect(rows.some((row) => row.length > 256)).toBe(true);
expect(rows.every((row) => row.length <= MAX_DISPO_WORKBOOK_COLUMNS)).toBe(true);
});
it("rejects legacy .xls workbook paths", async () => {
const directory = await makeTempDirectory();
const legacyPath = path.join(directory, "legacy-input.xls");
@@ -55,4 +93,30 @@ describe("readWorksheetMatrix", () => {
"Workbook file exceeds the",
);
});
it("rejects worksheets that exceed the row limit", async () => {
const directory = await makeTempDirectory();
const workbookPath = path.join(directory, "too-many-rows.xlsx");
await writeWorkbook(
workbookPath,
Array.from({ length: MAX_DISPO_WORKBOOK_ROWS + 1 }, (_, index) => [`row-${index + 1}`]),
);
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
`exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit`,
);
});
it("rejects worksheets that exceed the column limit", async () => {
const directory = await makeTempDirectory();
const workbookPath = path.join(directory, "too-many-columns.xlsx");
await writeWorkbook(
workbookPath,
[Array.from({ length: MAX_DISPO_WORKBOOK_COLUMNS + 1 }, (_, index) => `col-${index + 1}`)],
);
await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow(
`exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit`,
);
});
});