diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 7120ffc..42d4e3a 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -59,6 +59,7 @@ public static class AuditCommands var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; + var executionIdOption = new Option("--execution-id") { Description = "Filter by execution ID" }; var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; pageSizeOption.DefaultValueFactory = _ => 100; @@ -74,6 +75,7 @@ public static class AuditCommands cmd.Add(targetOption); cmd.Add(actorOption); cmd.Add(correlationIdOption); + cmd.Add(executionIdOption); cmd.Add(errorsOnlyOption); cmd.Add(pageSizeOption); cmd.Add(allOption); @@ -101,6 +103,7 @@ public static class AuditCommands Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), + ExecutionId = result.GetValue(executionIdOption), ErrorsOnly = result.GetValue(errorsOnlyOption), PageSize = result.GetValue(pageSizeOption), }; diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs index 39918f5..fda804e 100644 --- a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -24,6 +24,7 @@ public sealed class AuditQueryArgs public string? Target { get; set; } public string? Actor { get; set; } public string? CorrelationId { get; set; } + public string? ExecutionId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } @@ -125,6 +126,7 @@ public static class AuditQueryHelpers Add("target", args.Target); Add("actor", args.Actor); Add("correlationId", args.CorrelationId); + Add("executionId", args.ExecutionId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index c369195..70b2bc3 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -105,6 +105,13 @@ public static class AuditExportEndpoints correlationId = parsedCorr; } + Guid? executionId = null; + if (query.TryGetValue("executionId", out var execValues) + && Guid.TryParse(execValues.ToString(), out var parsedExec)) + { + executionId = parsedExec; + } + DateTime? fromUtc = ParseUtcDate(query, "from"); DateTime? toUtc = ParseUtcDate(query, "to"); @@ -116,6 +123,7 @@ public static class AuditExportEndpoints Target: target, Actor: actor, CorrelationId: correlationId, + ExecutionId: executionId, FromUtc: fromUtc, ToUtc: toUtc); } diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index 49b50f3..a1d2572 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -395,6 +395,13 @@ public static class AuditEndpoints correlationId = parsedCorr; } + Guid? executionId = null; + if (query.TryGetValue("executionId", out var execValues) + && Guid.TryParse(execValues.ToString(), out var parsedExec)) + { + executionId = parsedExec; + } + return new AuditLogQueryFilter( Channels: channels, Kinds: kinds, @@ -403,6 +410,7 @@ public static class AuditEndpoints Target: TrimToNullable(query, "target"), Actor: TrimToNullable(query, "actor"), CorrelationId: correlationId, + ExecutionId: executionId, FromUtc: ParseUtcDate(query, "fromUtc"), ToUtc: ParseUtcDate(query, "toUtc")); } diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index 3df692b..62c5abb 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -65,6 +65,7 @@ public class AuditQueryCommandTests Target = "weather-api", Actor = "multi-role", CorrelationId = "abc-123", + ExecutionId = "def-456", ErrorsOnly = false, PageSize = 250, }; @@ -81,6 +82,7 @@ public class AuditQueryCommandTests Assert.Equal("weather-api", parsed["target"]); Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("abc-123", parsed["correlationId"]); + Assert.Equal("def-456", parsed["executionId"]); Assert.Equal("250", parsed["pageSize"]); Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]); Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); @@ -155,9 +157,22 @@ public class AuditQueryCommandTests Assert.Null(parsed["channel"]); Assert.Null(parsed["status"]); Assert.Null(parsed["fromUtc"]); + Assert.Null(parsed["correlationId"]); + Assert.Null(parsed["executionId"]); Assert.Equal("100", parsed["pageSize"]); } + [Fact] + public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter() + { + // --execution-id is a single-value Guid filter — mirrors --correlation-id. + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); + } + // ---- HTTP execution / paging ------------------------------------------ private sealed class RecordingHandler : HttpMessageHandler @@ -281,6 +296,18 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + [Fact] + public void Query_ExecutionIdOption_IsAccepted() + { + // --execution-id is a single-value option — mirrors --correlation-id. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111", + }); + Assert.Empty(parse.Errors); + } + // ---- Enum-name validation (fast-fail) ---------------------------------- [Fact] diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index b91cc07..dcccf89 100644 --- a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -138,6 +138,7 @@ public class AuditExportEndpointsTests using (host) { var correlationId = Guid.NewGuid().ToString(); + var executionId = Guid.NewGuid().ToString(); var url = "/api/centralui/audit/export?" + "channel=ApiOutbound&" + @@ -147,6 +148,7 @@ public class AuditExportEndpointsTests "target=PaymentApi&" + "actor=apikey-1&" + $"correlationId={correlationId}&" + + $"executionId={executionId}&" + "from=2026-05-20T00:00:00Z&" + "to=2026-05-20T23:59:59Z"; @@ -167,6 +169,7 @@ public class AuditExportEndpointsTests f.Target == "PaymentApi" && f.Actor == "apikey-1" && f.CorrelationId == Guid.Parse(correlationId) && + f.ExecutionId == Guid.Parse(executionId) && f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) && f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)), Arg.Any(), @@ -195,6 +198,7 @@ public class AuditExportEndpointsTests f.Target == null && f.Actor == null && f.CorrelationId == null && + f.ExecutionId == null && f.FromUtc == null && f.ToUtc == null), Arg.Any(), @@ -222,6 +226,25 @@ public class AuditExportEndpointsTests } } + [Fact] + public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped() + { + // Lax-parse contract: an unparseable executionId is dropped (no 400) — + // mirrors the correlationId parse. + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => f.ExecutionId == null), + Arg.Any(), + Arg.Any()); + } + } + /// /// Test-only authentication handler that signs every request in as an Admin. /// Admin is in AuditExportRoles, so the endpoint's AuditExport policy diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs index 80fb691..7a2813a 100644 --- a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -428,6 +428,38 @@ public class AuditEndpointsTests Assert.Null(filter.Statuses); } + [Fact] + public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid() + { + // executionId is a single-value Guid? filter — mirrors correlationId. + var executionId = Guid.NewGuid(); + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["executionId"] = executionId.ToString(), + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Equal(executionId, filter.ExecutionId); + } + + [Fact] + public void ParseFilter_UnparseableExecutionId_IsDroppedSilently() + { + // Lax-parse contract: an unparseable executionId is dropped (no 400) — + // mirrors the correlationId parse. + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["executionId"] = "not-a-guid", + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Null(filter.ExecutionId); + } + [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() {