Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs
T
2026-09-10 00:51:45 -04:00

245 lines
12 KiB
C#

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