Complete management API and CLI command parity
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user