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:
@@ -6,13 +6,15 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
/// <summary>
|
||||
/// The <c>scadabridge audit</c> command group (Audit Log #23 M8). Provides read access to
|
||||
/// the centralized append-only Audit Log via the Bundle B REST endpoints
|
||||
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>), plus a v1 no-op
|
||||
/// <c>verify-chain</c> placeholder for the deferred hash-chain tamper-evidence feature.
|
||||
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
|
||||
/// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder
|
||||
/// for the deferred hash-chain tamper-evidence feature.
|
||||
/// </summary>
|
||||
public static class AuditCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>audit</c> command group with query, export, and verify-chain sub-commands.
|
||||
/// Builds the <c>audit</c> command group with query, export, tree, and verify-chain
|
||||
/// sub-commands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
||||
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
||||
@@ -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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var executionIdOption = new Option<string>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for an <c>audit tree</c> invocation.
|
||||
/// </summary>
|
||||
public sealed class AuditTreeArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string ExecutionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents one execution node as returned by <c>GET /api/audit/tree</c>.
|
||||
/// Property names match the server's camelCase JSON serialisation of
|
||||
/// <c>ExecutionTreeNode</c>.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
public string[] Statuses { get; init; } = Array.Empty<string>();
|
||||
public string? SourceSiteId { get; init; }
|
||||
public string? SourceInstanceId { get; init; }
|
||||
public DateTime? FirstOccurredAtUtc { get; init; }
|
||||
public DateTime? LastOccurredAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>audit tree</c> subcommand: builds the query string,
|
||||
/// calls <c>GET /api/audit/tree</c>, 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.
|
||||
/// </summary>
|
||||
public static class AuditTreeHelpers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonWriteOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the query string for <c>GET /api/audit/tree</c>.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID GUID.</param>
|
||||
/// <returns>A relative path + query string ready to append to the base URL.</returns>
|
||||
public static string BuildUrl(Guid executionId)
|
||||
=> $"api/audit/tree?executionId={executionId:D}";
|
||||
|
||||
/// <summary>
|
||||
/// Executes the tree lookup: GETs <c>/api/audit/tree</c> and renders the result
|
||||
/// in the requested format. Returns the process exit code (0 = success,
|
||||
/// 1 = error, 2 = authorization failure).
|
||||
/// </summary>
|
||||
/// <param name="client">The management HTTP client.</param>
|
||||
/// <param name="executionId">The execution ID to look up.</param>
|
||||
/// <param name="format">"table" (default) or "json".</param>
|
||||
/// <param name="output">The output writer for results.</param>
|
||||
/// <returns>A task that resolves to the process exit code.</returns>
|
||||
public static async Task<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON array from the server into an array of
|
||||
/// <see cref="AuditTreeNodeDto"/>.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw JSON response body.</param>
|
||||
/// <returns>An array of deserialized tree nodes (empty on parse failure).</returns>
|
||||
internal static AuditTreeNodeDto[] ParseNodes(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditTreeNodeDto[]>(json, JsonReadOptions)
|
||||
?? Array.Empty<AuditTreeNodeDto>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<AuditTreeNodeDto>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the nodes as pretty-printed JSON to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output)
|
||||
{
|
||||
output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the nodes as an indented ASCII tree. The root node (null
|
||||
/// <c>ParentExecutionId</c>) is printed first; each child is indented
|
||||
/// two spaces per depth level. The queried/entry-point node is marked
|
||||
/// with <c> [*]</c>.
|
||||
/// </summary>
|
||||
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<Guid, List<AuditTreeNodeDto>>();
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.ParentExecutionId is { } parentId)
|
||||
{
|
||||
if (!childrenOf.ContainsKey(parentId))
|
||||
childrenOf[parentId] = new List<AuditTreeNodeDto>();
|
||||
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<Guid>(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<Guid, List<AuditTreeNodeDto>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,17 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the
|
||||
/// ScadaBridge CLI (M8). Two routes:
|
||||
/// ScadaBridge CLI (M8). Three routes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/audit/query</c> — keyset-paged JSON page, gated on the
|
||||
/// <see cref="AuthorizationPolicies.OperationalAudit"/> permission.</item>
|
||||
/// <item><c>GET /api/audit/export</c> — streamed bulk export (csv / jsonl;
|
||||
/// parquet returns HTTP 501), gated on the
|
||||
/// <see cref="AuthorizationPolicies.AuditExport"/> permission.</item>
|
||||
/// <item><c>GET /api/audit/tree</c> — execution-chain tree rooted at the
|
||||
/// topmost ancestor of a given <c>executionId</c>, returned as a JSON array
|
||||
/// of <see cref="ExecutionTreeNode"/>; gated on
|
||||
/// <see cref="AuthorizationPolicies.OperationalAudit"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
@@ -86,7 +90,8 @@ public static class AuditEndpoints
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <c>/api/audit/query</c> and <c>/api/audit/export</c> minimal-API endpoints.
|
||||
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>, and
|
||||
/// <c>/api/audit/tree</c> minimal-API endpoints.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
|
||||
/// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>GET /api/audit/tree?executionId=...</c>: authenticates, checks the
|
||||
/// OperationalAudit permission, and returns the full execution-chain tree rooted at
|
||||
/// the topmost ancestor of the supplied <c>executionId</c>. The response is a JSON
|
||||
/// array of <see cref="ExecutionTreeNode"/> objects (empty array when the id is
|
||||
/// not found). Returns HTTP 400 when <c>executionId</c> is absent or not a valid
|
||||
/// GUID.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <returns>A task that resolves to the HTTP result (200 JSON array, 400, 401, or 403).</returns>
|
||||
internal static async Task<IResult> 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<IAuditLogRepository>();
|
||||
var nodes = await repo.GetExecutionTreeAsync(executionId, context.RequestAborted);
|
||||
|
||||
return Results.Json(nodes, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user