513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
374 lines
19 KiB
C#
374 lines
19 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"/>. 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
|
|
{
|
|
// 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)));
|
|
|
|
/// <summary>Verifies that exporting five rows writes a header line plus five data lines.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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]);
|
|
}
|
|
|
|
/// <summary>Verifies that the CSV header contains all 24 columns in specification order.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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(',');
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>Verifies that a field containing a comma is quoted and escaped.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that free-text cells starting with formula characters are prefixed with a single quote.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ExportAsync_FreeTextLeadingFormulaCharacters_ArePrefixedWithQuote()
|
|
{
|
|
// CSV formula injection guard: 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);
|
|
}
|
|
|
|
/// <summary>Verifies that numeric and date columns are never prefixed with a quote.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that a null field is written as an empty cell.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that a row count above the cap is truncated and a cap footer is appended.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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]);
|
|
}
|
|
|
|
/// <summary>Verifies that cancelling the token stops the export mid-stream.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that the export paginates using the last row of each page as the cursor.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 second-page 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);
|
|
}
|
|
}
|