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("--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(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(); 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); } /// Verifies that schema read and delete commands use the registered contract. /// The verb. /// The command. /// A task that represents the asynchronous operation. [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()); } /// Verifies that schema write commands read the JSON file and preserve the contract. /// The verb. /// The command. /// The include scope. /// A task that represents the asynchronous operation. [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); } } /// Verifies that a secured-write submit sends the value as JSON and leaves identity to the server. /// A task that represents the asynchronous operation. [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 _)); } /// Verifies that approve and reject decisions preserve the 64-bit identity and the comment. /// The verb. /// The command. /// A task that represents the asynchronous operation. [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 _)); } /// Verifies that server denials are returned without any local approval being performed. /// The verb. /// The status. /// The code. /// The expected. /// A task that represents the asynchronous operation. [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); } /// Verifies that the secured-write list preserves its filters and pagination. /// A task that represents the asynchronous operation. [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()); } /// Verifies that schema server errors map to the documented exit codes. /// The status. /// The expected. /// A task that represents the asynchronous operation. [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); } /// Verifies that a malformed submit value returns a JSON error before any HTTP call is made. /// A task that represents the asynchronous operation. [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); } } /// Verifies that the approve exit code is derived from the server execution outcome. /// The status. /// The expected. /// The format. /// A task that represents the asynchronous operation. [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); } } /// Verifies that an invalid server outcome in an approve response is reported as an error. /// The response. /// A task that represents the asynchronous operation. [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); } /// Verifies that the parser rejects commands that omit required arguments. /// The group. /// The verb. [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); /// Verifies that failures reading the schema file return an error before any HTTP call is made. /// The malformed. /// A task that represents the asynchronous operation. [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); } } }