Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs
T
Joseph Doherty 0569c5ff23 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).
2026-06-16 21:20:54 -04:00

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