Complete management API and CLI command parity
This commit is contained in:
@@ -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