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