using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
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;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class ManagementActorTests : TestKit, IDisposable
{
private readonly ITemplateEngineRepository _templateRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
private ArtifactDeploymentService _artifactService = null!;
/// Initializes a new instance of the ManagementActorTests class.
public ManagementActorTests()
{
_templateRepo = Substitute.For();
_auditService = Substitute.For();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _templateRepo);
_services.AddScoped(_ => _auditService);
_services.AddScoped();
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty()),
command, Guid.NewGuid().ToString("N"));
private static ManagementEnvelope ScopedEnvelope(object command, string[] permittedSiteIds, params string[] roles) =>
new(new AuthenticatedUser("scopeduser", "Scoped User", roles, permittedSiteIds),
command, Guid.NewGuid().ToString("N"));
///
/// Registers a substitutable into the
/// harness DI so DeployArtifacts tests can verify which deploy path was taken
/// (single-site vs. fleet-wide) without exercising the real cluster comms.
/// The virtual DeployToSiteAsync/DeployToAllSitesAsync members are
/// intercepted; all other members keep their real (unused) bodies.
///
private void RegisterArtifactService()
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
_artifactService = Substitute.For(
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
comms,
Substitute.For(),
Options.Create(new DeploymentManagerOptions()),
NullLogger.Instance,
new ArtifactRevisionHashService(),
new SiteSecretsCollector(Substitute.For()));
_services.AddScoped(_ => _artifactService);
}
///
/// Registers a REAL over substituted repositories
/// (its methods are not virtual, so it cannot be substituted itself — the same
/// approach the CentralUI bUnit tests take). Area lookups go through the
/// fixture's shared .
///
private (IDeploymentManagerRepository DeployRepo, IFlatteningPipeline Pipeline, ISiteRepository SiteRepo) RegisterDeploymentService()
{
var deployRepo = Substitute.For();
var pipeline = Substitute.For();
var siteRepo = Substitute.For();
pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
var service = new DeploymentService(
deployRepo, siteRepo, _templateRepo, pipeline,
new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance),
new OperationLockManager(), _auditService,
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService(),
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
_services.AddScoped(_ => service);
return (deployRepo, pipeline, siteRepo);
}
private static Result DeploySummary() =>
Result.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty(), 1, 0));
///
void IDisposable.Dispose()
{
Shutdown();
}
// ========================================================================
// 1. Authorization test -- admin command with wrong role
// ========================================================================
/// Verifies that create site command, when with design role, returns unauthorized.
[Fact]
public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that create site command, when with no roles, returns unauthorized.
[Fact]
public void CreateSiteCommand_WithNoRoles_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", null));
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
// ========================================================================
// ListSecuredWrites role gate (arch-review UA1): read-only, but the
// history table exposes process-sensitive tag values, so it is restricted
// to the two-person Secured Write roles + Administrator (any-of).
// ========================================================================
/// Verifies that list secured writes, when viewer only, unauthorized.
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg(TimeSpan.FromSeconds(5));
}
/// Verifies that list secured writes, when secured write roles, allowed.
/// The role.
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
var securedWriteRepo = Substitute.For();
securedWriteRepo.QueryAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any?>(), Arg.Any())
.Returns(Task.FromResult>(
Array.Empty()));
_services.AddScoped(_ => securedWriteRepo);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
ExpectMsg(TimeSpan.FromSeconds(5));
}
/// Verifies that deployment command, when with design role, returns unauthorized.
[Fact]
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Deployer", response.Message);
}
/// Verifies that query audit log command, when with no roles, returns unauthorized.
[Fact]
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
{
// QueryAuditLogCommand used to fall through to the
// default "any authenticated user" case, allowing a Deployment-only or
// no-role caller to read the configuration audit log via /management
// even though /api/audit/query enforces OperationalAuditRoles. The fix
// gates this legacy command to Admin so the older route is never looser
// than the new REST endpoint.
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
/// Verifies that query audit log command, when with deployment role, returns unauthorized.
[Fact]
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
// ========================================================================
// 2. Read-only query passes without special role
// ========================================================================
/// Verifies that list templates command, when with no roles, returns success.
[Fact]
public void ListTemplatesCommand_WithNoRoles_ReturnsSuccess()
{
var templates = new List { new("Template1") { Id = 1 } };
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(templates);
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that list sites command, when with design role, returns success.
[Fact]
public void ListSitesCommand_WithDesignRole_ReturnsSuccess()
{
// ListSitesCommand is read-only, any role should work
var siteRepo = Substitute.For();
siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(new List());
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new ListSitesCommand(), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// 2b. Configuration-change audit query -- result must not serialize to "{}"
// ========================================================================
///
/// Deferred-work register row 46: audit-config query returned the literal
/// {} for every filter combination against the rig even though the
/// AuditLogEntries rows were present. The handler returned the repository's
/// (Entries, TotalCount) ValueTuple straight to SerializeResult, and
/// System.Text.Json writes properties only — a tuple's Item1/Item2 are
/// fields, so the whole payload vanished with no error anywhere. Pins that the rows
/// and the total actually reach the wire.
///
[Fact]
public void QueryAuditLogCommand_ReturnsEntriesAndTotalCount()
{
var uiRepo = Substitute.For();
var entries = new List
{
new("alice", "Created", "Template", "7", "PumpTemplate")
{
Id = 1,
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero)
}
};
uiRepo.GetAuditLogEntriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(((IReadOnlyList)entries, 137));
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(PageSize: 25), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.NotEqual("{}", response.JsonData);
Assert.Contains("PumpTemplate", response.JsonData);
Assert.Contains("\"entries\"", response.JsonData);
Assert.Contains("\"totalCount\":137", response.JsonData);
}
///
/// Guard for the whole class of bug behind row 46: any handler that returns a tuple
/// silently serializes to {}.
/// now rejects tuples outright so the next occurrence is a loud failure rather than
/// an empty response.
///
[Fact]
public void SerializeResult_TupleResult_ThrowsInsteadOfEmittingEmptyObject()
{
var ex = Assert.Throws(
() => ManagementActor.SerializeResult((new[] { "a" }, 1)));
Assert.Contains("tuple", ex.Message, StringComparison.OrdinalIgnoreCase);
}
///
/// Pins the positive half of the guard: the named result record the handler now
/// returns serializes to a populated, camelCased object.
///
[Fact]
public void SerializeResult_AuditLogQueryResult_EmitsEntriesAndTotalCount()
{
var json = ManagementActor.SerializeResult(new AuditLogQueryResult(
new List
{
new("bob", "Deleted", "Instance", "3", "Pump1")
},
42));
Assert.Contains("\"entries\"", json);
Assert.Contains("\"totalCount\":42", json);
Assert.Contains("Pump1", json);
}
// ========================================================================
// 3. Template list test -- verify data flows through
// ========================================================================
/// Verifies that list templates command: returns template data.
[Fact]
public void ListTemplatesCommand_ReturnsTemplateData()
{
// ListTemplates now reads DB-side row summaries rather than materialising
// every template's full child graph and paging it in memory.
_templateRepo.GetTemplateSummariesAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new List
{
new(1, "PumpTemplate", "Pump", null, null, false, null, 0, 0, 0, 0, 0),
new(2, "ValveTemplate", "Valve", null, null, false, null, 0, 0, 0, 0, 0)
});
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.NotNull(response.JsonData);
Assert.Contains("PumpTemplate", response.JsonData);
Assert.Contains("ValveTemplate", response.JsonData);
}
// ========================================================================
// 4. Instance create test
// ========================================================================
/// Verifies that create instance command, when with deployment role, returns success.
[Fact]
public void CreateInstanceCommand_WithDeploymentRole_ReturnsSuccess()
{
var createdInstance = new Instance("Pump1") { Id = 42, TemplateId = 1, SiteId = 1 };
// InstanceService calls GetTemplateByIdAsync to verify template exists
_templateRepo.GetTemplateByIdAsync(1, Arg.Any())
.Returns(new Template("PumpTemplate") { Id = 1 });
// InstanceService calls GetInstanceByUniqueNameAsync to check uniqueness
_templateRepo.GetInstanceByUniqueNameAsync("Pump1", Arg.Any())
.Returns((Instance?)null);
// InstanceService calls AddInstanceAsync then SaveChangesAsync
_templateRepo.AddInstanceAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask);
_templateRepo.SaveChangesAsync(Arg.Any())
.Returns(1);
_auditService.LogAsync(
Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any