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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user