245 lines
12 KiB
C#
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); }
|
|
}
|
|
}
|