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);
+ }
+ }
}