From c510adc95c17641832216981e4657adf065d94d7 Mon Sep 17 00:00:00 2001 From: Andy Howard Date: Sat, 12 Sep 2026 16:05:32 +0100 Subject: [PATCH] Prepare for NPOI upgrade. --- .../Proc/Excel/SaMsSpreadsheet_POI_Tester.cs | 264 ++++++++++++++++++ .../Proc/Excel/SaMsSpreadsheet_POI.cs | 8 +- docs/component-reviews.md | 1 + docs/topics.md | 1 + 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 SacsCoreUnitTests/Standard/Proc/Excel/SaMsSpreadsheet_POI_Tester.cs diff --git a/SacsCoreUnitTests/Standard/Proc/Excel/SaMsSpreadsheet_POI_Tester.cs b/SacsCoreUnitTests/Standard/Proc/Excel/SaMsSpreadsheet_POI_Tester.cs new file mode 100644 index 0000000..dcecf3a --- /dev/null +++ b/SacsCoreUnitTests/Standard/Proc/Excel/SaMsSpreadsheet_POI_Tester.cs @@ -0,0 +1,264 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NPOI.HSSF.UserModel; +using Sarand.Net.Standard.Base.Core; +using Sarand.Net.Standard.Proc.Excel; +using System; +using System.IO; + +namespace Sacs.UnitTests.Net.Standard.Proc.Excel +{ + [TestClass] + public class SaMsSpreadsheet_POI_Tester + { + // --- Workbook Lifecycle --- + + [TestMethod] + public void CreateWorkbook_CreatesDefaultWorksheet() + { + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(spreadsheet.CreateWorkbook()); + Assert.IsTrue(spreadsheet.IsWorkbookValid()); + Assert.IsTrue(spreadsheet.IsWorksheetValid()); + Assert.AreEqual((uint)1, spreadsheet.WorksheetCount); + Assert.AreEqual((uint)1, spreadsheet.WorksheetPos); + Assert.AreEqual("Sheet1", spreadsheet.WorksheetName); + } + } + + [TestMethod] + public void CloseWorkbook_InvalidatesWorkbookAndWorksheet() + { + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.CloseWorkbook()); + Assert.IsFalse(spreadsheet.IsWorkbookValid()); + Assert.IsFalse(spreadsheet.IsWorksheetValid()); + } + } + + // --- Worksheet Operations --- + + [TestMethod] + public void WorksheetOperations_AddOpenRenameAndRemoveWorksheets() + { + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + spreadsheet.WorksheetName = "Primary"; + Assert.IsTrue(spreadsheet.AddWorksheet("Secondary")); + Assert.AreEqual((uint)2, spreadsheet.WorksheetCount); + Assert.AreEqual("Secondary", spreadsheet.WorksheetName); + + Assert.IsTrue(spreadsheet.OpenWorksheet(" primary ")); + Assert.AreEqual((uint)1, spreadsheet.WorksheetPos); + Assert.AreEqual("Primary", spreadsheet.WorksheetName); + + Assert.IsTrue(spreadsheet.RemoveWorksheet(2)); + Assert.AreEqual((uint)1, spreadsheet.WorksheetCount); + } + } + + [TestMethod] + public void SetWorksheetCount_AddsAndRemovesWorksheets() + { + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.SetWorksheetCount(3)); + Assert.AreEqual((uint)3, spreadsheet.WorksheetCount); + + Assert.IsTrue(spreadsheet.SetWorksheetCount(1)); + Assert.AreEqual((uint)1, spreadsheet.WorksheetCount); + Assert.AreEqual((uint)1, spreadsheet.WorksheetPos); + } + } + + // --- Workbook Round Trips --- + + [TestMethod] + public void GetDocumentData_RoundTripPreservesCellValuesAndFormula() + { + DateTime expectedDate = new DateTime(2026, 9, 12, 14, 30, 0); + SaMsExcelStyleAttr dateStyle = new SaMsExcelStyleAttr + { + DataFormat = "yyyy-mm-dd hh:mm:ss" + }; + + byte[] workbookData; + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.SetCellValue(1, 1, new DataValue("Heading"), null)); + Assert.IsTrue(spreadsheet.SetCellValue(1, 2, new DataValue("Text value"), null)); + Assert.IsTrue(spreadsheet.SetCellValue(2, 2, new DataValue(42L), null)); + Assert.IsTrue(spreadsheet.SetCellValue(3, 2, new DataValue(12.5), null)); + Assert.IsTrue(spreadsheet.SetCellValue(4, 2, new DataValue(true), null)); + Assert.IsTrue(spreadsheet.SetCellValue(5, 2, new DataValue(expectedDate), dateStyle)); + Assert.IsTrue(spreadsheet.SetCellFormula(6, 2, "B2+C2", null)); + workbookData = spreadsheet.GetDocumentData(); + } + + Assert.IsNotNull(workbookData); + Assert.IsTrue(workbookData.Length > 0); + + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(spreadsheet.OpenWorkbook(workbookData)); + Assert.IsTrue(spreadsheet.OpenWorksheet(1)); + Assert.AreEqual("Text value", spreadsheet.GetCellText(1, 2)); + Assert.AreEqual(42L, spreadsheet.GetCellValue(2, 2).AsLong()); + Assert.AreEqual(12.5, spreadsheet.GetCellValue(3, 2).AsDouble(), 0.000001); + Assert.AreEqual("TRUE", spreadsheet.GetCellText(4, 2).ToUpperInvariant()); + Assert.AreEqual(expectedDate, spreadsheet.GetCellValue(5, 2).AsDateTime()); + Assert.AreEqual("B2+C2", spreadsheet.GetCellFormula(6, 2)); + } + } + + [TestMethod] + public void GetCellValue_TextCellReturnsStringDataValue() + { + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.SetCellValue(1, 1, new DataValue("Text value"), null)); + Assert.AreEqual("Text value", spreadsheet.GetCellValue(1, 1).AsString()); + } + } + + [TestMethod] + public void OpenWorkbook_StreamReadsFromStartRegardlessOfPosition() + { + byte[] workbookData; + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.SetCellValue(1, 1, new DataValue("Stream value"), null)); + workbookData = spreadsheet.GetDocumentData(); + } + + using (MemoryStream stream = new MemoryStream(workbookData)) + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + stream.Position = stream.Length; + Assert.IsTrue(spreadsheet.OpenWorkbook(stream)); + Assert.IsTrue(spreadsheet.OpenWorksheet(1)); + Assert.AreEqual("Stream value", spreadsheet.GetCellText(1, 1)); + } + } + + [TestMethod] + public void OpenWorkbook_LegacyXlsDataCanBeReadAndReserialized() + { + byte[] workbookData = CreateLegacyWorkbookData(); + + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(spreadsheet.OpenWorkbook(workbookData)); + Assert.IsTrue(spreadsheet.OpenWorksheet("Legacy")); + Assert.AreEqual("Legacy value", spreadsheet.GetCellValue(1, 1).AsString()); + Assert.AreEqual(17L, spreadsheet.GetCellValue(2, 1).AsLong()); + + byte[] roundTripData = spreadsheet.GetDocumentData(); + Assert.IsNotNull(roundTripData); + + using (SaMsSpreadsheet_POI reopened = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(reopened.OpenWorkbook(roundTripData)); + Assert.IsTrue(reopened.OpenWorksheet("Legacy")); + Assert.AreEqual("Legacy value", reopened.GetCellValue(1, 1).AsString()); + } + } + } + + // --- Formatting and Dimensions --- + + [TestMethod] + public void DimensionsAndActiveCell_RoundTripPreservesValues() + { + byte[] workbookData; + double expectedColWidth; + double expectedRowHeight; + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + Assert.IsTrue(spreadsheet.SetColWidth(2, 120, SaExcelLogicalUnit.Pixel)); + Assert.IsTrue(spreadsheet.SetRowHeight(3, 24, SaExcelLogicalUnit.Point)); + spreadsheet.SetActiveCell(2, 3); + expectedColWidth = spreadsheet.GetColWidth(2, SaExcelLogicalUnit.Pixel); + expectedRowHeight = spreadsheet.GetRowHeight(3, SaExcelLogicalUnit.Point); + workbookData = spreadsheet.GetDocumentData(); + } + + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(spreadsheet.OpenWorkbook(workbookData)); + Assert.IsTrue(spreadsheet.OpenWorksheet(1)); + Assert.AreEqual(expectedColWidth, spreadsheet.GetColWidth(2, SaExcelLogicalUnit.Pixel), 0.1); + Assert.AreEqual(expectedRowHeight, spreadsheet.GetRowHeight(3, SaExcelLogicalUnit.Point), 0.1); + Assert.AreEqual((uint)2, spreadsheet.ActiveColPos); + Assert.AreEqual((uint)3, spreadsheet.ActiveRowPos); + } + } + + // --- File Persistence --- + + [TestMethod] + public void SaveDocument_FileRoundTripPreservesWorkbook() + { + String testDirectory = CreateTestDirectory(); + String workbookPath = Path.Combine(testDirectory, "round-trip.xlsx"); + + try + { + using (SaMsSpreadsheet_POI spreadsheet = CreateSpreadsheet()) + { + spreadsheet.WorksheetName = "Saved"; + Assert.IsTrue(spreadsheet.SetCellValue(1, 1, new DataValue("Saved value"), null)); + Assert.IsTrue(spreadsheet.SaveDocument(workbookPath, (int)SaXlFileFormat.OpenXMLWorkbook, true)); + } + + Assert.IsTrue(File.Exists(workbookPath)); + Assert.IsTrue(new FileInfo(workbookPath).Length > 0); + + using (SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI()) + { + Assert.IsTrue(spreadsheet.OpenWorkbook(workbookPath)); + Assert.IsTrue(spreadsheet.OpenWorksheet("Saved")); + Assert.AreEqual("Saved value", spreadsheet.GetCellText(1, 1)); + } + } + finally + { + if (Directory.Exists(testDirectory)) + Directory.Delete(testDirectory, true); + } + } + + // --- Utilities --- + + private static SaMsSpreadsheet_POI CreateSpreadsheet() + { + SaMsSpreadsheet_POI spreadsheet = new SaMsSpreadsheet_POI(); + Assert.IsTrue(spreadsheet.CreateWorkbook()); + return spreadsheet; + } + + private static byte[] CreateLegacyWorkbookData() + { + using (HSSFWorkbook workbook = new HSSFWorkbook()) + using (MemoryStream stream = new MemoryStream()) + { + var worksheet = workbook.CreateSheet("Legacy"); + var row = worksheet.CreateRow(0); + row.CreateCell(0).SetCellValue("Legacy value"); + row.CreateCell(1).SetCellValue(17); + workbook.Write(stream); + return stream.ToArray(); + } + } + + private static String CreateTestDirectory() + { + String directory = Path.Combine(Path.GetTempPath(), "Sarand", "SacsCoreUnitTests", "SaMsSpreadsheet_POI", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + // --- The End --- + } +} diff --git a/SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs b/SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs index aeb60af..0f1d2f9 100644 --- a/SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs +++ b/SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs @@ -90,6 +90,7 @@ namespace Sarand.Net.Standard.Proc.Excel catch { // If reading fails, try to read workbook as XLS: + fs.Seek(0, SeekOrigin.Begin); workbook = new HSSFWorkbook(fs); } } @@ -127,6 +128,7 @@ namespace Sarand.Net.Standard.Proc.Excel catch { // If reading fails, try to read workbook as XLS: + workbookStream.Seek(0, SeekOrigin.Begin); workbook = new HSSFWorkbook(workbookStream); } } @@ -268,8 +270,8 @@ namespace Sarand.Net.Standard.Proc.Excel SetErrorState(Msg_WorkbookNotOpen); if (!HasErrorState()) { - for (int sheetPos = 1; sheetPos <= workbook.NumberOfSheets; sheetPos++) - names.Add(workbook.GetSheetName(sheetPos)); + for (int sheetIdx = 0; sheetIdx < workbook.NumberOfSheets; sheetIdx++) + names.Add(workbook.GetSheetName(sheetIdx)); } return new List(names); } @@ -786,7 +788,7 @@ namespace Sarand.Net.Standard.Proc.Excel } private bool IsCellDate(ICell cell) { - return cell != null && NPOI.SS.UserModel.DateUtil.IsCellDateFormatted(cell); + return IsCellNumeric(cell) && NPOI.SS.UserModel.DateUtil.IsCellDateFormatted(cell); } //------------------------------------------------------------------- diff --git a/docs/component-reviews.md b/docs/component-reviews.md index de843c8..d2e4b1e 100644 --- a/docs/component-reviews.md +++ b/docs/component-reviews.md @@ -16,4 +16,5 @@ Last updated: 2026-09-12 | [`SacsMobileMaui`](../SacsMobileMaui/docs/component-reviews.md) | Project | .NET 10 migration complete; tracked in project docs | 2026-09-11 | Validate Apple targets on a Mac and continue API cleanup in the project tracker. | The shared project and both active mobile consumers target .NET 10. Windows runtime smoke tests pass, and Windows and Android Debug builds complete with zero errors. | | `SacsStandardBase/Base/Procs/SaProcess_Base.cs` | Class | In progress | 2026-09-09 | Complete the Process Base actions recorded in `topics.md`; review derived processes separately for cooperative polling. | Lifecycle state ownership and cancellation lock ordering were changed. Five focused tests pass. Remaining concerns include mixed sync/async execution, durable cancellation outcome, cancellation exception handling, disposal, and status synchronization. | | `SacsStandardBase/Base/Email/SaEmailConsole.cs` | Class | Dependency upgrade complete; security action still required | 2026-09-12 | Separately restore SMTP certificate validation and require the configured TLS mode. | MailKit and MimeKit 4.17.0 pass the Common and downstream validation suites without vulnerability warnings. Message construction is covered for addressing, bodies, attachments, content IDs, default filenames, and tags. The current SMTP client still accepts every server certificate. | +| `SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs` | Class | Characterization baseline complete; dependency upgrade pending | 2026-09-12 | Upgrade to NPOI 2.7.6 with an explicit patched cryptography dependency and rerun the focused and downstream suites. | Ten tests exercise lifecycle, worksheets, XLSX byte/stream/file round trips, typed values, formulas, dimensions, active cells, and legacy XLS. Automatic worksheet naming, typed text reads, and XLS fallback stream rewinding were corrected; all specifications now pass. NPOI 2.8.0 introduces a maintenance-fee EULA; 2.7.6 remains Apache-2.0. | | `SacsStandardBase/Base/Helpers/SacsEncryptionHelper.cs` and `SacsStandardBase/Base/Data/SacsAesIvCrypter.cs` | Classes | Reviewed; action required | 2026-09-08 | Design a versioned authenticated-encryption format with legacy migration and tamper tests. | Current CBC encryption is unauthenticated; password-based key derivation uses a fixed salt and low iteration count. | diff --git a/docs/topics.md b/docs/topics.md index bdcad62..1a36086 100644 --- a/docs/topics.md +++ b/docs/topics.md @@ -13,6 +13,7 @@ Last updated: 2026-09-12 | Priority | Topic | Date noted | Next step | Notes | |---|---|---|---|---| +| P1 | Upgrade NPOI and override its vulnerable cryptography dependency | 2026-09-12 | Evaluate NPOI 2.7.6 with an explicit `System.Security.Cryptography.Xml` 10.0.12 reference against the completed spreadsheet baseline, then validate downstream BusinessMgr exports. | `SacsStandardProc` directly references NPOI 2.6.2. Current NPOI 2.8.0 still declares vulnerable `System.Security.Cryptography.Xml` 8.0.2 and introduces a maintenance-fee EULA; do not adopt it without an explicit licensing decision. NPOI 2.7.6 remains Apache-2.0 but also needs the explicit cryptography override. Ten new NPOI tests pass, including fixes for automatic worksheet naming, typed reads of text cells, and XLS fallback stream handling. | | P2 | Resolve .NET 10 migration warnings in non-MAUI Common code | 2026-09-10 | Replace the obsolete `Rfc2898DeriveBytes` constructor, make the three `FileStream.Read` operations exact, and review removal of the redundant `Microsoft.Win32.Registry` package. | The .NET 10 build reports `SYSLIB0060`, three `CA2022` warnings, and `NU1510`. The cryptography change overlaps the existing encryption-hardening topic and requires compatibility tests. | | P1 | Prevent synchronous and asynchronous execution from overlapping in `SaProcess_Base` | 2026-09-09 | Route `RunProcess()` and `RunAsync()` through one guarded start operation and extend the lifecycle tests. | A synchronous call can currently reuse and dispose the asynchronous worker's task data. | | P2 | Retain cancellation as a completed process outcome | 2026-09-09 | Store a durable cancellation result until the next process reset and test post-completion state. | `IsInterrupted` currently loses token-based cancellation after task-data cleanup. |