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); }
|
||||
}
|
||||
}
|
||||
+1
@@ -355,6 +355,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
invocation => invocation.Arguments.Count == 1
|
||||
&& invocation.Arguments[0] is string s
|
||||
&& s.StartsWith("curl ", StringComparison.Ordinal));
|
||||
clipboardCall.SetVoidResult();
|
||||
|
||||
// Build an event with a {headers, body} RequestSummary so the cURL
|
||||
// builder has material to fold in.
|
||||
|
||||
@@ -301,6 +301,7 @@ public class AuditEventDetailTests : BunitContext
|
||||
invocation => invocation.Arguments.Count == 1
|
||||
&& invocation.Arguments[0] is string s
|
||||
&& s.StartsWith("curl ", StringComparison.Ordinal));
|
||||
clipboardCall.SetVoidResult();
|
||||
|
||||
var ev = MakeEvent(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.JSInterop;
|
||||
using System.Security.Claims;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using Bunit;
|
||||
@@ -20,6 +21,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||
/// </summary>
|
||||
public class DataConnectionsPageTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void StorageSaveFixture_CompletesInvocationImmediately()
|
||||
{
|
||||
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
|
||||
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
|
||||
}
|
||||
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
// M9-T25: the design page now injects IConnectionHealthQueryService to render
|
||||
// per-connection live health badges. A substitute defaulting to an empty map
|
||||
@@ -51,7 +59,7 @@ public class DataConnectionsPageTests : BunitContext
|
||||
new Dictionary<int, ConnectionHealth>()));
|
||||
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
}
|
||||
|
||||
private void AddTestAuth()
|
||||
|
||||
@@ -61,7 +61,7 @@ public class JsInteropLoggingTests : BunitContext
|
||||
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
|
||||
.SetException(new JSException("body lock failed"));
|
||||
// Focus and any other interop is harmless here — allow it loosely.
|
||||
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
|
||||
JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
|
||||
|
||||
var cut = Render<DiffDialog>();
|
||||
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
|
||||
@@ -84,7 +84,7 @@ public class JsInteropLoggingTests : BunitContext
|
||||
JSInterop.Mode = JSRuntimeMode.Strict;
|
||||
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
|
||||
.SetException(new JSDisconnectedException("circuit gone"));
|
||||
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
|
||||
JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
|
||||
|
||||
var cut = Render<DiffDialog>();
|
||||
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
|
||||
{
|
||||
// Checkbox mode pushes the tri-state via JS interop after every render;
|
||||
// stub it so bUnit's strict mode doesn't blow up on the unmocked call.
|
||||
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
|
||||
}
|
||||
|
||||
return Render<TreeView<TestNode>>(parameters =>
|
||||
|
||||
+2
-2
@@ -37,7 +37,7 @@ public class TreeViewStorageResilienceTests : BunitContext
|
||||
// A garbage payload that is not valid JSON for a List<string>.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
|
||||
.SetResult("{not json at all]");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
|
||||
// Deserialize call. Post-fix: the corrupt payload is ignored.
|
||||
@@ -53,7 +53,7 @@ public class TreeViewStorageResilienceTests : BunitContext
|
||||
// Valid JSON, but not a List<string> — an object, not an array.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
|
||||
.SetResult("{\"unexpected\": true}");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
var cut = BuildTree();
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class TemplatesPageTests : BunitContext
|
||||
// against sessionStorage (`templates-tree` key). bUnit requires explicit
|
||||
// stubs for all JS interop calls, otherwise rendering throws.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
}
|
||||
|
||||
private void AddTestAuth(params string[] roles)
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
SeedEmptyRepos();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.JSInterop;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -34,6 +35,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||
/// </summary>
|
||||
public class TopologyPageTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void StorageSaveFixture_CompletesInvocationImmediately()
|
||||
{
|
||||
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
|
||||
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
|
||||
}
|
||||
|
||||
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
@@ -97,7 +105,7 @@ public class TopologyPageTests : BunitContext
|
||||
|
||||
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
}
|
||||
|
||||
private void AddTestAuth()
|
||||
|
||||
@@ -38,7 +38,7 @@ public class TreeViewMultiSelectTests : BunitContext
|
||||
{
|
||||
// Indeterminate is set via JS interop after each render. We stub it so
|
||||
// bUnit's strict mode doesn't blow up on the unmocked call.
|
||||
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
|
||||
|
||||
return Render<TreeView<TestNode>>(parameters =>
|
||||
{
|
||||
|
||||
@@ -348,7 +348,7 @@ public class TreeViewTests : BunitContext
|
||||
public void SessionStorage_Set_ExpandWritesToStorage()
|
||||
{
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
var cut = RenderTreeView(storageKey: "test-tree");
|
||||
|
||||
@@ -366,7 +366,7 @@ public class TreeViewTests : BunitContext
|
||||
public void SessionStorage_RestoresExpandedOnMount()
|
||||
{
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
var cut = RenderTreeView(storageKey: "test-tree");
|
||||
|
||||
@@ -381,7 +381,7 @@ public class TreeViewTests : BunitContext
|
||||
{
|
||||
// Storage returns empty array — meaning user explicitly collapsed everything
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
|
||||
|
||||
var cut = RenderTreeView(
|
||||
storageKey: "test-tree",
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ public class ManagementCommandRegistryTests
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
&& !t.IsAbstract
|
||||
&& t != typeof(ReadTagValuesCommand)
|
||||
&& t != typeof(ResolveRolesCommand));
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
|
||||
|
||||
+6
-15
@@ -2,23 +2,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="ReadTagValuesCommand"/> is discovered by
|
||||
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
|
||||
/// boundary as a known command (resolvable by wire name and round-trippable
|
||||
/// through <c>GetCommandName</c> / <c>Resolve</c>). Mirrors
|
||||
/// <see cref="BrowseCommandsRegistryTests"/>.
|
||||
/// </summary>
|
||||
public class ReadTagValuesCommandRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_discovers_ReadTagValuesCommand()
|
||||
[Theory]
|
||||
[InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")]
|
||||
[InlineData(typeof(ResolveRolesCommand), "ResolveRoles")]
|
||||
public void SiteOnlyAndRetiredCommands_AreNotHttpCommands(Type type, string wireName)
|
||||
{
|
||||
// GetCommandName throws ArgumentException for any type the registry
|
||||
// does not contain, so a successful call here is proof of discovery.
|
||||
var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand));
|
||||
|
||||
Assert.Equal("ReadTagValues", name);
|
||||
Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name));
|
||||
Assert.Null(ManagementCommandRegistry.Resolve(wireName));
|
||||
Assert.Throws<ArgumentException>(() => ManagementCommandRegistry.GetCommandName(type));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the
|
||||
/// registry's default case, which throws <see cref="NotSupportedException"/>
|
||||
/// ("Unknown command type"). This test proves that signature never occurs for any
|
||||
/// registered command (bar the documented exclusion list), so a newly-added command
|
||||
/// HTTP-registered command, so a newly-added command
|
||||
/// that someone forgot to wire up fails CI here rather than at runtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -27,7 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
|
||||
/// we never execute the handler for real) inside an envelope carrying ALL roles, so
|
||||
/// the authorization gate always passes. The actor runs on a service provider whose
|
||||
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so
|
||||
/// scoped <see cref="IServiceProvider.GetService"/> passes through configuration transactions
|
||||
/// and throws a sentinel exception for handler dependencies, so
|
||||
/// any handler that touches the provider faults with the sentinel (a pass — the arm
|
||||
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
|
||||
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in
|
||||
@@ -37,36 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// </remarks>
|
||||
public class DispatchCoverageTests : TestKit, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
|
||||
/// carry a stable wire name across the management boundary) but are intentionally
|
||||
/// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
|
||||
/// different actor path. Each entry is a deliberate design decision, not a missing arm.
|
||||
/// </summary>
|
||||
private static readonly HashSet<Type> Excluded = new()
|
||||
{
|
||||
// The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
|
||||
// resolves roles itself and sends a single envelope. Leaving a handler would
|
||||
// expose role-mapping data to a raw in-cluster sender with no role gate.
|
||||
// See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
|
||||
typeof(ResolveRolesCommand),
|
||||
|
||||
// Site-routed, not a central ManagementActor command. Backs the CentralUI
|
||||
// "Test Bindings" dialog, which sends it straight through
|
||||
// CommunicationService.ReadTagValuesAsync (in-process) to the site's
|
||||
// DataConnectionManagerActor / DataConnectionActor — never through the
|
||||
// ManagementActor dispatch switch. It lives in the Messages.Management
|
||||
// namespace (hence auto-registered) only to earn a stable wire name for the
|
||||
// cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
|
||||
// ManagementActor arm would be the wrong transport for this command.
|
||||
typeof(ReadTagValuesCommand),
|
||||
};
|
||||
|
||||
private static IEnumerable<Type> RegisteredCommandTypes() =>
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
&& !t.IsAbstract
|
||||
&& ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t);
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
@@ -74,19 +51,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
public void EveryRegisteredCommand_ReachesADispatchArm()
|
||||
{
|
||||
var logger = new RecordingLogger<ManagementActor>();
|
||||
var actor = CreateSentinelActor(logger);
|
||||
var actor = CreateSentinelActor(logger, out var transaction);
|
||||
|
||||
var commandTypes = RegisteredCommandTypes()
|
||||
.Where(t => !Excluded.Contains(t))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Sanity: the exclusion list must reference real, registered command types.
|
||||
foreach (var excluded in Excluded)
|
||||
{
|
||||
Assert.Contains(excluded, RegisteredCommandTypes());
|
||||
}
|
||||
|
||||
var noDispatchArm = new List<string>();
|
||||
|
||||
foreach (var type in commandTypes)
|
||||
@@ -110,11 +80,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(transaction.DispatchCount > 0, "Configuration commands must execute beyond the transaction wrapper.");
|
||||
Assert.True(
|
||||
noDispatchArm.Count == 0,
|
||||
$"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
|
||||
$"(fell through to the NotSupportedException default). Add the missing arm, or — with " +
|
||||
$"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}");
|
||||
$"(fell through to the NotSupportedException default). Add the missing arm or correct HTTP eligibility. " +
|
||||
$"Commands: {string.Join(", ", noDispatchArm)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -124,10 +95,15 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
|
||||
/// actually resolves a service.
|
||||
/// </summary>
|
||||
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
|
||||
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger, out PassthroughTransaction transaction)
|
||||
{
|
||||
var scopedProvider = Substitute.For<IServiceProvider>();
|
||||
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());
|
||||
var passthrough = new PassthroughTransaction(scopedProvider);
|
||||
transaction = passthrough;
|
||||
scopedProvider.GetService(Arg.Any<Type>()).Returns(call =>
|
||||
call.Arg<Type>() == typeof(IConfigurationCommandTransaction)
|
||||
? passthrough
|
||||
: throw new SentinelException());
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(scopedProvider);
|
||||
@@ -141,6 +117,32 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_DetectsACommandWithoutADispatchArm()
|
||||
{
|
||||
var logger = new RecordingLogger<ManagementActor>();
|
||||
var actor = CreateSentinelActor(logger, out _);
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
actor.Tell(new ManagementEnvelope(
|
||||
new AuthenticatedUser("probe", "Probe", Roles.All, []), new UnsupportedCommand(), correlationId));
|
||||
ExpectMsg<object>(TimeSpan.FromSeconds(10));
|
||||
Assert.IsType<NotSupportedException>(logger.Captured[correlationId]);
|
||||
}
|
||||
|
||||
private sealed record UnsupportedCommand;
|
||||
|
||||
// Do not throw while resolving the transaction: that would pass configuration
|
||||
// commands before DispatchCommand had a chance to select (or miss) their arm.
|
||||
private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction
|
||||
{
|
||||
public int DispatchCount { get; private set; }
|
||||
public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
|
||||
{
|
||||
DispatchCount++;
|
||||
return operation(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marker fault thrown by the scoped provider — distinct from the
|
||||
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
|
||||
private sealed class SentinelException : Exception
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class HttpCommandEligibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ReadTagValues")]
|
||||
[InlineData("ResolveRoles")]
|
||||
public void ParseCommand_RejectsInternalContracts(string command)
|
||||
{
|
||||
var result = ManagementEndpoints.ParseCommand($"{{\"command\":\"{command}\",\"payload\":{{}}}}");
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||
Assert.Contains("Unknown command", result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class NotificationRecipientPreservationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(NotificationType.Email)]
|
||||
[InlineData(NotificationType.Sms)]
|
||||
public async Task DedicatedEdits_PreserveUnrelatedRecipientIdentityAndName(NotificationType channel)
|
||||
{
|
||||
var list = new NotificationList("Original") { Id = 7, Type = channel };
|
||||
var original = channel == NotificationType.Email
|
||||
? NotificationRecipient.ForEmail("Existing Operator", "original@example.com")
|
||||
: NotificationRecipient.ForSms("Existing Operator", "+15551234567");
|
||||
original.Id = 41;
|
||||
original.NotificationListId = list.Id;
|
||||
list.Recipients.Add(original);
|
||||
var originalEmail = original.EmailAddress;
|
||||
var originalPhone = original.PhoneNumber;
|
||||
var repo = Substitute.For<INotificationRepository>();
|
||||
repo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(list);
|
||||
repo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => list.Recipients.ToList());
|
||||
repo.GetRecipientByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call => list.Recipients.SingleOrDefault(r => r.Id == call.Arg<int>()));
|
||||
// Model repository inserts/deletes in one persisted collection. Any accidental
|
||||
// whole-list replacement would remove or renumber the original row and fail below.
|
||||
repo.When(r => r.AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>()))
|
||||
.Do(call =>
|
||||
{
|
||||
var row = call.Arg<NotificationRecipient>();
|
||||
row.Id = 42;
|
||||
list.Recipients.Add(row);
|
||||
});
|
||||
repo.When(r => r.DeleteRecipientAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()))
|
||||
.Do(call => list.Recipients.Remove(list.Recipients.Single(r => r.Id == call.Arg<int>())));
|
||||
using var services = new ServiceCollection().AddTestConfigurationTransaction()
|
||||
.AddSingleton(repo).AddSingleton(Substitute.For<IAuditService>()).BuildServiceProvider();
|
||||
var user = new AuthenticatedUser("designer", "designer", new[] { Roles.Designer }, Array.Empty<string>());
|
||||
|
||||
await ManagementActor.ExecuteConfigurationCommandAsync(services,
|
||||
new RenameNotificationListCommand(7, "Renamed"), user);
|
||||
Assert.Equal("Renamed", list.Name);
|
||||
AssertOriginalUnchanged();
|
||||
Assert.Single(list.Recipients);
|
||||
await repo.DidNotReceiveWithAnyArgs().AddRecipientAsync(default!, default);
|
||||
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
|
||||
|
||||
await ManagementActor.ExecuteConfigurationCommandAsync(services,
|
||||
new AddNotificationRecipientCommand(7, "New Operator",
|
||||
channel == NotificationType.Email ? "new@example.com" : null,
|
||||
channel == NotificationType.Sms ? "+15557654321" : null), user);
|
||||
AssertOriginalUnchanged();
|
||||
Assert.Equal(2, list.Recipients.Count);
|
||||
var added = Assert.Single(list.Recipients, r => r.Id != original.Id);
|
||||
Assert.Equal(42, added.Id);
|
||||
Assert.Equal("New Operator", added.Name);
|
||||
Assert.Equal(7, added.NotificationListId);
|
||||
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
|
||||
|
||||
await ManagementActor.ExecuteConfigurationCommandAsync(services,
|
||||
new DeleteNotificationRecipientCommand(42), user);
|
||||
AssertOriginalUnchanged();
|
||||
Assert.Single(list.Recipients);
|
||||
await repo.Received(1).AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>());
|
||||
await repo.Received(1).DeleteRecipientAsync(42, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().DeleteRecipientAsync(41, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceiveWithAnyArgs().UpdateRecipientAsync(default!, default);
|
||||
await repo.Received(3).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
|
||||
void AssertOriginalUnchanged()
|
||||
{
|
||||
var retained = Assert.Single(list.Recipients, r => r.Id == 41);
|
||||
Assert.Same(original, retained);
|
||||
Assert.Equal("Existing Operator", retained.Name);
|
||||
Assert.Equal(7, retained.NotificationListId);
|
||||
Assert.Equal(originalEmail, retained.EmailAddress);
|
||||
Assert.Equal(originalPhone, retained.PhoneNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user