diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 42d4e3a..2951e59 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -60,6 +60,7 @@ public static class AuditCommands 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 parentExecutionIdOption = new Option("--parent-execution-id") { Description = "Filter by parent 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; @@ -76,6 +77,7 @@ public static class AuditCommands cmd.Add(actorOption); cmd.Add(correlationIdOption); cmd.Add(executionIdOption); + cmd.Add(parentExecutionIdOption); cmd.Add(errorsOnlyOption); cmd.Add(pageSizeOption); cmd.Add(allOption); @@ -104,6 +106,7 @@ public static class AuditCommands Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), ExecutionId = result.GetValue(executionIdOption), + ParentExecutionId = result.GetValue(parentExecutionIdOption), 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 fda804e..f8640eb 100644 --- a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -25,6 +25,7 @@ public sealed class AuditQueryArgs public string? Actor { get; set; } public string? CorrelationId { get; set; } public string? ExecutionId { get; set; } + public string? ParentExecutionId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } @@ -127,6 +128,7 @@ public static class AuditQueryHelpers Add("actor", args.Actor); Add("correlationId", args.CorrelationId); Add("executionId", args.ExecutionId); + Add("parentExecutionId", args.ParentExecutionId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index a1d2572..9f15e19 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -402,6 +402,13 @@ public static class AuditEndpoints executionId = parsedExec; } + Guid? parentExecutionId = null; + if (query.TryGetValue("parentExecutionId", out var parentExecValues) + && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec)) + { + parentExecutionId = parsedParentExec; + } + return new AuditLogQueryFilter( Channels: channels, Kinds: kinds, @@ -411,6 +418,7 @@ public static class AuditEndpoints Actor: TrimToNullable(query, "actor"), CorrelationId: correlationId, ExecutionId: executionId, + ParentExecutionId: parentExecutionId, 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 62c5abb..811683c 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -66,6 +66,7 @@ public class AuditQueryCommandTests Actor = "multi-role", CorrelationId = "abc-123", ExecutionId = "def-456", + ParentExecutionId = "ghi-789", ErrorsOnly = false, PageSize = 250, }; @@ -83,6 +84,7 @@ public class AuditQueryCommandTests Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("abc-123", parsed["correlationId"]); Assert.Equal("def-456", parsed["executionId"]); + Assert.Equal("ghi-789", parsed["parentExecutionId"]); 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"]); @@ -159,6 +161,7 @@ public class AuditQueryCommandTests Assert.Null(parsed["fromUtc"]); Assert.Null(parsed["correlationId"]); Assert.Null(parsed["executionId"]); + Assert.Null(parsed["parentExecutionId"]); Assert.Equal("100", parsed["pageSize"]); } @@ -173,6 +176,17 @@ public class AuditQueryCommandTests Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); } + [Fact] + public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter() + { + // --parent-execution-id is a single-value Guid filter — mirrors --execution-id. + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]); + } + // ---- HTTP execution / paging ------------------------------------------ private sealed class RecordingHandler : HttpMessageHandler @@ -308,6 +322,18 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + [Fact] + public void Query_ParentExecutionIdOption_IsAccepted() + { + // --parent-execution-id is a single-value option — mirrors --execution-id. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222", + }); + Assert.Empty(parse.Errors); + } + // ---- Enum-name validation (fast-fail) ---------------------------------- [Fact] diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs index 7a2813a..190771c 100644 --- a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -460,6 +460,38 @@ public class AuditEndpointsTests Assert.Null(filter.ExecutionId); } + [Fact] + public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid() + { + // parentExecutionId is a single-value Guid? filter — mirrors executionId. + var parentExecutionId = Guid.NewGuid(); + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["parentExecutionId"] = parentExecutionId.ToString(), + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Equal(parentExecutionId, filter.ParentExecutionId); + } + + [Fact] + public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently() + { + // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) — + // mirrors the executionId parse. + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["parentExecutionId"] = "not-a-guid", + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Null(filter.ParentExecutionId); + } + [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() {