using System.Text; using NSubstitute; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Tests.Services; /// /// Tests for (#23 M7-T14 / Bundle F). The /// service streams the filtered Audit Log query to a destination stream as /// RFC 4180 CSV. These tests pin: /// /// Header + body row count for a simple page. /// RFC 4180 quoting for fields containing commas / quotes / CR-LF. /// Null fields render as empty (no literal "null"). /// Row cap honoured + cap footer appended. /// Cancellation tokens propagate mid-stream. /// /// public class AuditLogExportServiceTests { private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null) => new() { EventId = Guid.Parse(id), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, CorrelationId = null, SourceSiteId = "plant-a", SourceInstanceId = null, SourceScript = null, Actor = null, Target = target, Status = AuditStatus.Delivered, HttpStatus = 200, DurationMs = 42, ErrorMessage = error, ErrorDetail = null, RequestSummary = null, ResponseSummary = null, PayloadTruncated = false, Extra = null, ForwardState = null, }; [Fact] public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows() { var rows = new List { 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(); // First call returns the 5 rows; subsequent calls return empty so the // service terminates the keyset loop. repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(rows), Task.FromResult>(Array.Empty())); 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,SourceSiteId,", 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_HeaderHasAll21Columns_InSpecOrder() { var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); 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(','); Assert.Equal(21, columns.Length); Assert.Equal(new[] { "EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "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 = new AuditEvent { EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), IngestedAtUtc = null, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, CorrelationId = null, SourceSiteId = "plant-a, secondary", // comma SourceInstanceId = null, SourceScript = "say \"hi\"", // embedded quote Actor = null, Target = "x", Status = AuditStatus.Delivered, HttpStatus = null, DurationMs = null, ErrorMessage = "boom\r\nthen again", // CR-LF ErrorDetail = null, RequestSummary = null, ResponseSummary = null, PayloadTruncated = false, Extra = null, ForwardState = null, }; var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(new[] { row }), Task.FromResult>(Array.Empty())); 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_NullField_WrittenAsEmpty() { // Build a row with deliberate nulls for every nullable column. var row = new AuditEvent { EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), IngestedAtUtc = null, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, CorrelationId = null, SourceSiteId = null, SourceInstanceId = null, SourceScript = null, Actor = null, Target = null, Status = AuditStatus.Submitted, HttpStatus = null, DurationMs = null, ErrorMessage = null, ErrorDetail = null, RequestSummary = null, ResponseSummary = null, PayloadTruncated = false, Extra = null, ForwardState = null, }; var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(new[] { row }), Task.FromResult>(Array.Empty())); 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), SourceSiteId(6), SourceInstanceId(7), SourceScript(8), // Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13), // ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17), // PayloadTruncated(18), Extra(19), ForwardState(20) Assert.Equal(21, 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]); // SourceSiteId null Assert.Equal(string.Empty, fields[12]); // HttpStatus null Assert.Equal(string.Empty, fields[14]); // ErrorMessage null Assert.Equal("False", fields[18]); // PayloadTruncated Assert.Equal(string.Empty, fields[20]); // 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(); // Repo returns the 5 rows in a single page; the service must stop after 3. repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(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 { SimpleEvent("11111111-1111-1111-1111-111111111111"), SimpleEvent("22222222-2222-2222-2222-222222222222"), }; var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { // Cancel after delivering the first page so the next loop iteration // sees a canceled token. cts.Cancel(); return Task.FromResult>(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(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. var p1 = new List { new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, }; var p2 = new List { new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, }; var pagings = new List(); var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Do(p => pagings.Add(p)), Arg.Any()) .Returns( Task.FromResult>(p1), Task.FromResult>(p2), Task.FromResult>(Array.Empty())); 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); Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc); } }