feat(audit): M5.1 audit tree endpoint + CLI audit tree (T8)

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 <guid> [--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).
This commit is contained in:
Joseph Doherty
2026-06-16 21:20:54 -04:00
parent 0e9bcbb676
commit 0569c5ff23
5 changed files with 822 additions and 5 deletions
@@ -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;
/// <summary>
/// Tests for the <c>scadabridge audit tree</c> subcommand (Audit Log #23 M5.1-T8):
/// tree rendering (table format), JSON output, error handling, and CLI parsing.
/// </summary>
[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<AuditTreeNodeDto>(), 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<AuditTreeNodeDto>(), 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<HttpResponseMessage> 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());
}
}
@@ -610,4 +610,179 @@ public class AuditEndpointsTests
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/tree
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a TestServer with the audit-log endpoints wired up and the repository
/// stub returning the supplied <paramref name="treeNodes"/> for
/// <c>GetExecutionTreeAsync</c>.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
string[] roles,
IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
{
var repo = Substitute.For<IAuditLogRepository>();
// Default QueryAsync stub so the shared host initialisation does not fail.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }));
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), 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<CancellationToken>());
}
}
[Fact]
public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray()
{
var id = Guid.NewGuid();
var (client, _, host) = await BuildHostWithTreeAsync(
roles: new[] { "Administrator" },
treeNodes: Array.Empty<ExecutionTreeNode>());
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);
}
}
}