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.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(),
Substitute.For(),
comms,
Substitute.For(),
Options.Create(new DeploymentManagerOptions()),
NullLogger.Instance,
new ArtifactRevisionHashService(),
new SiteSecretsCollector(Substitute.For()));
_services.AddScoped(_ => _artifactService);
}
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(),
Arg.Any())
.Returns(Task.CompletedTask);
var actor = CreateActor();
var envelope = Envelope(
new CreateInstanceCommand("Pump1", 1, 1),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(response.JsonData);
Assert.Contains("Pump1", response.JsonData);
}
/// Verifies that set native alarm source override, when on locked source, returns management error.
[Fact]
public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
{
// The ManagementActor must reject an override targeting a source
// that is locked at the template level, before persisting anything.
var pipeline = Substitute.For();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List
{
new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(10, Arg.Any(), Arg.Any())
.Returns(Result.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
// The override must NOT be persisted.
_templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync(
Arg.Any());
}
// ========================================================================
// 5. Error handling test -- service throws exception
// ========================================================================
/// Verifies that list templates command, when repo throws, returns management error.
[Fact]
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
{
_templateRepo.GetTemplateSummariesAsync(Arg.Any(), Arg.Any(), Arg.Any())
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// An unanticipated repository fault must NOT leak its raw message to the
// caller; a generic message is returned.
Assert.DoesNotContain("Database connection lost", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
/// Verifies that create instance command, when service fails, returns management error.
[Fact]
public void CreateInstanceCommand_WhenServiceFails_ReturnsManagementError()
{
// Template not found -- InstanceService will return Result.Failure
_templateRepo.GetTemplateByIdAsync(99, Arg.Any())
.Returns((Template?)null);
var actor = CreateActor();
var envelope = Envelope(
new CreateInstanceCommand("BadInst", 99, 1),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
}
// ========================================================================
// Role boundary tests -- verify each category
// ========================================================================
/// Verifies that design command, when with admin role, returns unauthorized.
[Fact]
public void DesignCommand_WithAdminRole_ReturnsUnauthorized()
{
// CreateTemplateCommand requires "Designer" role, "Administrator" alone is insufficient
var actor = CreateActor();
var envelope = Envelope(
new CreateTemplateCommand("T1", null, null),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
/// Verifies that admin command, when with admin role, succeeds.
[Fact]
public void AdminCommand_WithAdminRole_Succeeds()
{
var siteRepo = Substitute.For();
siteRepo.AddSiteAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any())
.Returns(1);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateSiteCommand("NewSite", "NS1", "Desc"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that role check: is case insensitive.
[Fact]
public void RoleCheck_IsCaseInsensitive()
{
var siteRepo = Substitute.For();
siteRepo.AddSiteAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any())
.Returns(1);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
// "administrator" lowercase should still match "Administrator" requirement
var envelope = Envelope(
new CreateSiteCommand("Site2", "S2", null),
"administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// New command authorization tests
// ========================================================================
/// Verifies that shared script create, when with admin role, returns unauthorized.
[Fact]
public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
/// Verifies that database connection create, when with deployment role, returns unauthorized.
[Fact]
public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
/// Verifies that api method create, when with admin role, returns unauthorized.
[Fact]
public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
/// Verifies that update api method, when non compiling script, succeeds but returns compile warning.
[Fact]
public void UpdateApiMethod_NonCompilingScript_SucceedsButReturnsCompileWarning()
{
// The save still persists, but a script that does not compile must surface a
// top-level compileWarning in the result rather than silently looking like a
// success. The DB row is authoritative: ExecuteAsync revision-checks the cached
// handler against the fresh row, so the broken script returns a compilation error
// on every request/node rather than a previously registered handler still serving.
var existing = new Commons.Entities.InboundApi.ApiMethod("MyMethod", "return 1;")
{
Id = 7,
TimeoutSeconds = 10
};
var apiRepo = Substitute.For();
apiRepo.GetApiMethodByIdAsync(7, Arg.Any()).Returns(existing);
apiRepo.SaveChangesAsync(Arg.Any()).Returns(1);
_services.AddScoped(_ => apiRepo);
_services.AddSingleton(sp => new ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor(
NullLogger.Instance, sp));
var actor = CreateActor();
// Undefined symbol -> genuine Roslyn compile error.
var envelope = Envelope(
new UpdateApiMethodCommand(7, "return doesNotCompile;", 10, null, null), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(10));
Assert.Contains("compileWarning", response.JsonData);
Assert.Contains("failed to compile", response.JsonData);
}
/// Verifies that add template attribute, when with deployment role, returns unauthorized.
[Fact]
public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ========================================================================
// ElementDataType accept + validate on attribute add/update
// ========================================================================
/// Verifies that add list attribute, when with string element type, persists both columns.
[Fact]
public void AddListAttribute_WithStringElementType_PersistsBothColumns()
{
// A template exists with no attributes; AddAttributeAsync will save the
// entity built by the handler. Capture it to assert the persisted shape.
var template = new Template("T1") { Id = 1 };
_templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(new List { template });
TemplateAttribute? saved = null;
_templateRepo
.When(r => r.AddTemplateAttributeAsync(Arg.Any(), Arg.Any()))
.Do(ci => saved = ci.Arg());
_templateRepo.SaveChangesAsync(Arg.Any()).Returns(1);
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\",\"b\"]", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(saved);
Assert.Equal(Commons.Types.Enums.DataType.List, saved!.DataType);
Assert.Equal(Commons.Types.Enums.DataType.String, saved.ElementDataType);
Assert.Equal("[\"a\",\"b\"]", saved.Value);
}
/// Verifies that add list attribute, when with no element type, returns management error.
[Fact]
public void AddListAttribute_WithNoElementType_ReturnsManagementError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
/// Verifies that add list attribute, when with binary element type, returns management error.
[Fact]
public void AddListAttribute_WithBinaryElementType_ReturnsManagementError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Binary"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
/// Verifies that add scalar attribute, when with element type, returns management error.
[Fact]
public void AddScalarAttribute_WithElementType_ReturnsManagementError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
/// Verifies that add list attribute, when with malformed default, returns management error.
[Fact]
public void AddListAttribute_WithMalformedDefault_ReturnsManagementError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\"", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.Error);
}
/// Verifies that add list attribute, when with type mismatched default, returns management error.
[Fact]
public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Counts", "List", "[\"x\"]", null, null, false, "Int32"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.Error);
}
/// Verifies that update scalar attribute, when with element type, returns management error.
[Fact]
public void UpdateScalarAttribute_WithElementType_ReturnsManagementError()
{
// Update path runs the same pre-service validation: a scalar attribute
// may not carry an element type, regardless of repository state.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
/// Verifies that add attribute, when with unrecognised data type, returns curated management error.
[Fact]
public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError()
{
// A bogus DataType string used to throw ArgumentException
// from Enum.Parse, which MapFault masks as a generic "An internal error
// occurred" message. TryParse now raises a curated ManagementCommandException
// whose message names the offending token.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "Nonsense", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised data type", response.Error);
Assert.Contains("Nonsense", response.Error);
Assert.DoesNotContain("internal error", response.Error);
}
// ========================================================================
// Strict single-value enum parsing in
// ParseDataType / ParseElementDataType / the AlarmTriggerType parse sites.
// Raw Enum.Parse/TryParse admits ordinals ("2"), undefined values ("99" —
// a deploy-time bomb that saves fine and only detonates at deploy), and
// (Medium-wave T8 nuance) comma-joined name lists that TryParse/Parse
// bitwise-OR together regardless of [Flags]. ParseEnumStrict rejects all
// three, and — for AlarmTriggerType specifically — replaces a masked
// "An internal error occurred" (raw Enum.Parse's uncaught
// ArgumentException) with a curated, field-naming error.
// ========================================================================
/// Verifies that add attribute, when with non name data type token, returns curated error.
/// The token.
[Theory]
[InlineData("2")] // ordinal for a DEFINED member (Float)
[InlineData("99")] // undefined ordinal — the deploy-time-bomb case
[InlineData("String,Int32")] // comma-joined names — TryParse ORs 4|1 into DateTime (5)
public void AddAttribute_WithNonNameDataTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): the unguarded `Enum.TryParse` in the old
// ParseDataType has no numeric guard, no comma guard, and never checks
// Enum.IsDefined. Each of these tokens parsed successfully pre-fix (to
// Float, an undefined value, and DateTime respectively) and the command
// proceeded past the parser into TemplateService, failing later with
// "Template with ID 1 not found." instead of this curated message —
// verified by stashing this fix and re-running (see task report).
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", token, null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised data type", response.Error);
Assert.Contains(token, response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.DoesNotContain("internal error", response.Error);
}
/// Verifies that add attribute, when with typo data type, returns curated error naming valid values.
[Fact]
public void AddAttribute_WithTypoDataType_ReturnsCuratedErrorNamingValidValues()
{
// The pre-fix ParseDataType already curated a plain typo (that part of
// the earlier fix was correct), but its message named no valid set — this asserts
// the new "Valid values:" suffix, which is absent pre-fix.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "Sting", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised data type 'Sting'", response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.Contains("Boolean", response.Error);
Assert.Contains("List", response.Error);
}
/// Verifies that add attribute, when with defined data type name, parses and proceeds past guard.
/// The name.
[Theory]
[InlineData("Boolean")]
[InlineData("Int32")]
[InlineData("Float")]
[InlineData("Double")]
[InlineData("String")]
[InlineData("DateTime")]
[InlineData("Binary")]
public void AddAttribute_WithDefinedDataTypeName_ParsesAndProceedsPastGuard(string name)
{
// No repository stub: GetTemplateByIdAsync returns null (NSubstitute
// default), so a token the guard ACCEPTS reaches AddAttributeAsync's
// "Template not found" failure. A token the guard REJECTS would never
// reach the service at all (see the tests above), so this proves every
// defined name still parses.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", name, null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// Verifies that add attribute, when with defined list data type name and element type, parses and proceeds past guard.
[Fact]
public void AddAttribute_WithDefinedListDataTypeNameAndElementType_ParsesAndProceedsPastGuard()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "List", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// Verifies that update attribute, when with comma joined data type, returns curated error.
[Fact]
public void UpdateAttribute_WithCommaJoinedDataType_ReturnsCuratedError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "String,Int32", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised data type", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// Verifies that add list attribute, when with non name element type token, returns curated error.
/// The token.
[Theory]
[InlineData("2")] // ordinal for a DEFINED, valid element type (Float)
[InlineData("99")] // undefined ordinal
[InlineData("String,Int32")] // comma-joined — combines to DateTime (also a valid element type)
public void AddListAttribute_WithNonNameElementTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): each of these tokens parsed successfully
// (Float / an undefined value that fails a LATER, differently-worded
// "requires a valid element type" check / DateTime) rather than being
// rejected at the parse boundary with this message.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, token),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised element type", response.Error);
Assert.Contains(token, response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// Verifies that add list attribute, when with typo element type, returns curated error naming valid values.
[Fact]
public void AddListAttribute_WithTypoElementType_ReturnsCuratedErrorNamingValidValues()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Sting"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised element type 'Sting'", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// Verifies that add alarm, when with non name trigger type token, returns curated error.
/// The token.
[Theory]
[InlineData("0")] // ordinal for a DEFINED member (ValueMatch)
[InlineData("99")] // undefined ordinal — the deploy-time-bomb case
[InlineData("ValueMatch,RangeViolation")] // comma-joined — Enum.Parse ORs 0|1 into RangeViolation
public void AddAlarm_WithNonNameTriggerTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): AddAlarm/UpdateAlarm built TriggerType via
// raw `Enum.Parse`, which has none of ParseDeploymentStatus's guards.
// Each of these tokens parsed SILENTLY (to ValueMatch, an undefined
// value, and RangeViolation respectively — no exception at all) and the
// command proceeded into TemplateService, failing later with
// "Template with ID 1 not found." instead of this curated message.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", token, 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised alarm trigger type", response.Error);
Assert.Contains(token, response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// Verifies that add alarm, when with typo trigger type, returns curated error instead of masked internal error.
[Fact]
public void AddAlarm_WithTypoTriggerType_ReturnsCuratedErrorInsteadOfMaskedInternalError()
{
// PROOF OF FAILURE (pre-fix): unlike ParseDataType (already curated by
// the earlier fix), the TriggerType sites used raw `Enum.Parse`. A typo's
// ArgumentException is uncaught by any handler-local guard and reaches
// MapFault as an unanticipated fault — masked to a generic
// "An internal error occurred (CorrelationId=...)" message, exactly the
// masking this task's defect description calls out. Post-fix,
// ParseEnumStrict raises a curated ManagementCommandException instead.
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", "ValueMach", 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised alarm trigger type 'ValueMach'", response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.DoesNotContain("internal error", response.Error);
}
/// Verifies that add alarm, when with defined trigger type name, parses and proceeds past guard.
/// The name.
[Theory]
[InlineData("ValueMatch")]
[InlineData("RangeViolation")]
[InlineData("RateOfChange")]
[InlineData("HiLo")]
[InlineData("Expression")]
public void AddAlarm_WithDefinedTriggerTypeName_ParsesAndProceedsPastGuard(string name)
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", name, 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// Verifies that update alarm, when with ordinal trigger type, returns curated error.
[Fact]
public void UpdateAlarm_WithOrdinalTriggerType_ReturnsCuratedError()
{
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAlarmCommand(5, "AlarmX", "0", 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised alarm trigger type", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// Verifies that add template alarm: carries is mes reportable.
[Fact]
public void AddTemplateAlarm_CarriesIsMesReportable()
{
// The MES-reportable flag rides AddTemplateAlarmCommand as a trailing
// optional (additive-only wire contract) and must reach the TemplateAlarm
// the handler hands to TemplateService — verified at the repository
// boundary, which is where the entity is persisted.
var template = new Template("T1") { Id = 1 };
_templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(template);
_templateRepo.GetAllTemplatesForAnalysisAsync(Arg.Any())
.Returns(new List { template });
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(new List { template });
TemplateAlarm? added = null;
_templateRepo
.When(r => r.AddTemplateAlarmAsync(Arg.Any(), Arg.Any()))
.Do(ci => added = ci.Arg());
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
"Designer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.True(added!.IsMesReportable);
}
/// Verifies that update template alarm: carries is mes reportable.
[Fact]
public void UpdateTemplateAlarm_CarriesIsMesReportable()
{
var template = new Template("T1") { Id = 1 };
var existing = new TemplateAlarm("HighTemp")
{
Id = 5,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
PriorityLevel = 100,
IsMesReportable = false
};
_templateRepo.GetTemplateAlarmByIdAsync(5, Arg.Any()).Returns(existing);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(new List { template });
TemplateAlarm? updated = null;
_templateRepo
.When(r => r.UpdateTemplateAlarmAsync(Arg.Any(), Arg.Any()))
.Do(ci => updated = ci.Arg());
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAlarmCommand(5, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
"Designer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
Assert.NotNull(updated);
Assert.True(updated!.IsMesReportable);
}
/// Verifies that set instance alarm override: passes is mes reportable override.
[Fact]
public void SetInstanceAlarmOverride_PassesIsMesReportableOverride()
{
// false is the interesting case: it is distinct from the null "inherit"
// default, so a dropped argument would read as "inherit" rather than
// "explicitly not reportable".
var template = new Template("T1") { Id = 1 };
template.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 5,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
PriorityLevel = 100
});
var instance = new Instance("Inst1") { Id = 10, TemplateId = 1, SiteId = 1 };
_templateRepo.GetInstanceByIdAsync(10, Arg.Any()).Returns(instance);
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(new List { template });
_templateRepo.GetAlarmOverrideAsync(10, "HighTemp", Arg.Any())
.Returns((InstanceAlarmOverride?)null);
InstanceAlarmOverride? persisted = null;
_templateRepo
.When(r => r.AddInstanceAlarmOverrideAsync(Arg.Any(), Arg.Any()))
.Do(ci => persisted = ci.Arg());
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceAlarmOverrideCommand(10, "HighTemp", null, null, IsMesReportableOverride: false),
"Deployer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
Assert.NotNull(persisted);
Assert.False(persisted!.IsMesReportableOverride);
}
/// Verifies that update attribute, when valid scalar, persists value and keeps fixed fields.
[Fact]
public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
{
// Documents the fixed-field contract on update —
// ValidateAttributeOverride(existing, proposed) runs for every update, so
// a matching DataType/ElementDataType lets the Value change persist while
// the fixed type columns are never copied from the proposed attribute.
var template = new Template("T1") { Id = 1 };
var existing = new TemplateAttribute("Count")
{
Id = 5, TemplateId = 1, DataType = Commons.Types.Enums.DataType.Int32,
ElementDataType = null, Value = "0"
};
_templateRepo.GetTemplateAttributeByIdAsync(5, Arg.Any()).Returns(existing);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any())
.Returns(new List { template });
TemplateAttribute? updated = null;
_templateRepo
.When(r => r.UpdateTemplateAttributeAsync(Arg.Any(), Arg.Any()))
.Do(ci => updated = ci.Arg());
_templateRepo.SaveChangesAsync(Arg.Any()).Returns(1);
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "Int32", "42", null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(updated);
Assert.Equal("42", updated!.Value); // Value updated
Assert.Equal(Commons.Types.Enums.DataType.Int32, updated.DataType); // fixed
Assert.Null(updated.ElementDataType); // fixed
}
/// Verifies that update api key, when with design role, returns unauthorized.
[Fact]
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
/// Verifies that add scope rule, when with design role, returns unauthorized.
[Fact]
public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
/// Verifies that update area, when with admin role, returns unauthorized.
[Fact]
public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ========================================================================
// New command read-only query tests (no role required)
// ========================================================================
/// Verifies that list shared scripts, when with no roles, returns success.
[Fact]
public void ListSharedScripts_WithNoRoles_ReturnsSuccess()
{
_templateRepo.GetAllSharedScriptsAsync(Arg.Any())
.Returns(new List());
_services.AddScoped();
var actor = CreateActor();
var envelope = Envelope(new ListSharedScriptsCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that list database connections, when with no roles, returns success.
[Fact]
public void ListDatabaseConnections_WithNoRoles_ReturnsSuccess()
{
var extRepo = Substitute.For();
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any())
.Returns(new List());
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(new ListDatabaseConnectionsCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that list api methods, when with no roles, returns success.
[Fact]
public void ListApiMethods_WithNoRoles_ReturnsSuccess()
{
var apiRepo = Substitute.For();
apiRepo.GetAllApiMethodsAsync(Arg.Any())
.Returns(new List());
_services.AddScoped(_ => apiRepo);
var actor = CreateActor();
var envelope = Envelope(new ListApiMethodsCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that list scope rules, when with admin role, returns success.
[Fact]
public void ListScopeRules_WithAdminRole_ReturnsSuccess()
{
var secRepo = Substitute.For();
secRepo.GetScopeRulesForMappingAsync(1, Arg.Any())
.Returns(new List());
_services.AddScoped(_ => secRepo);
var actor = CreateActor();
var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// New command error handling tests
// ========================================================================
/// Verifies that list database connections, when repo throws, returns error.
[Fact]
public void ListDatabaseConnections_WhenRepoThrows_ReturnsError()
{
var extRepo = Substitute.For();
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any())
.ThrowsAsync(new InvalidOperationException("Connection refused"));
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(new ListDatabaseConnectionsCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// Raw repository fault text is not leaked.
Assert.DoesNotContain("Connection refused", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
// ========================================================================
// Site-scope enforcement tests
//
// A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must
// not be able to read or act on entities belonging to a site outside their
// scope. The handlers must throw SiteScopeViolationException, which the
// actor maps to ManagementUnauthorized.
// ========================================================================
private void AddSiteRepoWithSite(int siteId, string identifier)
{
var siteRepo = Substitute.For();
siteRepo.GetSiteByIdAsync(siteId, Arg.Any())
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any())
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
_services.AddScoped(_ => siteRepo);
}
/// Verifies that get instance, when out of scope for site scoped user, returns unauthorized.
[Fact]
public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Instance belongs to site 2; user is scoped to site 1.
_templateRepo.GetInstanceByIdAsync(7, Arg.Any())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that get instance, when in scope for site scoped user, returns success.
[Fact]
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
{
_templateRepo.GetInstanceByIdAsync(7, Arg.Any())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
}
// ========================================================================
// Register row 98: the scope gate fails CLOSED on an unresolvable target.
//
// EnforceSiteScopeForInstance / EnforceSiteScopeForIdentifier used to skip
// EnforceSiteScope entirely when the id did not resolve, so a site-scoped
// caller PASSED the gate and fell through into the handler body. A gate that
// cannot prove the target is inside the caller's scope must treat it as
// outside. System-wide callers return at the guards above the lookup and are
// untouched — they still get the handler's ordinary "not found".
// ========================================================================
/// Verifies that get instance, when nonexistent id for site scoped user, returns unauthorized.
[Fact]
public void GetInstance_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// _templateRepo is unstubbed for id 999 → GetInstanceByIdAsync returns null.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The denial must not double as an existence oracle: the message says the
// instance is not in scope, never whether it exists.
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
}
/// Verifies that get instance, when nonexistent id for site scoped user, is indistinguishable from out of scope.
[Fact]
public void GetInstance_NonexistentIdForSiteScopedUser_IsIndistinguishableFromOutOfScope()
{
// The security property: a scoped caller cannot tell "instance 7 lives at a
// site you may not see" apart from "instance 999 does not exist" — both are
// the same ManagementUnauthorized shape.
_templateRepo.GetInstanceByIdAsync(7, Arg.Any())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"));
var outOfScope = ExpectMsg(TimeSpan.FromSeconds(5));
actor.Tell(ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer"));
var nonexistent = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(outOfScope.GetType(), nonexistent.GetType());
}
/// Verifies that get instance, when nonexistent id for admin user, is unchanged.
[Fact]
public void GetInstance_NonexistentIdForAdminUser_IsUnchanged()
{
// An Administrator is system-wide, so the gate returns before the lookup:
// the handler runs and answers for itself. NOT a ManagementUnauthorized.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.IsNotType(response);
}
/// Verifies that get instance, when nonexistent id for system wide deployer, is unchanged.
[Fact]
public void GetInstance_NonexistentIdForSystemWideDeployer_IsUnchanged()
{
// The other system-wide arm: a Deployer with NO site grants. Same rule,
// same untouched behaviour.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), Array.Empty(), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.IsNotType(response);
}
/// Verifies that get instance, when in scope id for site scoped user, is still allowed.
[Fact]
public void GetInstance_InScopeIdForSiteScopedUser_IsStillAllowed()
{
// The fail-closed change must not deny a legitimate in-scope target.
_templateRepo.GetInstanceByIdAsync(7, Arg.Any())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
}
/// Verifies that get site, when nonexistent id for site scoped user, returns unauthorized.
[Fact]
public void GetSite_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// HandleGetSite resolves the site itself and carried its own copy of the
// fail-OPEN shape (`if (site != null) EnforceSiteScope(...)`), so a scoped
// caller used to get success/null for any id — an enumeration oracle.
var siteRepo = Substitute.For();
siteRepo.GetSiteByIdAsync(999, Arg.Any())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that get site, when nonexistent id for admin user, is unchanged.
[Fact]
public void GetSite_NonexistentIdForAdminUser_IsUnchanged()
{
var siteRepo = Substitute.For();
siteRepo.GetSiteByIdAsync(999, Arg.Any())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Administrator");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.IsNotType(response);
}
/// Verifies that get data connection, when nonexistent id for site scoped user, returns unauthorized.
[Fact]
public void GetDataConnection_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// Third copy of the same inline fail-OPEN shape.
var siteRepo = Substitute.For();
siteRepo.GetDataConnectionByIdAsync(999, Arg.Any())
.Returns((Commons.Entities.Sites.DataConnection?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetDataConnectionCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that query event logs, when nonexistent site identifier for site scoped user, returns unauthorized.
[Fact]
public void QueryEventLogs_NonexistentSiteIdentifierForSiteScopedUser_ReturnsUnauthorized()
{
// EnforceSiteScopeForIdentifier shares the shape: an identifier that
// resolves to no Site row is a denial for a scoped caller.
var siteRepo = Substitute.For();
siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(
new QueryEventLogsCommand("GHOST"),
new[] { "1" },
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that query event logs, when in scope site identifier for site scoped user, is still allowed.
[Fact]
public void QueryEventLogs_InScopeSiteIdentifierForSiteScopedUser_IsStillAllowed()
{
AddSiteRepoWithSite(1, "SITE1");
var actor = CreateActor();
var envelope = ScopedEnvelope(
new QueryEventLogsCommand("SITE1"),
new[] { "1" },
"Deployer");
actor.Tell(envelope);
// The scope gate passes; whatever the (unwired) CommunicationService does
// next is not this test's business — only that it is NOT a scope denial.
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.IsNotType(response);
}
/// Verifies that get site, when out of scope for site scoped user, returns unauthorized.
[Fact]
public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that list areas, when out of scope for site scoped user, returns unauthorized.
[Fact]
public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var uiRepo = Substitute.For();
uiRepo.GetAreaTreeBySiteIdAsync(2, Arg.Any())
.Returns(new List());
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// Verifies that get data connection, when out of scope for site scoped user, returns unauthorized.
[Fact]
public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
var siteRepo = Substitute.For();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any())
.Returns(new Commons.Entities.Sites.DataConnection("Conn5", "OpcUa", 2) { Id = 5 });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
///