Complete management API and CLI command parity

This commit is contained in:
Joseph Doherty
2026-09-10 00:51:45 -04:00
parent 477e2abe97
commit 2750bd2809
38 changed files with 1637 additions and 199 deletions
@@ -18,46 +18,15 @@ public class CommandTreeTests
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in
// src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs. When a new command group is added (or one is
// removed/renamed), update this array and bump the count assertion in
// AllCommandGroups_Build_WithoutThrowing accordingly.
private static IEnumerable<Command> AllCommandGroups() => new[]
{
TemplateCommands.Build(Url, Format, Username, Password),
InstanceCommands.Build(Url, Format, Username, Password),
SiteCommands.Build(Url, Format, Username, Password),
DeployCommands.Build(Url, Format, Username, Password),
BuildDataConnectionGroup(),
ExternalSystemCommands.Build(Url, Format, Username, Password),
NotificationCommands.Build(Url, Format, Username, Password),
SecurityCommands.Build(Url, Format, Username, Password),
AuditLogCommands.Build(Url, Format, Username, Password),
AuditCommands.Build(Url, Format, Username, Password),
HealthCommands.Build(Url, Format, Username, Password),
DebugCommands.Build(Url, Format, Username, Password),
SharedScriptCommands.Build(Url, Format, Username, Password),
DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password),
CachedCallCommands.Build(Url, Format, Username, Password),
SecuredWriteCommands.Build(Url, Format, Username, Password),
FlowDebugCommands.Build(Url, Format, Username, Password),
};
private static Command BuildDataConnectionGroup()
{
var command = DataConnectionCommands.Build(Url, Format, Username, Password);
ConnectionBrowseCommands.AddTo(command, Url, Format, Username, Password);
return command;
}
// Exercise the production root: a separately maintained group list can drift.
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
[Fact]
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
{
var groups = AllCommandGroups().ToList();
var secured = groups.Single(g => g.Name == "secured-write");
Assert.Equal("list", Assert.Single(secured.Subcommands).Name);
Assert.Equal(new[] { "approve", "list", "reject", "submit" }, secured.Subcommands.Select(c => c.Name).Order());
var connection = groups.Single(g => g.Name == "data-connection");
foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" })
Assert.Contains(connection.Subcommands, c => c.Name == name);
@@ -82,12 +51,7 @@ public class CommandTreeTests
public void AllCommandGroups_Build_WithoutThrowing()
{
var groups = AllCommandGroups().ToList();
// CLI-022: bump this count whenever a new top-level command group is
// registered in Program.cs. Current registered groups (19):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle, cached-call, secured-write, flow-debug.
Assert.Equal(19, groups.Count);
Assert.Equal(20, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
}
@@ -0,0 +1,75 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class DeploymentDeadlineParityTests
{
[Fact]
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
{
// Run the four independent loopback requests together: one 31-second window,
// exercising the real commands and socket transport past the old 30-second cap.
await Task.WhenAll(
InvokeDelayed(["deploy", "instance", "--id", "1"], "MgmtDeployInstance"),
InvokeDelayed(["instance", "deploy", "--id", "1"], "MgmtDeployInstance"),
InvokeDelayed(["deploy", "artifacts", "--site-id", "1"], "MgmtDeployArtifacts"),
InvokeDelayed(["site", "deploy-artifacts", "--site-id", "1"], "MgmtDeployArtifacts"));
}
private static async Task InvokeDelayed(string[] args, string expectedCommand)
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync(deadline.Token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync(deadline.Token) is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var request = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(request.AsMemory(read), deadline.Token);
if (count == 0) throw new EndOfStreamException();
read += count;
}
using var payload = JsonDocument.Parse(new string(request));
Assert.Equal(expectedCommand, payload.RootElement.GetProperty("command").GetString());
await Task.Delay(TimeSpan.FromSeconds(31), deadline.Token);
var reply = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
try { await stream.WriteAsync(reply, deadline.Token); }
catch (IOException) { /* Old client closes at 30s; exit assertion diagnoses it. */ }
}, deadline.Token);
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(DeployCommands.Build(url, format, user, password));
root.Add(InstanceCommands.Build(url, format, user, password));
root.Add(SiteCommands.Build(url, format, user, password));
var result = root.Parse([.. args, "--url", $"http://127.0.0.1:{port}",
"--username", "test", "--password", "test", "--format", "json"]);
Assert.Empty(result.Errors);
var exit = await result.InvokeAsync().WaitAsync(deadline.Token);
await server;
Assert.Equal(0, exit);
}
finally { listener.Stop(); }
}
}
@@ -0,0 +1,82 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class EditingParityTests
{
private static RootCommand BuildRoot()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(TemplateCommands.Build(url, format, user, password));
root.Add(InstanceCommands.Build(url, format, user, password));
root.Add(DataConnectionCommands.Build(url, format, user, password));
return root;
}
[Theory]
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
[InlineData("template move --id 12", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":null}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source --filter Active --description Updated --locked", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":\"Active\",\"description\":\"Updated\",\"isLocked\":true}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":null,\"description\":null,\"isLocked\":false}")]
[InlineData("instance attribute-override delete --instance-id 6 --attribute Module.Value", "DeleteInstanceAttributeOverride", "{\"instanceId\":6,\"attributeName\":\"Module.Value\"}")]
[InlineData("instance native-alarm-source list --instance-id 6", "ListInstanceNativeAlarmSourceOverrides", "{\"instanceId\":6}")]
[InlineData("instance alarm-override list --instance-id 6", "ListInstanceAlarmOverrides", "{\"instanceId\":6}")]
[InlineData("data-connection move --id 4 --site-id 7", "MoveDataConnection", "{\"dataConnectionId\":4,\"targetSiteId\":7}")]
public async Task Command_SendsExactManagementEnvelope(string arguments, string command, string payload)
{
var root = BuildRoot();
Assert.Empty(root.Parse(arguments).Errors);
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var requestLine = await reader.ReadLineAsync();
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } header)
if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(header.Split(':')[1]);
var buffer = new char[length];
Assert.Equal(length, await reader.ReadBlockAsync(buffer, 0, length));
var response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
await stream.WriteAsync(response);
return (requestLine, body: new string(buffer));
});
var args = arguments.Split(' ').Concat(new[] { "--url", $"http://127.0.0.1:{port}", "--username", "test", "--password", "test", "--format", "json" }).ToArray();
Assert.Equal(0, await root.Parse(args).InvokeAsync());
var request = await server.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("POST /management HTTP/1.1", request.requestLine);
using var actual = JsonDocument.Parse(request.body);
Assert.Equal(command, actual.RootElement.GetProperty("command").GetString());
Assert.True(JsonElement.DeepEquals(JsonDocument.Parse(payload).RootElement, actual.RootElement.GetProperty("payload")), request.body);
}
finally { listener.Stop(); }
}
[Theory]
[InlineData("template resolved-members")]
[InlineData("template move --id 12 --folder-id invalid")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC")]
[InlineData("instance attribute-override delete --instance-id 6")]
[InlineData("instance native-alarm-source list")]
[InlineData("data-connection move --id 4")]
public void Command_RejectsMissingOrMalformedArguments(string arguments)
=> Assert.NotEmpty(BuildRoot().Parse(arguments).Errors);
}
@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Drives the production command tree against a loopback recorder. Every public
/// management contract must actually cross HTTP from a CLI action. A constructor
/// reference, an unregistered group, or a help-only leaf cannot satisfy this gate.
/// Server replies are deliberate errors so no local success handlers write exports.
/// </summary>
[Collection("Console")]
public class ManagementSurfaceParityTests
{
[Fact]
public async Task EveryHttpManagementContract_IsSentByTheProductionCli()
{
var sent = await ProbeProductionTree();
var registered = typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && !t.IsAbstract
&& t.Name.EndsWith("Command", StringComparison.Ordinal))
.Where(t => ManagementCommandRegistry.Resolve(t.Name[..^7]) == t)
.Select(ManagementCommandRegistry.GetCommandName).ToHashSet();
var missing = registered.Except(sent.Keys).Order().ToArray();
Assert.True(missing.Length == 0, "No CLI dispatch for: " + string.Join(", ", missing));
Assert.Empty(sent.Keys.Except(registered));
}
[Fact]
public async Task RemovingProductionSchemaGroup_IsDetectedByParityProbe()
{
var sent = await ProbeProductionTree(omitGroup: "shared-schema");
Assert.DoesNotContain("ListSharedSchemas", sent.Keys);
Assert.Contains("ListSharedScripts", sent.Keys);
}
private static async Task<ConcurrentDictionary<string, string>> ProbeProductionTree(string? omitGroup = null)
{
var root = CliApplication.BuildRoot();
var recorded = new ConcurrentDictionary<string, string>();
var directory = Directory.CreateTempSubdirectory("cli-parity-");
var file = Path.Combine(directory.FullName, "input.json");
await File.WriteAllTextAsync(file, "{}");
var csv = Path.Combine(directory.FullName, "native.csv");
await File.WriteAllTextAsync(csv, "SourceName,Connection,SourceReference,Filter\nSource,Connection,Path,\n");
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
var currentPath = "";
var server = Task.Run(async () =>
{
try
{
while (!stop.IsCancellationRequested)
{
using var client = await listener.AcceptTcpClientAsync(stop.Token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync(stop.Token) is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var buffer = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(buffer.AsMemory(read), stop.Token);
if (count == 0) throw new EndOfStreamException();
read += count;
}
using var body = JsonDocument.Parse(new string(buffer));
recorded[body.RootElement.GetProperty("command").GetString()!] = currentPath;
const string response = "{\"error\":\"parity probe\",\"code\":\"PROBE\"}";
var bytes = Encoding.UTF8.GetBytes($"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {response.Length}\r\nConnection: close\r\n\r\n{response}");
await stream.WriteAsync(bytes, stop.Token);
}
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
}, stop.Token);
try
{
foreach (var (leaf, path) in Leaves(root, []))
{
// Separate REST and persistent SignalR surfaces, not management contracts.
if (path.Length == 0 || path[0] == "audit" || path[0] == omitGroup
|| string.Join(' ', path) == "debug stream") continue;
currentPath = string.Join(' ', path);
var args = new List<string>(path);
foreach (var option in leaf.Options.Where(o => o.Required))
{
args.Add(option.Name);
args.Add(ValueFor(option, currentPath, file, csv));
}
AddConditionalOptions(args, currentPath);
args.AddRange(["--url", url, "--username", "probe", "--password", "probe", "--format", "json"]);
var parse = root.Parse(args.ToArray());
// Some convenience aliases require richer local files/preflight replies;
// other leaves must still emit every contract. Missing coverage fails above.
if (parse.Errors.Count > 0) continue;
await parse.InvokeAsync().WaitAsync(stop.Token);
}
}
finally
{
await stop.CancelAsync();
listener.Stop();
await server;
directory.Delete(recursive: true);
}
return recorded;
}
private static IEnumerable<(Command Leaf, string[] Path)> Leaves(Command command, string[] path)
{
if (command.Subcommands.Count == 0) yield return (command, path);
foreach (var child in command.Subcommands)
foreach (var entry in Leaves(child, [.. path, child.Name])) yield return entry;
}
private static string ValueFor(Option option, string path, string file, string csv)
{
var type = option.GetType().GetGenericArguments().FirstOrDefault();
type = Nullable.GetUnderlyingType(type ?? typeof(string)) ?? type;
if (type == typeof(int) || type == typeof(long) || type == typeof(double)) return "1";
if (type == typeof(Guid)) return "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
if (type == typeof(bool)) return "true";
if (type?.IsEnum == true) return Enum.GetNames(type)[0];
return option.Name switch
{
"--schema-file" or "--input" or "--config-file" or "--der-file" => file,
"--output" => file + ".out",
"--file" => csv,
"--bindings" => "[[\"Value\",1]]",
"--direction" => "up",
"--overrides" or "--config" => "{}",
"--value-json" => "1",
"--data-type" or "--value-type" => "Int32",
"--trigger-type" => "Periodic",
"--auth-type" or "--auth-mode" => "None",
"--http-method" => "GET",
"--type" => "OpcUa",
"--endpoint-url" => "http://localhost",
"--account-sid" => "AC" + new string('a', 32),
"--from-address" => "sender@example.test",
"--thumbprint" => new string('A', 40),
"--role" => "Designer",
"--code" or "--script" => "return null;",
_ => "fixture"
};
}
private static void AddConditionalOptions(List<string> args, string path)
{
if (path is "notification create" or "notification update")
args.AddRange(["--emails", "recipient@example.test"]);
if (path is "notification sms create" or "notification sms update")
args.AddRange(["--from-number", "+15551234567"]);
if (path == "notification sms create") args.AddRange(["--auth-token", "test-token"]);
if (path == "notification recipient add") args.AddRange(["--email", "recipient@example.test"]);
}
}
@@ -0,0 +1,130 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class NotificationParityTests
{
private static RootCommand Root()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(NotificationCommands.Build(url, format, user, password));
return root;
}
[Theory]
[InlineData("rename --id 7 --name Operators", "RenameNotificationListCommand", "notificationListId", "7")]
[InlineData("recipient add --list-id 7 --name Jane --email jane@example.com", "AddNotificationRecipientCommand", "name", "Jane")]
[InlineData("recipient add --list-id 7 --name Jane --phone +15551234567", "AddNotificationRecipientCommand", "phoneNumber", "+15551234567")]
[InlineData("recipient delete --id 42", "DeleteNotificationRecipientCommand", "recipientId", "42")]
[InlineData("smtp create --server mail.example.com --port 587 --auth-mode basic --from-address ops@example.com --tls-mode StartTLS --credentials test:secret", "CreateSmtpConfigCommand", "tlsMode", "StartTLS")]
[InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --auth-token secret", "CreateSmsConfigCommand", "authToken", "secret")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --connection-timeout-seconds 15 --max-retries 4 --retry-delay-seconds 90", "UpdateSmsConfigCommand", "retryDelay", "00:01:30")]
public async Task InvocationSendsDedicatedContract(string arguments, string command, string property, string expected)
{
var body = await Capture(arguments);
Assert.Equal(command.Replace("Command", ""), body.GetProperty("command").GetString());
var payload = body.GetProperty("payload");
Assert.Equal(expected, payload.GetProperty(property).ToString());
if (command == "AddNotificationRecipientCommand")
{
Assert.Equal(7, payload.GetProperty("notificationListId").GetInt32());
Assert.Equal("Jane", payload.GetProperty("name").GetString());
Assert.Equal(arguments.Contains("--email") ? "jane@example.com" : null, payload.GetProperty("emailAddress").GetString());
Assert.Equal(arguments.Contains("--phone") ? "+15551234567" : null, payload.GetProperty("phoneNumber").GetString());
}
if (command == "RenameNotificationListCommand")
{
Assert.Equal("Operators", payload.GetProperty("name").GetString());
Assert.False(payload.TryGetProperty("recipientEmails", out _));
}
}
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
public async Task SmsPolicyExplicitValuesIncludingZeroAreForwarded(string verb)
{
var payload = (await Capture($"sms {verb} --account-sid AC00000000000000000000000000000000 --messaging-service-sid MG123 --connection-timeout-seconds 15 --max-retries 0 --retry-delay-seconds 0")).GetProperty("payload");
Assert.Equal(15, payload.GetProperty("connectionTimeoutSeconds").GetInt32());
Assert.Equal(0, payload.GetProperty("maxRetries").GetInt32());
Assert.Equal("00:00:00", payload.GetProperty("retryDelay").GetString());
Assert.Equal(JsonValueKind.Null, payload.GetProperty("fromNumber").ValueKind);
}
[Fact]
public async Task SmtpCreateForwardsAllOptionalFields()
{
var payload = (await Capture("smtp create --server mail.example.com --port 587 --auth-mode oauth2 --from-address ops@example.com --credentials secret --oauth2-authority https://login.example.com/token --oauth2-scope scope --transport Smtp --tls-mode StartTLS")).GetProperty("payload");
Assert.Equal("secret", payload.GetProperty("credentials").GetString());
Assert.Equal("https://login.example.com/token", payload.GetProperty("oAuth2Authority").GetString());
Assert.Equal("scope", payload.GetProperty("oAuth2Scope").GetString());
Assert.Equal("Smtp", payload.GetProperty("transport").GetString());
}
[Fact]
public async Task SmtpCreateOmittedOptionsUseServerDefaults()
{
var payload = (await Capture("smtp create --server mail.example.com --port 25 --auth-mode none --from-address ops@example.com")).GetProperty("payload");
foreach (var name in new[] { "tlsMode", "credentials", "oAuth2Authority", "oAuth2Scope", "transport" })
Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind);
}
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
public async Task OmittedSmsPolicyAndSecretsRemainNull(string verb)
{
var payload = (await Capture($"sms {verb} --account-sid AC00000000000000000000000000000000 --from-number +15551234567")).GetProperty("payload");
foreach (var name in new[] { "connectionTimeoutSeconds", "maxRetries", "retryDelay", "apiBaseUrl", "messagingServiceSid" })
Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind);
if (verb.StartsWith("update")) Assert.Equal(JsonValueKind.Null, payload.GetProperty("authToken").ValueKind);
}
[Theory]
[InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --max-retries -1")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --connection-timeout-seconds 0")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --retry-delay-seconds -1")]
[InlineData("recipient add --list-id 7 --name Jane")]
[InlineData("recipient delete --id 0")]
public void InvalidInputFailsParsing(string arguments)
{
Assert.NotEmpty(Root().Parse(("notification " + arguments).Split(' ')).Errors);
}
private static async Task<JsonElement> Capture(string arguments)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var parse = Root().Parse(("notification " + arguments + $" --url http://127.0.0.1:{port} --username test --password test --format json").Split(' '));
Assert.Empty(parse.Errors);
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) length = int.Parse(line.Split(':')[1]);
var buffer = new char[length];
await reader.ReadBlockAsync(buffer);
var body = JsonDocument.Parse(new string(buffer)).RootElement.Clone();
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"));
return body;
});
Assert.Equal(0, await parse.InvokeAsync());
return await server.WaitAsync(TimeSpan.FromSeconds(5));
}
}
@@ -0,0 +1,20 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class ProductionRootTests
{
[Fact]
public void ProductionRoot_ExposesSchemaLibraryAndSecuredWriteLifecycle()
{
var rootType = typeof(SecuredWriteCommands).Assembly.GetType("ZB.MOM.WW.ScadaBridge.CLI.CliApplication");
Assert.NotNull(rootType);
var method = rootType.GetMethod("BuildRoot");
Assert.NotNull(method);
var root = Assert.IsType<RootCommand>(method.Invoke(null, null));
Assert.Contains(root.Subcommands, c => c.Name == "shared-schema");
var secured = root.Subcommands.Single(c => c.Name == "secured-write");
Assert.Equal(new[] { "approve", "list", "reject", "submit" }, secured.Subcommands.Select(c => c.Name).Order());
}
}
@@ -0,0 +1,244 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class SchemaSecuredParityTests
{
private static RootCommand BuildRoot()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(SharedSchemaCommands.Build(url, format, user, password));
root.Add(SecuredWriteCommands.Build(url, format, user, password));
return root;
}
private static async Task<(int Exit, JsonElement Envelope, string Headers)> InvokeHttp(
string[] args, int status = 200, string response = "{}", string format = "json")
{
var root = BuildRoot();
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var parse = root.Parse([.. args, "--url", $"http://127.0.0.1:{port}", "--username", "operator", "--password", "test", "--format", format]);
Assert.Empty(parse.Errors);
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync(deadline.Token);
await using var stream = client.GetStream();
var headerBytes = new List<byte>();
var one = new byte[1];
while (true)
{
await stream.ReadExactlyAsync(one, deadline.Token);
headerBytes.Add(one[0]);
if (headerBytes.Count >= 4 && headerBytes.TakeLast(4).SequenceEqual(new byte[] { 13, 10, 13, 10 })) break;
}
var headers = Encoding.ASCII.GetString(headerBytes.ToArray());
var length = int.Parse(headers.Split("\r\n").Single(h => h.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)).Split(':')[1].Trim());
var request = new byte[length];
await stream.ReadExactlyAsync(request, deadline.Token);
var bytes = Encoding.UTF8.GetBytes(response);
await stream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n"), deadline.Token);
await stream.WriteAsync(bytes, deadline.Token);
using var document = JsonDocument.Parse(request);
return (document.RootElement.Clone(), headers);
});
var exit = await parse.InvokeAsync().WaitAsync(deadline.Token);
var captured = await server;
Assert.StartsWith("POST /management HTTP/1.1", captured.headers);
Assert.Contains("Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("operator:test")), captured.headers);
return (exit, captured.Item1, captured.headers);
}
[Theory]
[InlineData("list", "ListSharedSchemas")]
[InlineData("get", "GetSharedSchema")]
[InlineData("delete", "DeleteSharedSchema")]
public async Task SchemaReadDelete_UsesRegisteredContract(string verb, string command)
{
var args = verb == "list" ? new[] { "shared-schema", verb } : new[] { "shared-schema", verb, "--id", "42" };
var result = await InvokeHttp(args);
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
if (verb != "list") Assert.Equal(42, result.Envelope.GetProperty("payload").GetProperty("sharedSchemaId").GetInt32());
}
[Theory]
[InlineData("create", "CreateSharedSchema", true)]
[InlineData("update", "UpdateSharedSchema", true)]
[InlineData("update", "UpdateSharedSchema", false)]
public async Task SchemaWrite_ReadsJsonFileAndPreservesContract(string verb, string command, bool includeScope)
{
var path = Path.GetTempFileName();
const string schema = "{\"type\":\"object\",\"properties\":{\"é\":{\"$ref\":\"lib:Address\"}}}";
await File.WriteAllTextAsync(path, schema);
try
{
string[] args = ["shared-schema", verb, "--name", "Order", "--schema-file", path];
if (includeScope) args = [.. args, "--scope", "ERP"];
if (verb == "update") args = [.. args, "--id", "42"];
var result = await InvokeHttp(args);
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("Order", payload.GetProperty("name").GetString());
Assert.Equal(includeScope ? "ERP" : null, payload.GetProperty("scope").GetString());
Assert.Equal(schema, payload.GetProperty("schemaJson").GetString());
if (verb == "update") Assert.Equal(42, payload.GetProperty("sharedSchemaId").GetInt32());
}
finally { File.Delete(path); }
}
[Fact]
public async Task Submit_UsesValueJsonAndServerOwnedIdentity()
{
var result = await InvokeHttp(["secured-write", "submit", "--site", "SITE1", "--connection", "mx", "--tag-path", "Tank.Level", "--value-json", "12.5", "--value-type", "Double", "--comment", "batch start"]);
Assert.Equal(0, result.Exit);
Assert.Equal("SubmitSecuredWrite", result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("SITE1", payload.GetProperty("siteId").GetString());
Assert.Equal("mx", payload.GetProperty("connectionName").GetString());
Assert.Equal("Tank.Level", payload.GetProperty("tagPath").GetString());
Assert.Equal("12.5", payload.GetProperty("valueJson").GetString());
Assert.Equal("Double", payload.GetProperty("valueType").GetString());
Assert.Equal("batch start", payload.GetProperty("comment").GetString());
Assert.False(payload.TryGetProperty("operatorUser", out _));
}
[Theory]
[InlineData("approve", "ApproveSecuredWrite")]
[InlineData("reject", "RejectSecuredWrite")]
public async Task Decision_Preserves64BitIdentityAndComment(string verb, string command)
{
var result = await InvokeHttp(["secured-write", verb, "--id", "5000000000", "--comment", "reviewed"],
response: JsonSerializer.Serialize(new { id = 5_000_000_000L, status = verb == "approve" ? "Executed" : "Rejected" }));
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal(5_000_000_000L, payload.GetProperty("id").GetInt64());
Assert.Equal("reviewed", payload.GetProperty("comment").GetString());
Assert.False(payload.TryGetProperty("verifierUser", out _));
}
[Theory]
[InlineData("submit", 403, "Forbidden", 2)]
[InlineData("approve", 403, "Forbidden", 2)]
[InlineData("reject", 400, "MANAGEMENT_ERROR", 1)]
public async Task ServerDenials_AreReturnedWithoutLocalApproval(string verb, int status, string code, int expected)
{
string[] args = verb == "submit"
? ["secured-write", verb, "--site", "S", "--connection", "mx", "--tag-path", "tag", "--value-json", "true", "--value-type", "Boolean"]
: ["secured-write", verb, "--id", "123"];
var result = await InvokeHttp(args, status, JsonSerializer.Serialize(new { error = "Server rejected this decision", code }));
Assert.Equal(expected, result.Exit);
}
[Fact]
public async Task SecuredList_PreservesFiltersAndPagination()
{
var result = await InvokeHttp(["secured-write", "list", "--status", "Pending", "--site", "S1", "--skip", "7", "--take", "25"]);
Assert.Equal(0, result.Exit);
Assert.Equal("ListSecuredWrites", result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("Pending", payload.GetProperty("status").GetString());
Assert.Equal("S1", payload.GetProperty("siteId").GetString());
Assert.Equal(7, payload.GetProperty("skip").GetInt32());
Assert.Equal(25, payload.GetProperty("take").GetInt32());
}
[Theory]
[InlineData(403, 2)]
[InlineData(404, 1)]
public async Task SchemaServerErrors_PreserveExitContract(int status, int expected)
{
var result = await InvokeHttp(["shared-schema", "get", "--id", "42"], status,
"{\"error\":\"Server denied the schema request\",\"code\":\"REQUEST_FAILED\"}");
Assert.Equal(expected, result.Exit);
}
[Fact]
public async Task MalformedSubmitValue_ReturnsJsonErrorBeforeHttp()
{
var original = Console.Error;
using var error = new StringWriter();
Console.SetError(error);
try
{
var exit = await BuildRoot().Parse(["secured-write", "submit", "--site", "S", "--connection", "mx", "--tag-path", "tag", "--value-json", "{broken", "--value-type", "Double"]).InvokeAsync();
Assert.Equal(1, exit);
Assert.Contains("INVALID_JSON", error.ToString());
}
finally { Console.SetError(original); }
}
[Theory]
[InlineData("Executed", 0, "json")]
[InlineData("Failed", 1, "json")]
[InlineData("Executed", 0, "table")]
[InlineData("Failed", 1, "table")]
public async Task Approve_UsesServerExecutionOutcomeForExitCode(string status, int expected, string format)
{
var original = Console.Out;
using var output = new StringWriter();
Console.SetOut(output);
try
{
var result = await InvokeHttp(["secured-write", "approve", "--id", "123"], 200,
JsonSerializer.Serialize(new { id = 123L, status, executionError = status == "Failed" ? "device disconnected" : null }), format);
Assert.Equal(expected, result.Exit);
Assert.Contains(status, output.ToString());
if (status == "Failed") Assert.Contains("device disconnected", output.ToString());
}
finally { Console.SetOut(original); }
}
[Theory]
[InlineData("{}")]
[InlineData("{\"status\":\"Approved\"}")]
[InlineData("{\"status\":42}")]
[InlineData("not JSON")]
[InlineData("null")]
public async Task Approve_InvalidServerOutcomeIsAnError(string response)
{
var result = await InvokeHttp(["secured-write", "approve", "--id", "123"], response: response);
Assert.Equal(1, result.Exit);
}
[Theory]
[InlineData("shared-schema", "update")]
[InlineData("secured-write", "submit")]
[InlineData("secured-write", "approve")]
public void RequiredArguments_AreRejectedByParser(string group, string verb)
=> Assert.NotEmpty(BuildRoot().Parse([group, verb]).Errors);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SchemaFileFailures_ReturnErrorBeforeHttp(bool malformed)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json");
if (malformed) await File.WriteAllTextAsync(path, "{broken");
var original = Console.Error;
using var error = new StringWriter();
Console.SetError(error);
try
{
var exit = await BuildRoot().Parse(["shared-schema", "create", "--name", "bad", "--schema-file", path]).InvokeAsync();
Assert.Equal(1, exit);
Assert.Contains(malformed ? "INVALID_JSON" : "FILE_ERROR", error.ToString());
}
finally { Console.SetError(original); File.Delete(path); }
}
}
@@ -355,6 +355,7 @@ public class AuditDrilldownDrawerTests : BunitContext
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
clipboardCall.SetVoidResult();
// Build an event with a {headers, body} RequestSummary so the cURL
// builder has material to fold in.
@@ -301,6 +301,7 @@ public class AuditEventDetailTests : BunitContext
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
clipboardCall.SetVoidResult();
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
@@ -1,3 +1,4 @@
using Microsoft.JSInterop;
using System.Security.Claims;
using ZB.MOM.WW.ScadaBridge.Security;
using Bunit;
@@ -20,6 +21,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary>
public class DataConnectionsPageTests : BunitContext
{
[Fact]
public void StorageSaveFixture_CompletesInvocationImmediately()
{
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
}
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
// M9-T25: the design page now injects IConnectionHealthQueryService to render
// per-connection live health badges. A substitute defaulting to an empty map
@@ -51,7 +59,7 @@ public class DataConnectionsPageTests : BunitContext
new Dictionary<int, ConnectionHealth>()));
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
}
private void AddTestAuth()
@@ -61,7 +61,7 @@ public class JsInteropLoggingTests : BunitContext
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSException("body lock failed"));
// Focus and any other interop is harmless here — allow it loosely.
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
@@ -84,7 +84,7 @@ public class JsInteropLoggingTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSDisconnectedException("circuit gone"));
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
@@ -53,7 +53,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
{
// Checkbox mode pushes the tri-state via JS interop after every render;
// stub it so bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
}
return Render<TreeView<TestNode>>(parameters =>
@@ -37,7 +37,7 @@ public class TreeViewStorageResilienceTests : BunitContext
// A garbage payload that is not valid JSON for a List<string>.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{not json at all]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
// Deserialize call. Post-fix: the corrupt payload is ignored.
@@ -53,7 +53,7 @@ public class TreeViewStorageResilienceTests : BunitContext
// Valid JSON, but not a List<string> — an object, not an array.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{\"unexpected\": true}");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = BuildTree();
@@ -44,7 +44,7 @@ public class TemplatesPageTests : BunitContext
// against sessionStorage (`templates-tree` key). bUnit requires explicit
// stubs for all JS interop calls, otherwise rendering throws.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
}
private void AddTestAuth(params string[] roles)
@@ -87,7 +87,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
SeedEmptyRepos();
}
@@ -1,3 +1,4 @@
using Microsoft.JSInterop;
using System.Security.Claims;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -34,6 +35,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary>
public class TopologyPageTests : BunitContext
{
[Fact]
public void StorageSaveFixture_CompletesInvocationImmediately()
{
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
}
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
@@ -97,7 +105,7 @@ public class TopologyPageTests : BunitContext
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
}
private void AddTestAuth()
@@ -38,7 +38,7 @@ public class TreeViewMultiSelectTests : BunitContext
{
// Indeterminate is set via JS interop after each render. We stub it so
// bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
return Render<TreeView<TestNode>>(parameters =>
{
@@ -348,7 +348,7 @@ public class TreeViewTests : BunitContext
public void SessionStorage_Set_ExpandWritesToStorage()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView(storageKey: "test-tree");
@@ -366,7 +366,7 @@ public class TreeViewTests : BunitContext
public void SessionStorage_RestoresExpandedOnMount()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView(storageKey: "test-tree");
@@ -381,7 +381,7 @@ public class TreeViewTests : BunitContext
{
// Storage returns empty array — meaning user explicitly collapsed everything
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView(
storageKey: "test-tree",
@@ -15,7 +15,9 @@ public class ManagementCommandRegistryTests
typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract);
&& !t.IsAbstract
&& t != typeof(ReadTagValuesCommand)
&& t != typeof(ResolveRolesCommand));
[Fact]
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
@@ -2,23 +2,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Verifies that <see cref="ReadTagValuesCommand"/> is discovered by
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
/// boundary as a known command (resolvable by wire name and round-trippable
/// through <c>GetCommandName</c> / <c>Resolve</c>). Mirrors
/// <see cref="BrowseCommandsRegistryTests"/>.
/// </summary>
public class ReadTagValuesCommandRegistryTests
{
[Fact]
public void Registry_discovers_ReadTagValuesCommand()
[Theory]
[InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")]
[InlineData(typeof(ResolveRolesCommand), "ResolveRoles")]
public void SiteOnlyAndRetiredCommands_AreNotHttpCommands(Type type, string wireName)
{
// GetCommandName throws ArgumentException for any type the registry
// does not contain, so a successful call here is proof of discovery.
var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand));
Assert.Equal("ReadTagValues", name);
Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name));
Assert.Null(ManagementCommandRegistry.Resolve(wireName));
Assert.Throws<ArgumentException>(() => ManagementCommandRegistry.GetCommandName(type));
}
}
@@ -6,8 +6,8 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the
/// registry's default case, which throws <see cref="NotSupportedException"/>
/// ("Unknown command type"). This test proves that signature never occurs for any
/// registered command (bar the documented exclusion list), so a newly-added command
/// HTTP-registered command, so a newly-added command
/// that someone forgot to wire up fails CI here rather than at runtime.
/// </summary>
/// <remarks>
@@ -27,7 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
/// we never execute the handler for real) inside an envelope carrying ALL roles, so
/// the authorization gate always passes. The actor runs on a service provider whose
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so
/// scoped <see cref="IServiceProvider.GetService"/> passes through configuration transactions
/// and throws a sentinel exception for handler dependencies, so
/// any handler that touches the provider faults with the sentinel (a pass — the arm
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in
@@ -37,36 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// </remarks>
public class DispatchCoverageTests : TestKit, IDisposable
{
/// <summary>
/// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
/// carry a stable wire name across the management boundary) but are intentionally
/// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
/// different actor path. Each entry is a deliberate design decision, not a missing arm.
/// </summary>
private static readonly HashSet<Type> Excluded = new()
{
// The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
// resolves roles itself and sends a single envelope. Leaving a handler would
// expose role-mapping data to a raw in-cluster sender with no role gate.
// See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
typeof(ResolveRolesCommand),
// Site-routed, not a central ManagementActor command. Backs the CentralUI
// "Test Bindings" dialog, which sends it straight through
// CommunicationService.ReadTagValuesAsync (in-process) to the site's
// DataConnectionManagerActor / DataConnectionActor — never through the
// ManagementActor dispatch switch. It lives in the Messages.Management
// namespace (hence auto-registered) only to earn a stable wire name for the
// cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
// ManagementActor arm would be the wrong transport for this command.
typeof(ReadTagValuesCommand),
};
private static IEnumerable<Type> RegisteredCommandTypes() =>
typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract);
&& !t.IsAbstract
&& ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t);
void IDisposable.Dispose() => Shutdown();
@@ -74,19 +51,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
public void EveryRegisteredCommand_ReachesADispatchArm()
{
var logger = new RecordingLogger<ManagementActor>();
var actor = CreateSentinelActor(logger);
var actor = CreateSentinelActor(logger, out var transaction);
var commandTypes = RegisteredCommandTypes()
.Where(t => !Excluded.Contains(t))
.OrderBy(t => t.Name, StringComparer.Ordinal)
.ToList();
// Sanity: the exclusion list must reference real, registered command types.
foreach (var excluded in Excluded)
{
Assert.Contains(excluded, RegisteredCommandTypes());
}
var noDispatchArm = new List<string>();
foreach (var type in commandTypes)
@@ -110,11 +80,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
}
}
Assert.True(transaction.DispatchCount > 0, "Configuration commands must execute beyond the transaction wrapper.");
Assert.True(
noDispatchArm.Count == 0,
$"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
$"(fell through to the NotSupportedException default). Add the missing arm, or — with " +
$"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}");
$"(fell through to the NotSupportedException default). Add the missing arm or correct HTTP eligibility. " +
$"Commands: {string.Join(", ", noDispatchArm)}");
}
/// <summary>
@@ -124,10 +95,15 @@ public class DispatchCoverageTests : TestKit, IDisposable
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
/// actually resolves a service.
/// </summary>
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger, out PassthroughTransaction transaction)
{
var scopedProvider = Substitute.For<IServiceProvider>();
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());
var passthrough = new PassthroughTransaction(scopedProvider);
transaction = passthrough;
scopedProvider.GetService(Arg.Any<Type>()).Returns(call =>
call.Arg<Type>() == typeof(IConfigurationCommandTransaction)
? passthrough
: throw new SentinelException());
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(scopedProvider);
@@ -141,6 +117,32 @@ public class DispatchCoverageTests : TestKit, IDisposable
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
}
[Fact]
public void Probe_DetectsACommandWithoutADispatchArm()
{
var logger = new RecordingLogger<ManagementActor>();
var actor = CreateSentinelActor(logger, out _);
var correlationId = Guid.NewGuid().ToString("N");
actor.Tell(new ManagementEnvelope(
new AuthenticatedUser("probe", "Probe", Roles.All, []), new UnsupportedCommand(), correlationId));
ExpectMsg<object>(TimeSpan.FromSeconds(10));
Assert.IsType<NotSupportedException>(logger.Captured[correlationId]);
}
private sealed record UnsupportedCommand;
// Do not throw while resolving the transaction: that would pass configuration
// commands before DispatchCommand had a chance to select (or miss) their arm.
private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction
{
public int DispatchCount { get; private set; }
public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
DispatchCount++;
return operation(provider);
}
}
/// <summary>Marker fault thrown by the scoped provider — distinct from the
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
private sealed class SentinelException : Exception
@@ -0,0 +1,17 @@
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class HttpCommandEligibilityTests
{
[Theory]
[InlineData("ReadTagValues")]
[InlineData("ResolveRoles")]
public void ParseCommand_RejectsInternalContracts(string command)
{
var result = ManagementEndpoints.ParseCommand($"{{\"command\":\"{command}\",\"payload\":{{}}}}");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
Assert.Contains("Unknown command", result.ErrorMessage);
}
}
@@ -0,0 +1,89 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class NotificationRecipientPreservationTests
{
[Theory]
[InlineData(NotificationType.Email)]
[InlineData(NotificationType.Sms)]
public async Task DedicatedEdits_PreserveUnrelatedRecipientIdentityAndName(NotificationType channel)
{
var list = new NotificationList("Original") { Id = 7, Type = channel };
var original = channel == NotificationType.Email
? NotificationRecipient.ForEmail("Existing Operator", "original@example.com")
: NotificationRecipient.ForSms("Existing Operator", "+15551234567");
original.Id = 41;
original.NotificationListId = list.Id;
list.Recipients.Add(original);
var originalEmail = original.EmailAddress;
var originalPhone = original.PhoneNumber;
var repo = Substitute.For<INotificationRepository>();
repo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(list);
repo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
.Returns(_ => list.Recipients.ToList());
repo.GetRecipientByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call => list.Recipients.SingleOrDefault(r => r.Id == call.Arg<int>()));
// Model repository inserts/deletes in one persisted collection. Any accidental
// whole-list replacement would remove or renumber the original row and fail below.
repo.When(r => r.AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>()))
.Do(call =>
{
var row = call.Arg<NotificationRecipient>();
row.Id = 42;
list.Recipients.Add(row);
});
repo.When(r => r.DeleteRecipientAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()))
.Do(call => list.Recipients.Remove(list.Recipients.Single(r => r.Id == call.Arg<int>())));
using var services = new ServiceCollection().AddTestConfigurationTransaction()
.AddSingleton(repo).AddSingleton(Substitute.For<IAuditService>()).BuildServiceProvider();
var user = new AuthenticatedUser("designer", "designer", new[] { Roles.Designer }, Array.Empty<string>());
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new RenameNotificationListCommand(7, "Renamed"), user);
Assert.Equal("Renamed", list.Name);
AssertOriginalUnchanged();
Assert.Single(list.Recipients);
await repo.DidNotReceiveWithAnyArgs().AddRecipientAsync(default!, default);
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new AddNotificationRecipientCommand(7, "New Operator",
channel == NotificationType.Email ? "new@example.com" : null,
channel == NotificationType.Sms ? "+15557654321" : null), user);
AssertOriginalUnchanged();
Assert.Equal(2, list.Recipients.Count);
var added = Assert.Single(list.Recipients, r => r.Id != original.Id);
Assert.Equal(42, added.Id);
Assert.Equal("New Operator", added.Name);
Assert.Equal(7, added.NotificationListId);
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new DeleteNotificationRecipientCommand(42), user);
AssertOriginalUnchanged();
Assert.Single(list.Recipients);
await repo.Received(1).AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>());
await repo.Received(1).DeleteRecipientAsync(42, Arg.Any<CancellationToken>());
await repo.DidNotReceive().DeleteRecipientAsync(41, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().UpdateRecipientAsync(default!, default);
await repo.Received(3).SaveChangesAsync(Arg.Any<CancellationToken>());
void AssertOriginalUnchanged()
{
var retained = Assert.Single(list.Recipients, r => r.Id == 41);
Assert.Same(original, retained);
Assert.Equal("Existing Operator", retained.Name);
Assert.Equal(7, retained.NotificationListId);
Assert.Equal(originalEmail, retained.EmailAddress);
Assert.Equal(originalPhone, retained.PhoneNumber);
}
}
}