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("--url") { Recursive = true }; var format = new Option("--format") { Recursive = true }; var user = new Option("--username") { Recursive = true }; var password = new Option("--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; } /// Verifies that each command sends the exact management envelope with the expected command name and payload. /// The command-line arguments to run. /// The expected management command name. /// The expected JSON payload. /// A task that represents the asynchronous operation. [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("template alarm add --template-id 12 --name HighTemp --trigger-type HiLo --priority 500 --mes-reportable", "AddTemplateAlarm", "{\"templateId\":12,\"name\":\"HighTemp\",\"triggerType\":\"HiLo\",\"priorityLevel\":500,\"description\":null,\"triggerConfiguration\":null,\"isLocked\":false,\"isMesReportable\":true}")] [InlineData("template alarm update --id 9 --name HighTemp --trigger-type HiLo --priority 500", "UpdateTemplateAlarm", "{\"alarmId\":9,\"name\":\"HighTemp\",\"triggerType\":\"HiLo\",\"priorityLevel\":500,\"description\":null,\"triggerConfiguration\":null,\"isLocked\":false,\"isMesReportable\":false}")] [InlineData("instance alarm-override set --instance-id 6 --alarm HighTemp --mes-reportable false", "SetInstanceAlarmOverride", "{\"instanceId\":6,\"alarmCanonicalName\":\"HighTemp\",\"triggerConfigurationOverride\":null,\"priorityLevelOverride\":null,\"isMesReportableOverride\":false}")] [InlineData("instance alarm-override set --instance-id 6 --alarm HighTemp --priority 7", "SetInstanceAlarmOverride", "{\"instanceId\":6,\"alarmCanonicalName\":\"HighTemp\",\"triggerConfigurationOverride\":null,\"priorityLevelOverride\":7,\"isMesReportableOverride\":null}")] [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(); } } /// Verifies that missing or malformed arguments are rejected. /// The invalid command-line arguments to run. [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); }