From 0569c5ff2357fb134c6f51d6e40491cbe6d91e20 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:20:54 -0400 Subject: [PATCH] feat(audit): M5.1 audit tree endpoint + CLI `audit tree` (T8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/audit/tree endpoint that accepts executionId query param, authenticates via HTTP Basic + LDAP (OperationalAudit permission), calls IAuditLogRepository.GetExecutionTreeAsync, and returns a JSON array of ExecutionTreeNode. Returns 400 for missing/invalid GUID, 401/403 as normal. Add `scadabridge audit tree --execution-id [--format table|json]` CLI subcommand in AuditCommands.Build(). Adds AuditTreeHelpers with: - BuildUrl: constructs the relative URL + query string - RunTreeAsync: calls the endpoint, dispatches to table or JSON renderer - WriteTable: indented ASCII tree (root → children, [*] marks queried node) - WriteJson: pretty-printed JSON array pass-through Tests: 7 new ManagementService endpoint tests (valid id, empty, 400, 401, 403, Viewer allowed, wrong role), 18 new CLI tests (parse, render, HTTP error codes, JSON output, multi-level indentation, queried-node marker). --- .../Commands/AuditCommands.cs | 47 ++- .../Commands/AuditTreeHelpers.cs | 208 +++++++++++ .../AuditEndpoints.cs | 51 ++- .../Commands/AuditTreeCommandTests.cs | 346 ++++++++++++++++++ .../AuditEndpointsTests.cs | 175 +++++++++ 5 files changed, 822 insertions(+), 5 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditTreeHelpers.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs index 9b588e35..e608aa9e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs @@ -6,13 +6,15 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// The scadabridge audit command group (Audit Log #23 M8). Provides read access to /// the centralized append-only Audit Log via the Bundle B REST endpoints -/// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op -/// verify-chain placeholder for the deferred hash-chain tamper-evidence feature. +/// (GET /api/audit/query, GET /api/audit/export, +/// GET /api/audit/tree), plus a v1 no-op verify-chain placeholder +/// for the deferred hash-chain tamper-evidence feature. /// public static class AuditCommands { /// - /// Builds the audit command group with query, export, and verify-chain sub-commands. + /// Builds the audit command group with query, export, tree, and verify-chain + /// sub-commands. /// /// Global --url option for the management API endpoint. /// Global --format option for output format. @@ -25,6 +27,7 @@ public static class AuditCommands command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption)); return command; @@ -224,6 +227,44 @@ public static class AuditCommands return cmd; } + private static Command BuildTree(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var executionIdOption = new Option("--execution-id") + { + Description = "Execution ID (GUID) to look up — may be any node in the chain", + Required = true, + }; + + var cmd = new Command("tree") { Description = "Display the full execution-chain tree for an audit execution" }; + cmd.Add(executionIdOption); + + cmd.SetAction(async (ParseResult result) => + { + var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption); + if (connection.Error != null) + { + OutputFormatter.WriteError(connection.Error, connection.ErrorCode!); + return 1; + } + + var rawId = result.GetValue(executionIdOption); + if (!Guid.TryParse(rawId, out var executionId)) + { + OutputFormatter.WriteError( + $"Invalid execution ID '{rawId}'. Expected a GUID (e.g. 11111111-1111-1111-1111-111111111111).", + "INVALID_ARGUMENT"); + return 1; + } + + var format = AuditCommandHelpers.ResolveFormat(result, formatOption); + + using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); + return await AuditTreeHelpers.RunTreeAsync(client, executionId, format, Console.Out); + }); + + return cmd; + } + private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditTreeHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditTreeHelpers.cs new file mode 100644 index 00000000..66662949 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditTreeHelpers.cs @@ -0,0 +1,208 @@ +using System.Text; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; + +/// +/// Arguments for an audit tree invocation. +/// +public sealed class AuditTreeArgs +{ + /// + /// The execution ID (GUID) to look up. May be any node in the chain — the + /// server walks to the root and returns the full tree. + /// + public string ExecutionId { get; set; } = string.Empty; +} + +/// +/// Represents one execution node as returned by GET /api/audit/tree. +/// Property names match the server's camelCase JSON serialisation of +/// ExecutionTreeNode. +/// +internal sealed class AuditTreeNodeDto +{ + public Guid ExecutionId { get; init; } + public Guid? ParentExecutionId { get; init; } + public int RowCount { get; init; } + public string[] Channels { get; init; } = Array.Empty(); + public string[] Statuses { get; init; } = Array.Empty(); + public string? SourceSiteId { get; init; } + public string? SourceInstanceId { get; init; } + public DateTime? FirstOccurredAtUtc { get; init; } + public DateTime? LastOccurredAtUtc { get; init; } +} + +/// +/// Pure helpers for the audit tree subcommand: builds the query string, +/// calls GET /api/audit/tree, and renders the result as either an +/// indented ASCII tree (table format) or raw JSON. Kept separate from the +/// command wiring so each piece is unit-testable without standing up the +/// command tree. +/// +public static class AuditTreeHelpers +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + /// + /// Builds the query string for GET /api/audit/tree. + /// + /// The execution ID GUID. + /// A relative path + query string ready to append to the base URL. + public static string BuildUrl(Guid executionId) + => $"api/audit/tree?executionId={executionId:D}"; + + /// + /// Executes the tree lookup: GETs /api/audit/tree and renders the result + /// in the requested format. Returns the process exit code (0 = success, + /// 1 = error, 2 = authorization failure). + /// + /// The management HTTP client. + /// The execution ID to look up. + /// "table" (default) or "json". + /// The output writer for results. + /// A task that resolves to the process exit code. + public static async Task RunTreeAsync( + ManagementHttpClient client, + Guid executionId, + string format, + TextWriter output) + { + var url = BuildUrl(executionId); + var response = await client.SendGetAsync(url, TimeSpan.FromSeconds(30)); + + if (response.JsonData == null) + { + OutputFormatter.WriteError( + response.Error ?? "Audit tree request failed.", response.ErrorCode ?? "ERROR"); + return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1; + } + + var nodes = ParseNodes(response.JsonData); + + if (format == "json") + { + WriteJson(nodes, output); + } + else + { + WriteTable(nodes, executionId, output); + } + + output.Flush(); + return 0; + } + + /// + /// Parses the JSON array from the server into an array of + /// . + /// + /// The raw JSON response body. + /// An array of deserialized tree nodes (empty on parse failure). + internal static AuditTreeNodeDto[] ParseNodes(string json) + { + try + { + return JsonSerializer.Deserialize(json, JsonReadOptions) + ?? Array.Empty(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + /// + /// Renders the nodes as pretty-printed JSON to . + /// + internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output) + { + output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions)); + } + + /// + /// Renders the nodes as an indented ASCII tree. The root node (null + /// ParentExecutionId) is printed first; each child is indented + /// two spaces per depth level. The queried/entry-point node is marked + /// with [*]. + /// + internal static void WriteTable( + AuditTreeNodeDto[] nodes, + Guid queriedExecutionId, + TextWriter output) + { + if (nodes.Length == 0) + { + output.WriteLine("(no execution tree found)"); + return; + } + + // Build a parent → children lookup (keyed by non-null parent Guid). + // Nodes whose ParentExecutionId is null are roots and are not placed in + // the lookup; they are identified separately below. + var childrenOf = new Dictionary>(); + foreach (var node in nodes) + { + if (node.ParentExecutionId is { } parentId) + { + if (!childrenOf.ContainsKey(parentId)) + childrenOf[parentId] = new List(); + childrenOf[parentId].Add(node); + } + } + + // Identify roots: nodes whose ParentExecutionId is null, or whose parent + // is not present in the node set (stub-root case). + var nodeIds = new HashSet(nodes.Select(n => n.ExecutionId)); + var roots = nodes + .Where(n => n.ParentExecutionId == null || !nodeIds.Contains(n.ParentExecutionId.Value)) + .ToList(); + + // Render depth-first. + var sb = new StringBuilder(); + foreach (var root in roots) + { + RenderNode(root, depth: 0, childrenOf, queriedExecutionId, sb); + } + + output.Write(sb.ToString()); + } + + private static void RenderNode( + AuditTreeNodeDto node, + int depth, + Dictionary> childrenOf, + Guid queriedExecutionId, + StringBuilder sb) + { + var indent = new string(' ', depth * 2); + var marker = node.ExecutionId == queriedExecutionId ? " [*]" : string.Empty; + var channels = node.Channels.Length > 0 ? string.Join(",", node.Channels) : "-"; + var statuses = node.Statuses.Length > 0 ? string.Join(",", node.Statuses) : "-"; + var site = node.SourceSiteId ?? "-"; + var instance = node.SourceInstanceId ?? "-"; + var first = node.FirstOccurredAtUtc.HasValue + ? node.FirstOccurredAtUtc.Value.ToString("yyyy-MM-ddTHH:mm:ssZ") + : "-"; + + sb.AppendLine( + $"{indent}{node.ExecutionId:D}{marker} rows={node.RowCount} channels=[{channels}] statuses=[{statuses}] site={site} instance={instance} first={first}"); + + if (childrenOf.TryGetValue(node.ExecutionId, out var children)) + { + foreach (var child in children) + { + RenderNode(child, depth + 1, childrenOf, queriedExecutionId, sb); + } + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs index a66481f8..631ce5e6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs @@ -18,13 +18,17 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// /// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the -/// ScadaBridge CLI (M8). Two routes: +/// ScadaBridge CLI (M8). Three routes: /// /// GET /api/audit/query — keyset-paged JSON page, gated on the /// permission. /// GET /api/audit/export — streamed bulk export (csv / jsonl; /// parquet returns HTTP 501), gated on the /// permission. +/// GET /api/audit/tree — execution-chain tree rooted at the +/// topmost ancestor of a given executionId, returned as a JSON array +/// of ; gated on +/// . /// /// /// @@ -86,7 +90,8 @@ public static class AuditEndpoints }; /// - /// Registers the /api/audit/query and /api/audit/export minimal-API endpoints. + /// Registers the /api/audit/query, /api/audit/export, and + /// /api/audit/tree minimal-API endpoints. /// /// The endpoint route builder to register routes on. /// The same builder, for chaining. @@ -94,6 +99,7 @@ public static class AuditEndpoints { endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery); endpoints.MapGet("/api/audit/export", (Delegate)HandleExport); + endpoints.MapGet("/api/audit/tree", (Delegate)HandleTree); return endpoints; } @@ -232,6 +238,47 @@ public static class AuditEndpoints return Results.Empty; } + // ───────────────────────────────────────────────────────────────────── + // GET /api/audit/tree + // ───────────────────────────────────────────────────────────────────── + + /// + /// Handles GET /api/audit/tree?executionId=...: authenticates, checks the + /// OperationalAudit permission, and returns the full execution-chain tree rooted at + /// the topmost ancestor of the supplied executionId. The response is a JSON + /// array of objects (empty array when the id is + /// not found). Returns HTTP 400 when executionId is absent or not a valid + /// GUID. + /// + /// The HTTP context for the current request. + /// A task that resolves to the HTTP result (200 JSON array, 400, 401, or 403). + internal static async Task HandleTree(HttpContext context) + { + var auth = await AuthenticateAsync(context); + if (auth.Failure is not null) + { + return auth.Failure; + } + + if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles)) + { + return Forbidden("OperationalAudit"); + } + + var raw = context.Request.Query["executionId"].ToString(); + if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw, out var executionId)) + { + return Results.Json( + new { error = "Missing or invalid 'executionId' query parameter (expected a GUID).", code = "BAD_REQUEST" }, + statusCode: 400); + } + + var repo = context.RequestServices.GetRequiredService(); + var nodes = await repo.GetExecutionTreeAsync(executionId, context.RequestAborted); + + return Results.Json(nodes, JsonOptions); + } + /// /// Streams every matching row as RFC 4180 CSV, paging the repository with its /// keyset cursor and flushing after each page so a large export starts diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs new file mode 100644 index 00000000..cae5687c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs @@ -0,0 +1,346 @@ +using System.CommandLine; +using System.Net; +using System.Text; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.CLI; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; + +/// +/// Tests for the scadabridge audit tree subcommand (Audit Log #23 M5.1-T8): +/// tree rendering (table format), JSON output, error handling, and CLI parsing. +/// +[Collection("Console")] +public class AuditTreeCommandTests +{ + // ───────────────────────────────────────────────────────────────────── + // JSON parsing helpers + // ───────────────────────────────────────────────────────────────────── + + private static string NodeJson( + string executionId, + string? parentId = null, + int rowCount = 3, + string[]? channels = null, + string[]? statuses = null, + string? siteId = "plant-a", + string? instanceId = "inst-1", + string? first = "2026-05-20T10:00:00Z", + string? last = "2026-05-20T10:01:00Z") + { + var parentStr = parentId != null ? $"\"{parentId}\"" : "null"; + var channelArr = channels is { Length: > 0 } + ? "[" + string.Join(",", channels.Select(c => $"\"{c}\"")) + "]" + : "[\"ApiOutbound\"]"; + var statusArr = statuses is { Length: > 0 } + ? "[" + string.Join(",", statuses.Select(s => $"\"{s}\"")) + "]" + : "[\"Delivered\"]"; + var siteStr = siteId != null ? $"\"{siteId}\"" : "null"; + var instanceStr = instanceId != null ? $"\"{instanceId}\"" : "null"; + var firstStr = first != null ? $"\"{first}\"" : "null"; + var lastStr = last != null ? $"\"{last}\"" : "null"; + + return $@"{{ +""executionId"":""{executionId}"", +""parentExecutionId"":{parentStr}, +""rowCount"":{rowCount}, +""channels"":{channelArr}, +""statuses"":{statusArr}, +""sourceSiteId"":{siteStr}, +""sourceInstanceId"":{instanceStr}, +""firstOccurredAtUtc"":{firstStr}, +""lastOccurredAtUtc"":{lastStr} +}}"; + } + + // ───────────────────────────────────────────────────────────────────── + // ParseNodes + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void ParseNodes_ValidArray_ReturnsDtos() + { + var root = "11111111-1111-1111-1111-111111111111"; + var child = "22222222-2222-2222-2222-222222222222"; + var json = $"[{NodeJson(root)},{NodeJson(child, parentId: root)}]"; + + var nodes = AuditTreeHelpers.ParseNodes(json); + + Assert.Equal(2, nodes.Length); + Assert.Equal(Guid.Parse(root), nodes[0].ExecutionId); + Assert.Null(nodes[0].ParentExecutionId); + Assert.Equal(Guid.Parse(child), nodes[1].ExecutionId); + Assert.Equal(Guid.Parse(root), nodes[1].ParentExecutionId); + Assert.Equal(3, nodes[0].RowCount); + } + + [Fact] + public void ParseNodes_EmptyArray_ReturnsEmpty() + { + var nodes = AuditTreeHelpers.ParseNodes("[]"); + Assert.Empty(nodes); + } + + [Fact] + public void ParseNodes_InvalidJson_ReturnsEmpty() + { + var nodes = AuditTreeHelpers.ParseNodes("not-json"); + Assert.Empty(nodes); + } + + // ───────────────────────────────────────────────────────────────────── + // WriteTable — ASCII tree rendering + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void WriteTable_EmptyNodes_PrintsFallbackMessage() + { + var output = new StringWriter(); + AuditTreeHelpers.WriteTable(Array.Empty(), Guid.NewGuid(), output); + Assert.Contains("no execution tree found", output.ToString()); + } + + [Fact] + public void WriteTable_SingleRootNode_PrintsWithNoIndent() + { + var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId.ToString())}]"); + + var output = new StringWriter(); + AuditTreeHelpers.WriteTable(nodes, rootId, output); + var text = output.ToString(); + + // Root node printed at column 0 (no leading spaces). + var line = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).First(); + Assert.StartsWith(rootId.ToString("D"), line); + Assert.Contains("[*]", line); // queried node marked + } + + [Fact] + public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly() + { + var rootId = "11111111-1111-1111-1111-111111111111"; + var childId = "22222222-2222-2222-2222-222222222222"; + var grandChildId = "33333333-3333-3333-3333-333333333333"; + var json = $"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)},{NodeJson(grandChildId, parentId: childId)}]"; + var nodes = AuditTreeHelpers.ParseNodes(json); + + var output = new StringWriter(); + AuditTreeHelpers.WriteTable(nodes, Guid.Parse(rootId), output); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Root: no indent. + Assert.True(lines[0].StartsWith(rootId, StringComparison.OrdinalIgnoreCase) || + lines[0].StartsWith(rootId.ToUpper(), StringComparison.OrdinalIgnoreCase)); + + // Child: 2-space indent (exactly 2, not 4+). + var childLine = lines.First(l => l.Contains(childId)); + Assert.StartsWith(" ", childLine); + Assert.False(childLine.StartsWith(" ", StringComparison.Ordinal), "child should be indented exactly 2, not 4+"); + + // Grandchild: 4-space indent. + var grandLine = lines.First(l => l.Contains(grandChildId)); + Assert.StartsWith(" ", grandLine); + } + + [Fact] + public void WriteTable_QueriedNodeIsMarked_OthersAreNot() + { + var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var childId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var json = $"[{NodeJson(rootId.ToString())},{NodeJson(childId.ToString(), parentId: rootId.ToString())}]"; + var nodes = AuditTreeHelpers.ParseNodes(json); + + // Query via child ID — child should be marked, root should not. + var output = new StringWriter(); + AuditTreeHelpers.WriteTable(nodes, childId, output); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + + var childLine = lines.First(l => l.Contains(childId.ToString("D"))); + var rootLine = lines.First(l => l.Contains(rootId.ToString("D"))); + Assert.Contains("[*]", childLine); + Assert.DoesNotContain("[*]", rootLine); + } + + // ───────────────────────────────────────────────────────────────────── + // WriteJson + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void WriteJson_ValidNodes_EmitsValidJsonArray() + { + var rootId = "11111111-1111-1111-1111-111111111111"; + var childId = "22222222-2222-2222-2222-222222222222"; + var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)}]"); + + var output = new StringWriter(); + AuditTreeHelpers.WriteJson(nodes, output); + var text = output.ToString(); + + using var doc = JsonDocument.Parse(text); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + } + + [Fact] + public void WriteJson_EmptyNodes_EmitsEmptyArray() + { + var output = new StringWriter(); + AuditTreeHelpers.WriteJson(Array.Empty(), output); + var text = output.ToString().Trim(); + + using var doc = JsonDocument.Parse(text); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } + + // ───────────────────────────────────────────────────────────────────── + // RunTreeAsync — HTTP execution + // ───────────────────────────────────────────────────────────────────── + + private sealed class FixedHandler : HttpMessageHandler + { + private readonly HttpStatusCode _status; + private readonly string _body; + + public FixedHandler(HttpStatusCode status, string body) + { + _status = status; + _body = body; + } + + public string? LastRequestUri { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri!.PathAndQuery; + return Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_body, Encoding.UTF8, "application/json"), + }); + } + } + + [Fact] + public async Task RunTree_Success_ReturnsZeroAndWritesOutput() + { + var rootId = "11111111-1111-1111-1111-111111111111"; + var json = $"[{NodeJson(rootId)}]"; + var handler = new FixedHandler(HttpStatusCode.OK, json); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditTreeHelpers.RunTreeAsync( + client, Guid.Parse(rootId), "table", output); + + Assert.Equal(0, exit); + Assert.Contains(rootId, output.ToString()); + } + + [Fact] + public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage() + { + var handler = new FixedHandler(HttpStatusCode.OK, "[]"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditTreeHelpers.RunTreeAsync( + client, Guid.NewGuid(), "table", output); + + Assert.Equal(0, exit); + Assert.Contains("no execution tree found", output.ToString()); + } + + [Fact] + public async Task RunTree_JsonFormat_EmitsValidJson() + { + var rootId = "11111111-1111-1111-1111-111111111111"; + var handler = new FixedHandler(HttpStatusCode.OK, $"[{NodeJson(rootId)}]"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditTreeHelpers.RunTreeAsync( + client, Guid.Parse(rootId), "json", output); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(output.ToString()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + } + + [Fact] + public async Task RunTree_Http403_ReturnsExitCode2() + { + var handler = new FixedHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditTreeHelpers.RunTreeAsync( + client, Guid.NewGuid(), "table", output); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task RunTree_Http500_ReturnsExitCode1() + { + var handler = new FixedHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditTreeHelpers.RunTreeAsync( + client, Guid.NewGuid(), "table", output); + + Assert.Equal(1, exit); + } + + [Fact] + public async Task RunTree_RequestUrlContainsExecutionId() + { + var id = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var handler = new FixedHandler(HttpStatusCode.OK, "[]"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + await AuditTreeHelpers.RunTreeAsync(client, id, "table", output); + + Assert.Contains("11111111-1111-1111-1111-111111111111", handler.LastRequestUri); + Assert.Contains("executionId", handler.LastRequestUri); + } + + // ───────────────────────────────────────────────────────────────────── + // CLI parsing — audit tree subcommand + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Tree_Subcommand_ExistsInAuditCommandGroup() + { + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] { "audit", "tree", "--help" }); + // --help is never an error, exit 0. + Assert.Empty(parse.Errors); + } + + [Fact] + public void Tree_ExecutionIdOption_IsRequired() + { + // Invoking without --execution-id must produce an error (the option is Required). + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "tree"); + // System.CommandLine returns non-zero for a missing required option. + Assert.NotEqual(0, exit); + } + + [Fact] + public void Tree_HelpText_DescribesExecutionId() + { + var root = AuditCommandTestHarness.BuildRoot(); + var output = new StringWriter(); + var exit = root.Parse(new[] { "audit", "tree", "--help" }) + .Invoke(new InvocationConfiguration { Output = output }); + + Assert.Equal(0, exit); + Assert.Contains("execution-id", output.ToString()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs index 0013617a..53b95fa9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs @@ -610,4 +610,179 @@ public class AuditEndpointsTests Assert.NotNull(result); Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds); } + + // ───────────────────────────────────────────────────────────────────── + // /api/audit/tree + // ───────────────────────────────────────────────────────────────────── + + /// + /// Builds a TestServer with the audit-log endpoints wired up and the repository + /// stub returning the supplied for + /// GetExecutionTreeAsync. + /// + private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync( + string[] roles, + IReadOnlyList? treeNodes = null) + { + var repo = Substitute.For(); + + // Default QueryAsync stub so the shared host initialisation does not fail. + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var returnNodes = treeNodes ?? Array.Empty(); + repo.GetExecutionTreeAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(returnNodes)); + + var ldap = Substitute.For(); + ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })); + + var roleMapper = Substitute.For(Substitute.For()); + roleMapper.MapGroupsToRolesAsync(Arg.Any>(), Arg.Any()) + .Returns(new RoleMappingResult(roles, Array.Empty(), IsSystemWideDeployment: true)); + + var hostBuilder = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(repo); + services.AddSingleton(ldap); + services.AddSingleton(roleMapper); + }); + web.Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapAuditAPI()); + }); + }); + + var host = await hostBuilder.StartAsync(); + return (host.GetTestClient(), repo, host); + } + + private static ExecutionTreeNode MakeNode(Guid id, Guid? parentId = null, int rowCount = 2) => + new ExecutionTreeNode( + ExecutionId: id, + ParentExecutionId: parentId, + RowCount: rowCount, + Channels: new[] { "ApiOutbound" }, + Statuses: new[] { "Delivered" }, + SourceSiteId: "plant-a", + SourceInstanceId: "inst-1", + FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc)); + + [Fact] + public async Task Tree_ValidExecutionId_ReturnsJsonArray() + { + var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + var child = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"); + var nodes = new[] + { + MakeNode(root), + MakeNode(child, parentId: root), + }; + + var (client, repo, host) = await BuildHostWithTreeAsync( + roles: new[] { "Administrator" }, + treeNodes: nodes); + using (host) + { + var response = await client.SendAsync(Get($"/api/audit/tree?executionId={root:D}")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + + await repo.Received(1).GetExecutionTreeAsync(root, Arg.Any()); + } + } + + [Fact] + public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray() + { + var id = Guid.NewGuid(); + var (client, _, host) = await BuildHostWithTreeAsync( + roles: new[] { "Administrator" }, + treeNodes: Array.Empty()); + using (host) + { + var response = await client.SendAsync(Get($"/api/audit/tree?executionId={id:D}")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } + } + + [Fact] + public async Task Tree_MissingExecutionId_Returns400() + { + var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/tree")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } + + [Fact] + public async Task Tree_InvalidExecutionId_Returns400() + { + var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/tree?executionId=not-a-guid")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("BAD_REQUEST", body); + } + } + + [Fact] + public async Task Tree_WithoutOperationalAudit_Returns403() + { + var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Designer" }); + using (host) + { + var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } + + [Fact] + public async Task Tree_WithoutCredentials_Returns401() + { + var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); + using (host) + { + var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}", credential: "")); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + } + + [Fact] + public async Task Tree_ViewerRole_IsAllowed() + { + var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Viewer" }); + using (host) + { + var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } }