9a3f006721
P6-7 (CWE-1236): AuditEndpoints CSV export quoted cells on comma/quote/CR/LF but never neutralized a leading =, +, -, @, TAB, or CR — inbound-API-caller text landing in Target/ErrorMessage/RequestSummary/ResponseSummary/Extra could execute as a formula/DDE trigger when opened in Excel. AppendField now prefixes a literal single quote on free-text columns only (never the GUID/enum/numeric/date/bool columns the exporter formats itself), applied before RFC 4180 quoting. The CSV was also missing ExecutionId/ ParentExecutionId/SourceNode that the JSONL export already carried; both are now 24 columns in the same AuditExportRow order. A second, independently maintained CSV writer (CentralUI's AuditLogExportService, feeding the Audit Log page's bulk-export button) had the identical gap and got the identical fix + column alignment. P6-8: `audit export` was the one CLI HTTP path with no deadline at all — a residual gap from the ManagementHttpClient timeout-honesty fix, which made HttpClient.Timeout infinite and the per-call token the single deadline everywhere except this streamed GET. RunExportAsync now takes a headers- received deadline (CancelAfter, reusing ManagementHttpClient. DefaultConnectTimeout) around the initial GetAsync, an inactivity deadline around the streaming copy (rearmed on every successful read, so large exports are bounded by silence, not total size), and an external CancellationToken wired to Console.CancelKeyPress in AuditCommands so Ctrl-C cancels promptly. A stalled/faulted stream now produces a friendly error noting the partial output file instead of hanging or dumping a stack trace.
356 lines
17 KiB
C#
356 lines
17 KiB
C#
using System.Text;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
|
|
/// service streams the filtered Audit Log query to a destination stream as
|
|
/// RFC 4180 CSV. These tests pin:
|
|
/// <list type="bullet">
|
|
/// <item>Header + body row count for a simple page.</item>
|
|
/// <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
|
|
/// <item>Null fields render as empty (no literal "null").</item>
|
|
/// <item>Row cap honoured + cap footer appended.</item>
|
|
/// <item>Cancellation tokens propagate mid-stream.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class AuditLogExportServiceTests
|
|
{
|
|
// C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
|
|
// from IAuditLogRepository and decomposes each into the flat 21-column CSV shape;
|
|
// build the canonical rows via the shared factory (domain fields ride in DetailsJson).
|
|
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
|
|
=> ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
eventId: Guid.Parse(id),
|
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
target: target,
|
|
sourceSiteId: "plant-a",
|
|
httpStatus: 200,
|
|
durationMs: 42,
|
|
errorMessage: error,
|
|
ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
|
|
{
|
|
var rows = new List<AuditEvent>
|
|
{
|
|
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
|
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
|
SimpleEvent("33333333-3333-3333-3333-333333333333"),
|
|
SimpleEvent("44444444-4444-4444-4444-444444444444"),
|
|
SimpleEvent("55555555-5555-5555-5555-555555555555"),
|
|
};
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
// First call returns the 5 rows; subsequent calls return empty so the
|
|
// service terminates the keyset loop.
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
|
|
|
// 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
|
|
Assert.Equal(7, lines.Length);
|
|
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,ExecutionId,ParentExecutionId,SourceSiteId,SourceNode,", lines[0]);
|
|
Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
|
|
Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
|
|
Assert.Equal(string.Empty, lines[6]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_HeaderHasAll24Columns_InSpecOrder()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
|
|
var header = csv.Split("\r\n")[0];
|
|
var columns = header.Split(',');
|
|
|
|
// arch-review 2026-08-19 P6-7: ExecutionId/ParentExecutionId/SourceNode
|
|
// were missing from this CSV (JSON/JSONL exports already carried them).
|
|
Assert.Equal(24, columns.Length);
|
|
Assert.Equal(new[]
|
|
{
|
|
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
|
|
"CorrelationId", "ExecutionId", "ParentExecutionId",
|
|
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript",
|
|
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
|
|
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
|
|
"PayloadTruncated", "Extra", "ForwardState",
|
|
}, columns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
|
|
{
|
|
// Target contains a comma → field must be wrapped in double quotes.
|
|
// Target with embedded quote → quote must be doubled ("") and field quoted.
|
|
// ResponseSummary contains CR-LF → field must be quoted.
|
|
var row = ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
eventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
target: "x",
|
|
sourceSiteId: "plant-a, secondary", // comma
|
|
sourceScript: "say \"hi\"", // embedded quote
|
|
errorMessage: "boom\r\nthen again"); // CR-LF
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
|
|
// Comma-bearing field is quoted.
|
|
Assert.Contains("\"plant-a, secondary\"", csv);
|
|
// Embedded quote is doubled inside a quoted field.
|
|
Assert.Contains("\"say \"\"hi\"\"\"", csv);
|
|
// Newline-bearing field is quoted; the inner \r\n stays as-is.
|
|
Assert.Contains("\"boom\r\nthen again\"", csv);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_FreeTextLeadingFormulaCharacters_ArePrefixedWithQuote()
|
|
{
|
|
// arch-review 2026-08-19 P6-7 (CWE-1236): a free-text cell starting with
|
|
// =, +, -, @, or TAB must be prefixed with a literal single quote so a
|
|
// spreadsheet application renders it as inert text, not a formula/DDE
|
|
// launch. Target, ErrorMessage, ResponseSummary, and Extra are
|
|
// inbound-API-caller-controlled and are the primary attack surface.
|
|
var row = ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
eventId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
target: "=HYPERLINK(\"http://evil\",\"click\")",
|
|
errorMessage: "+cmd|'/c calc'!A1",
|
|
responseSummary: "-2+3",
|
|
extra: "@SUM(A1:A9)");
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
var dataLine = csv.Split("\r\n")[1];
|
|
|
|
Assert.Contains("'=HYPERLINK(\"\"http://evil\"\",\"\"click\"\")", dataLine);
|
|
Assert.Contains("'+cmd|'/c calc'!A1", dataLine);
|
|
Assert.Contains(",'-2+3,", dataLine);
|
|
Assert.Contains(",'@SUM(A1:A9)", dataLine);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_NumericAndDateColumns_NeverPrefixed()
|
|
{
|
|
// HttpStatus/DurationMs/OccurredAtUtc are formatted by the exporter
|
|
// itself (never free text) and must render unmangled even when a
|
|
// negative DurationMs would otherwise look like a formula-neutralized
|
|
// "-" leader if the neutralization were mistakenly applied there.
|
|
var row = ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
eventId: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
httpStatus: 200,
|
|
durationMs: -5);
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
var dataLine = csv.Split("\r\n")[1];
|
|
var fields = dataLine.Split(',');
|
|
|
|
Assert.Equal("200", fields[15]); // HttpStatus, unprefixed
|
|
Assert.Equal("-5", fields[16]); // DurationMs, unprefixed despite leading '-'
|
|
Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_NullField_WrittenAsEmpty()
|
|
{
|
|
// Build a row with deliberate nulls for every nullable column.
|
|
var row = ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Submitted,
|
|
eventId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
var dataLine = csv.Split("\r\n")[1];
|
|
var fields = dataLine.Split(',');
|
|
|
|
// EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
|
|
// CorrelationId(5), ExecutionId(6), ParentExecutionId(7), SourceSiteId(8),
|
|
// SourceNode(9), SourceInstanceId(10), SourceScript(11), Actor(12),
|
|
// Target(13), Status(14), HttpStatus(15), DurationMs(16), ErrorMessage(17),
|
|
// ErrorDetail(18), RequestSummary(19), ResponseSummary(20),
|
|
// PayloadTruncated(21), Extra(22), ForwardState(23)
|
|
Assert.Equal(24, fields.Length);
|
|
Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
|
|
Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null
|
|
Assert.Equal(string.Empty, fields[5]); // CorrelationId null
|
|
Assert.Equal(string.Empty, fields[6]); // ExecutionId null
|
|
Assert.Equal(string.Empty, fields[7]); // ParentExecutionId null
|
|
Assert.Equal(string.Empty, fields[8]); // SourceSiteId null
|
|
Assert.Equal(string.Empty, fields[9]); // SourceNode null
|
|
Assert.Equal(string.Empty, fields[15]); // HttpStatus null
|
|
Assert.Equal(string.Empty, fields[17]); // ErrorMessage null
|
|
Assert.Equal("False", fields[21]); // PayloadTruncated
|
|
Assert.Equal(string.Empty, fields[23]); // ForwardState null
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
|
|
{
|
|
// The service is asked for 3 rows but the repo would happily yield 5.
|
|
// Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
|
|
var rows = Enumerable.Range(0, 5)
|
|
.Select(i => SimpleEvent(Guid.NewGuid().ToString()))
|
|
.ToList();
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
// Repo returns the 5 rows in a single page; the service must stop after 3.
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);
|
|
|
|
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
|
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
|
|
|
// 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
|
|
Assert.Equal(6, lines.Length);
|
|
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_CancellationToken_StopsMidStream()
|
|
{
|
|
// Repo yields a single page, then on the next page call we observe the
|
|
// canceled token and throw — service should propagate OperationCanceled.
|
|
var cts = new CancellationTokenSource();
|
|
var firstPage = new List<AuditEvent>
|
|
{
|
|
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
|
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
|
};
|
|
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(callInfo =>
|
|
{
|
|
// Cancel after delivering the first page so the next loop iteration
|
|
// sees a canceled token.
|
|
cts.Cancel();
|
|
return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
|
|
});
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
|
|
// The service writes the first page then checks the token before pulling
|
|
// the next — we expect OperationCanceledException to surface.
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
|
|
{
|
|
// Two pages of 2 rows each, then empty. The service must pass the last
|
|
// row of page 1 as the cursor on the page-2 call.
|
|
AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
eventId: Guid.Parse(id),
|
|
occurredAtUtc: occurredAt);
|
|
var p1 = new List<AuditEvent>
|
|
{
|
|
Row("11111111-1111-1111-1111-111111111111", new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
|
|
Row("22222222-2222-2222-2222-222222222222", new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc)),
|
|
};
|
|
var p2 = new List<AuditEvent>
|
|
{
|
|
Row("33333333-3333-3333-3333-333333333333", new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc)),
|
|
};
|
|
|
|
var pagings = new List<AuditLogPaging>();
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var sut = new AuditLogExportService(repo);
|
|
using var ms = new MemoryStream();
|
|
// PageSize is 2 so the first page returns full and the loop continues.
|
|
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);
|
|
|
|
Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
|
|
Assert.Null(pagings[0].AfterEventId);
|
|
Assert.Null(pagings[0].AfterOccurredAtUtc);
|
|
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
|
|
// Canonical OccurredAtUtc is a DateTimeOffset; the paging cursor is a DateTime —
|
|
// compare via the decomposed row view (Kind=Utc DateTime).
|
|
Assert.Equal(p1[^1].AsRow().OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
|
|
}
|
|
}
|