277 lines
10 KiB
C#
277 lines
10 KiB
C#
using System.Text.Json;
|
|
using BuildFlow.Shared.Domain.Errors;
|
|
using DocumentFormat.OpenXml;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using DocumentFormat.OpenXml.Spreadsheet;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Workflow.Application.BuildFlow.Common.Services;
|
|
using Workflow.Application.BuildFlow.Imports.Commands;
|
|
using Workflow.Application.BuildFlow.Imports.Handlers;
|
|
using Workflow.Application.Reliability;
|
|
using Workflow.Domain.Entities;
|
|
using Workflow.Domain.Enums;
|
|
using Xunit;
|
|
|
|
namespace BuildFlow.Workflow.Tests.Performance;
|
|
|
|
/// <summary>Excel 导入上传与 OpenXML 流式解析的资源边界测试。</summary>
|
|
public class OpenXmlImportBoundaryTests
|
|
{
|
|
[Fact]
|
|
public async Task OversizedUploadIsRejectedBeforeReadingStream()
|
|
{
|
|
await using var db = await CreateDbContextWithFormAsync();
|
|
var formId = await db.FormDefinitions.Select(form => form.Id).SingleAsync();
|
|
using var stream = new TrackingReadStream(new MemoryStream([1, 2, 3]));
|
|
var handler = CreateUploadHandler(db, maxFileBytes: 10);
|
|
|
|
var act = () => handler.Handle(
|
|
new CreateImportJobCommand(formId, stream, 11, "oversized.xlsx"),
|
|
CancellationToken.None);
|
|
|
|
var exception = await act.Should()
|
|
.ThrowAsync<global::BuildFlow.Shared.Domain.Exceptions.TooLargeException>();
|
|
exception.Which.Code.Should().Be(ErrorCode.TooLarge);
|
|
stream.BytesRead.Should().Be(0);
|
|
stream.ReadCalls.Should().Be(0);
|
|
db.BuildFlowImportJobs.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadCopiesStreamToTemporaryFileWithBoundedBuffer()
|
|
{
|
|
await using var db = await CreateDbContextWithFormAsync();
|
|
var formId = await db.FormDefinitions.Select(form => form.Id).SingleAsync();
|
|
var payload = Enumerable.Range(0, 200_000)
|
|
.Select(index => (byte)(index % 251))
|
|
.ToArray();
|
|
using var stream = new TrackingReadStream(new MemoryStream(payload));
|
|
var handler = CreateUploadHandler(db, maxFileBytes: 500_000);
|
|
string? filePath = null;
|
|
|
|
try
|
|
{
|
|
await handler.Handle(
|
|
new CreateImportJobCommand(formId, stream, payload.Length, "data.xlsx"),
|
|
CancellationToken.None);
|
|
|
|
var job = await db.BuildFlowImportJobs.SingleAsync();
|
|
filePath = job.FileUrl;
|
|
(await File.ReadAllBytesAsync(filePath)).Should().Equal(payload);
|
|
stream.BytesRead.Should().Be(payload.Length);
|
|
stream.ReadCalls.Should().BeGreaterThan(1);
|
|
stream.MaxRequestedReadSize.Should().BeLessThanOrEqualTo(81_920);
|
|
}
|
|
finally
|
|
{
|
|
if (filePath is not null)
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingFormIsRejectedWithoutReadingStream()
|
|
{
|
|
await using var db = TestDbFactory.Create();
|
|
using var stream = new TrackingReadStream(new MemoryStream([1]));
|
|
var handler = CreateUploadHandler(db, maxFileBytes: 10);
|
|
|
|
var act = () => handler.Handle(
|
|
new CreateImportJobCommand(Guid.NewGuid(), stream, 1, "data.xlsx"),
|
|
CancellationToken.None);
|
|
|
|
var exception = await act.Should().ThrowAsync<NotFoundException>();
|
|
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
|
stream.BytesRead.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParserRejectsRowBeyondConfiguredLimit()
|
|
{
|
|
using var stream = CreateWorkbook(
|
|
[
|
|
HeaderRow("名称"),
|
|
InlineRow(2, "first"),
|
|
InlineRow(3, "second"),
|
|
InlineRow(4, "third"),
|
|
]);
|
|
var service = new FormSchemaExcelService();
|
|
|
|
var act = () => service.Parse(SingleTextFieldSchema, stream, 2, 100).ToList();
|
|
|
|
var exception = act.Should().Throw<BusinessException>();
|
|
exception.Which.Code.Should().Be(ErrorCode.TooLarge);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParserRejectsCellTextBeyondConfiguredLimit()
|
|
{
|
|
using var stream = CreateWorkbook(
|
|
[HeaderRow("名称"), InlineRow(2, "123456")]);
|
|
var service = new FormSchemaExcelService();
|
|
|
|
var act = () => service.Parse(SingleTextFieldSchema, stream, 10, 5).ToList();
|
|
|
|
var exception = act.Should().Throw<BusinessException>();
|
|
exception.Which.Code.Should().Be(ErrorCode.TooLarge);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParserReadsSharedInlineNumberBooleanAndBlankCells()
|
|
{
|
|
using var stream = CreateTypedWorkbook();
|
|
var service = new FormSchemaExcelService();
|
|
|
|
var row = service.Parse(TypedSchema, stream, 10, 100).Single();
|
|
|
|
using var json = JsonDocument.Parse(row.JsonObject);
|
|
var root = json.RootElement;
|
|
root.GetProperty("shared").GetString().Should().Be("shared-value");
|
|
root.GetProperty("inline").GetString().Should().Be("inline-value");
|
|
root.GetProperty("amount").GetDecimal().Should().Be(42.5m);
|
|
root.GetProperty("approved").GetBoolean().Should().BeTrue();
|
|
root.TryGetProperty("blank", out _).Should().BeFalse();
|
|
}
|
|
|
|
private static CreateImportJobCommandHandler CreateUploadHandler(
|
|
WorkflowDbContext db,
|
|
long maxFileBytes)
|
|
=> new(
|
|
db,
|
|
Options.Create(new WorkflowJobOptions
|
|
{
|
|
Import = new WorkflowJobOptions.ImportJobSection
|
|
{
|
|
MaxFileBytes = maxFileBytes,
|
|
},
|
|
}));
|
|
|
|
private static async Task<WorkflowDbContext> CreateDbContextWithFormAsync()
|
|
{
|
|
var db = TestDbFactory.Create();
|
|
db.FormDefinitions.Add(new FormDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "导入边界测试",
|
|
Code = $"import-boundary-{Guid.NewGuid():N}",
|
|
Version = 1,
|
|
SchemaJson = SingleTextFieldSchema,
|
|
Status = FormStatus.Published,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return db;
|
|
}
|
|
|
|
private static MemoryStream CreateTypedWorkbook()
|
|
{
|
|
var stream = new MemoryStream();
|
|
using (var document = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true))
|
|
{
|
|
var workbookPart = document.AddWorkbookPart();
|
|
workbookPart.Workbook = new Workbook();
|
|
var sharedStringsPart = workbookPart.AddNewPart<SharedStringTablePart>();
|
|
sharedStringsPart.SharedStringTable = new SharedStringTable(
|
|
new SharedStringItem(new Text("shared-value")));
|
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
worksheetPart.Worksheet = new Worksheet(new SheetData(
|
|
HeaderRow("Shared", "Inline", "Number", "Boolean", "Blank"),
|
|
new Row(
|
|
new Cell { CellReference = "A2", DataType = CellValues.SharedString, CellValue = new CellValue("0") },
|
|
InlineCell("B2", "inline-value"),
|
|
new Cell { CellReference = "C2", DataType = CellValues.Number, CellValue = new CellValue("42.5") },
|
|
new Cell { CellReference = "D2", DataType = CellValues.Boolean, CellValue = new CellValue("1") },
|
|
new Cell { CellReference = "E2" })
|
|
{
|
|
RowIndex = 2,
|
|
}));
|
|
AppendSheet(workbookPart, worksheetPart);
|
|
}
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
private static MemoryStream CreateWorkbook(IEnumerable<Row> rows)
|
|
{
|
|
var stream = new MemoryStream();
|
|
using (var document = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true))
|
|
{
|
|
var workbookPart = document.AddWorkbookPart();
|
|
workbookPart.Workbook = new Workbook();
|
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
worksheetPart.Worksheet = new Worksheet(new SheetData(rows));
|
|
AppendSheet(workbookPart, worksheetPart);
|
|
}
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
private static void AppendSheet(WorkbookPart workbookPart, WorksheetPart worksheetPart)
|
|
{
|
|
var sheets = workbookPart.Workbook.AppendChild(new Sheets());
|
|
sheets.Append(new Sheet
|
|
{
|
|
Id = workbookPart.GetIdOfPart(worksheetPart),
|
|
SheetId = 1,
|
|
Name = "Sheet1",
|
|
});
|
|
workbookPart.Workbook.Save();
|
|
}
|
|
|
|
private static Row HeaderRow(params string[] headers)
|
|
=> new(headers.Select((header, index) => InlineCell($"{ColumnName(index + 1)}1", header)))
|
|
{
|
|
RowIndex = 1,
|
|
};
|
|
|
|
private static Row InlineRow(uint rowIndex, string value)
|
|
=> new(InlineCell($"A{rowIndex}", value))
|
|
{
|
|
RowIndex = rowIndex,
|
|
};
|
|
|
|
private static Cell InlineCell(string reference, string value)
|
|
=> new(new InlineString(new Text(value)))
|
|
{
|
|
CellReference = reference,
|
|
DataType = CellValues.InlineString,
|
|
};
|
|
|
|
private static string ColumnName(int index)
|
|
{
|
|
var name = string.Empty;
|
|
while (index > 0)
|
|
{
|
|
index--;
|
|
name = (char)('A' + index % 26) + name;
|
|
index /= 26;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
private const string SingleTextFieldSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string", "title": "名称", "required": true, "x-component": "Input" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
private const string TypedSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"shared": { "type": "string", "title": "Shared", "x-component": "Input" },
|
|
"inline": { "type": "string", "title": "Inline", "x-component": "Input" },
|
|
"amount": { "type": "number", "title": "Number", "x-component": "InputNumber" },
|
|
"approved": { "type": "boolean", "title": "Boolean", "x-component": "Switch" },
|
|
"blank": { "type": "string", "title": "Blank", "x-component": "Input" }
|
|
}
|
|
}
|
|
""";
|
|
}
|