0569c5ff23
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).
347 lines
15 KiB
C#
347 lines
15 KiB
C#
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());
|
|
}
|
|
}
|