Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
T
Joseph Doherty 41a5ecf14a feat(external-systems): deploy per-system retry policy to sites + CLI/management surface (register row 27)
An external system's MaxRetries/RetryDelay existed on the central entity but never
reached a site and had no management surface, so every ExternalSystem.CachedCall
buffered with the S&F engine default (DefaultMaxRetries 50 x DefaultRetryInterval
30s ~= 25 min to park) whatever central had configured. The sibling
DatabaseConnectionArtifact had always carried its equivalents.

The consumption end was already wired: ExternalSystemClient.CachedCallAsync already
read system.MaxRetries/RetryDelay and passed them to EnqueueAsync with the 0-as-unset
convention. Only the transport and persistence were missing.

Commons
- ExternalSystemArtifact gains MaxRetries/RetryDelay as optional positional params
  (0 / TimeSpan.Zero = unset), matching DatabaseConnectionArtifact rather than going
  nullable, so it maps 1:1 onto the entity and the proto3 scalars.
- Create/UpdateExternalSystemCommand gain nullable MaxRetries/RetryDelay, following
  the existing TimeoutSeconds "omit to preserve" convention. Both additive.

Central
- ManagementActor create maps both; update preserves-if-null.
- ArtifactDeploymentService projects them onto the artifact.

Wire
- site_command.proto ExternalSystemArtifactDto gains max_retries=7 /
  retry_delay=8 (google.protobuf.Duration), mirroring DatabaseConnectionArtifactDto.
  Additive: a pre-existing site decodes them as 0/absent, which IS the unset value,
  so old behaviour is preserved. Vendored C# regenerated via docker/regen-proto.sh
  sitecommand; csproj Protobuf item re-commented, no message types added or removed.

Site
- external_systems gains max_retries / retry_delay_ms via AddColumnIfMissing,
  DEFAULT 0 = unset. Storage upsert, repository SELECT/mapping (new ordinals 6/7
  appended so existing ones are untouched), apply path, and the legacy migrator's
  explicit column list all carry them.

Replicated-table decision: external_systems IS in SiteLocalDbSetup.ReplicatedTables,
so this was checked against the AckTime/native_alarm_state precedent. Real columns
are correct here, not a JSON bag: the table has no general-purpose config column
(auth_configuration and method_definitions are both semantically specific),
timeout_seconds already set the additive-column precedent on this exact table, and
sibling database_connections stores the same two values as plain columns. Verified in
LocalDb source that RegisterReplicated re-reads PRAGMA table_info every boot and its
install script is DROP-then-CREATE, so capture triggers rebuild over the new column
set automatically, and SiteStorageSchema.Apply runs before registration on the same
boot. The only cost is the ReplicatedTable.ComputeDigest change, which the sync
handshake compares fail-closed - a mixed-version pair refuses to sync loudly instead
of diverging silently. Both-nodes-together is already the standing site-pair rule, so
no new operational constraint; recorded in topology-guide.md.

Also fixed: ArtifactDiff.CompareExternalSystem never compared MaxRetries/RetryDelay,
so a bundle import that changed a system's retry budget previewed as "Identical" -
harmless while the values were inert, a live behaviour change now.

No EF migration: the central ExternalSystemDefinitions columns already exist
(InitialSchema). Central UI already had both form fields.

Tests: site storage round-trip + unset-default, site apply path, central artifact
projection, proto round-trip (via the shared sample), management create/preserve,
CLI option parse + alias, and diff reporting. Fail-before verified on the two core
tests (expected 7 got 0 on the read path; expected 9 got 0 on the apply path).

Docs: Component-ExternalSystemGateway, Component-StoreAndForward, Component-CLI,
components/StoreAndForward, components/SiteRuntime, deployment/topology-guide,
CLI README (which was also missing --timeout-seconds entirely).
2026-08-15 12:56:12 -04:00

4438 lines
191 KiB
C#

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!;
public ManagementActorTests()
{
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services.AddScoped(_ => _templateRepo);
_services.AddScoped(_ => _auditService);
_services.AddScoped<InstanceService>();
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
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"));
/// <summary>
/// Registers a substitutable <see cref="ArtifactDeploymentService"/> 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 <c>DeployToSiteAsync</c>/<c>DeployToAllSitesAsync</c> members are
/// intercepted; all other members keep their real (unused) bodies.
/// </summary>
private void RegisterArtifactService()
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
_artifactService = Substitute.For<ArtifactDeploymentService>(
Substitute.For<ISiteRepository>(),
Substitute.For<IDeploymentManagerRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IExternalSystemRepository>(),
Substitute.For<INotificationRepository>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
_services.AddScoped(_ => _artifactService);
}
private static Result<ArtifactDeploymentSummary> DeploySummary() =>
Result<ArtifactDeploymentSummary>.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0));
void IDisposable.Dispose()
{
Shutdown();
}
// ========================================================================
// 1. Authorization test -- admin command with wrong role
// ========================================================================
[Fact]
public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void CreateSiteCommand_WithNoRoles_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", null));
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(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).
// ========================================================================
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
var securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
securedWriteRepo.QueryAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<PendingSecuredWrite>>(
Array.Empty<PendingSecuredWrite>()));
_services.AddScoped(_ => securedWriteRepo);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployer", response.Message);
}
[Fact]
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
{
// ManagementService-018: 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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
// ========================================================================
// 2. Read-only query passes without special role
// ========================================================================
[Fact]
public void ListTemplatesCommand_WithNoRoles_ReturnsSuccess()
{
var templates = new List<Template> { new("Template1") { Id = 1 } };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(templates);
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void ListSitesCommand_WithDesignRole_ReturnsSuccess()
{
// ListSitesCommand is read-only, any role should work
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new ListSitesCommand(), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// 2b. Configuration-change audit query -- result must not serialize to "{}"
// ========================================================================
/// <summary>
/// Deferred-work register row 46: <c>audit-config query</c> returned the literal
/// <c>{}</c> for every filter combination against the rig even though the
/// <c>AuditLogEntries</c> rows were present. The handler returned the repository's
/// <c>(Entries, TotalCount)</c> ValueTuple straight to <c>SerializeResult</c>, and
/// System.Text.Json writes properties only — a tuple's <c>Item1</c>/<c>Item2</c> are
/// fields, so the whole payload vanished with no error anywhere. Pins that the rows
/// and the total actually reach the wire.
/// </summary>
[Fact]
public void QueryAuditLogCommand_ReturnsEntriesAndTotalCount()
{
var uiRepo = Substitute.For<ICentralUiRepository>();
var entries = new List<Commons.Entities.Audit.AuditLogEntry>
{
new("alice", "Created", "Template", "7", "PumpTemplate")
{
Id = 1,
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero)
}
};
uiRepo.GetAuditLogEntriesAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(),
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<Guid?>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<Commons.Entities.Audit.AuditLogEntry>)entries, 137));
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(PageSize: 25), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotEqual("{}", response.JsonData);
Assert.Contains("PumpTemplate", response.JsonData);
Assert.Contains("\"entries\"", response.JsonData);
Assert.Contains("\"totalCount\":137", response.JsonData);
}
/// <summary>
/// Guard for the whole class of bug behind row 46: any handler that returns a tuple
/// silently serializes to <c>{}</c>. <see cref="ManagementActor.SerializeResult"/>
/// now rejects tuples outright so the next occurrence is a loud failure rather than
/// an empty response.
/// </summary>
[Fact]
public void SerializeResult_TupleResult_ThrowsInsteadOfEmittingEmptyObject()
{
var ex = Assert.Throws<InvalidOperationException>(
() => ManagementActor.SerializeResult((new[] { "a" }, 1)));
Assert.Contains("tuple", ex.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Pins the positive half of the guard: the named result record the handler now
/// returns serializes to a populated, camelCased object.
/// </summary>
[Fact]
public void SerializeResult_AuditLogQueryResult_EmitsEntriesAndTotalCount()
{
var json = ManagementActor.SerializeResult(new AuditLogQueryResult(
new List<Commons.Entities.Audit.AuditLogEntry>
{
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
// ========================================================================
[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<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(new List<TemplateSummary>
{
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<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(response.JsonData);
Assert.Contains("PumpTemplate", response.JsonData);
Assert.Contains("ValveTemplate", response.JsonData);
}
// ========================================================================
// 4. Instance create test
// ========================================================================
[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<CancellationToken>())
.Returns(new Template("PumpTemplate") { Id = 1 });
// InstanceService calls GetInstanceByUniqueNameAsync to check uniqueness
_templateRepo.GetInstanceByUniqueNameAsync("Pump1", Arg.Any<CancellationToken>())
.Returns((Instance?)null);
// InstanceService calls AddInstanceAsync then SaveChangesAsync
_templateRepo.AddInstanceAsync(Arg.Any<Instance>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns(1);
_auditService.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(),
Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateActor();
var envelope = Envelope(
new CreateInstanceCommand("Pump1", 1, 1),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(response.JsonData);
Assert.Contains("Pump1", response.JsonData);
}
[Fact]
public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
{
// #05-T13: the ManagementActor must reject an override targeting a source
// that is locked at the template level, before persisting anything.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(10, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.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<ManagementError>(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<InstanceNativeAlarmSourceOverride>());
}
// ========================================================================
// 5. Error handling test -- service throws exception
// ========================================================================
[Fact]
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
{
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(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 (finding ManagementService-016); a generic message is returned.
Assert.DoesNotContain("Database connection lost", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
[Fact]
public void CreateInstanceCommand_WhenServiceFails_ReturnsManagementError()
{
// Template not found -- InstanceService will return Result.Failure
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Template?)null);
var actor = CreateActor();
var envelope = Envelope(
new CreateInstanceCommand("BadInst", 99, 1),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
}
// ========================================================================
// Role boundary tests -- verify each category
// ========================================================================
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void AdminCommand_WithAdminRole_Succeeds()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns(1);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateSiteCommand("NewSite", "NS1", "Desc"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void RoleCheck_IsCaseInsensitive()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
.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<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// New command authorization tests
// ========================================================================
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[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<IInboundApiRepository>();
apiRepo.GetApiMethodByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
apiRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => apiRepo);
_services.AddSingleton(sp => new ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor(
NullLogger<ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor>.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<ManagementSuccess>(TimeSpan.FromSeconds(10));
Assert.Contains("compileWarning", response.JsonData);
Assert.Contains("failed to compile", response.JsonData);
}
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ========================================================================
// MV-10: ElementDataType accept + validate on attribute add/update
// ========================================================================
[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<CancellationToken>()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
TemplateAttribute? saved = null;
_templateRepo
.When(r => r.AddTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
.Do(ci => saved = ci.Arg<TemplateAttribute>());
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\",\"b\"]", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(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);
}
[Fact]
public void AddListAttribute_WithNoElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
[Fact]
public void AddListAttribute_WithBinaryElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Binary"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
[Fact]
public void AddScalarAttribute_WithElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
[Fact]
public void AddListAttribute_WithMalformedDefault_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\"", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.Error);
}
[Fact]
public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Counts", "List", "[\"x\"]", null, null, false, "Int32"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.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<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
[Fact]
public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError()
{
// MV-10 review fix: 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<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "Nonsense", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(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);
}
[Fact]
public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
{
// MV-10 review fix: 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<CancellationToken>()).Returns(existing);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
TemplateAttribute? updated = null;
_templateRepo
.When(r => r.UpdateTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<TemplateAttribute>());
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "Int32", "42", null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(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
}
[Fact]
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ========================================================================
// New command read-only query tests (no role required)
// ========================================================================
[Fact]
public void ListSharedScripts_WithNoRoles_ReturnsSuccess()
{
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
_services.AddScoped<SharedScriptService>();
var actor = CreateActor();
var envelope = Envelope(new ListSharedScriptsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void ListDatabaseConnections_WithNoRoles_ReturnsSuccess()
{
var extRepo = Substitute.For<IExternalSystemRepository>();
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(new ListDatabaseConnectionsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void ListApiMethods_WithNoRoles_ReturnsSuccess()
{
var apiRepo = Substitute.For<IInboundApiRepository>();
apiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
_services.AddScoped(_ => apiRepo);
var actor = CreateActor();
var envelope = Envelope(new ListApiMethodsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void ListScopeRules_WithAdminRole_ReturnsSuccess()
{
var secRepo = Substitute.For<ISecurityRepository>();
secRepo.GetScopeRulesForMappingAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Security.SiteScopeRule>());
_services.AddScoped(_ => secRepo);
var actor = CreateActor();
var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// New command error handling tests
// ========================================================================
[Fact]
public void ListDatabaseConnections_WhenRepoThrows_ReturnsError()
{
var extRepo = Substitute.For<IExternalSystemRepository>();
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Connection refused"));
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(new ListDatabaseConnectionsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// Raw repository fault text is not leaked (finding ManagementService-016).
Assert.DoesNotContain("Connection refused", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
// ========================================================================
// Site-scope enforcement tests (findings ManagementService-001 / -002)
//
// 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<ISiteRepository>();
siteRepo.GetSiteByIdAsync(siteId, Arg.Any<CancellationToken>())
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
_services.AddScoped(_ => siteRepo);
}
[Fact]
public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Instance belongs to site 2; user is scoped to site 1.
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
.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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
{
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var uiRepo = Substitute.For<ICentralUiRepository>();
uiRepo.GetAreaTreeBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Instances.Area>());
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>())
.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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void GetSite_OutOfScopeForAdminUser_ReturnsSuccess()
{
// Admin role bypasses site scoping even when PermittedSiteIds is set.
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Administrator");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void QueryEventLogs_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Site SITE2 has Id 2; user scoped to site 1.
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void RetryParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void DiscardParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
[Fact]
public void DebugSnapshot_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Instance 9 belongs to site 2; user scoped to site 1.
_templateRepo.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump9") { Id = 9, SiteId = 2 });
AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor();
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
// ========================================================================
// Serialization (finding ManagementService-007)
//
// Command results are serialized with System.Text.Json configured with
// ReferenceHandler.IgnoreCycles, so an entity graph with a bidirectional
// navigation property does not throw. Property names are camelCase, which
// the CLI's case-insensitive deserializer accepts.
// ========================================================================
private sealed class CyclicNode
{
public string Name { get; set; } = "";
public CyclicNode? Parent { get; set; }
public List<CyclicNode> Children { get; set; } = new();
}
[Fact]
public void SerializeResult_WithCyclicGraph_DoesNotThrow()
{
var parent = new CyclicNode { Name = "Parent" };
var child = new CyclicNode { Name = "Child", Parent = parent };
parent.Children.Add(child); // parent <-> child cycle
var json = ManagementActor.SerializeResult(parent);
Assert.Contains("Parent", json);
Assert.Contains("Child", json);
}
[Fact]
public void SerializeResult_UsesCamelCasePropertyNames()
{
var json = ManagementActor.SerializeResult(new CyclicNode { Name = "X" });
Assert.Contains("\"name\"", json);
Assert.DoesNotContain("\"Name\"", json);
}
// ========================================================================
// PipeTo fault mapping (finding ManagementService-004)
//
// Command processing is piped back via PipeTo; a fault raised inside
// DispatchCommand must be mapped to ManagementError by the failure
// continuation rather than escaping or being silently dropped.
// ========================================================================
// ========================================================================
// ResolveRolesCommand dead-code removal (finding ManagementService-011 / -008)
//
// The two-step ResolveRoles + command flow is retired: the HTTP endpoint does
// LDAP auth and role resolution itself. The actor must no longer dispatch
// ResolveRolesCommand — a stray in-cluster sender hitting it gets a uniform
// ManagementError rather than an unauthenticated role-mapping enumeration.
// ========================================================================
[Fact]
public void ResolveRolesCommand_IsNoLongerDispatched_ReturnsManagementError()
{
var secRepo = Substitute.For<ISecurityRepository>();
_services.AddScoped(_ => secRepo);
var actor = CreateActor();
var envelope = Envelope(new ResolveRolesCommand(new[] { "cn=admins" }));
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// No role-mapping data is enumerated/leaked back to the caller.
secRepo.DidNotReceiveWithAnyArgs().GetAllMappingsAsync(default);
}
[Fact]
public void UnknownCommandType_FaultMappedToManagementError()
{
// ManagementEnvelope.Command is typed object; an unrecognised payload
// makes DispatchCommand throw NotSupportedException. The PipeTo failure
// continuation must map it to ManagementError.
var actor = CreateActor();
var envelope = Envelope("not-a-command");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
}
// ========================================================================
// QueryDeployments authorization + site-scope (findings -014 / -017)
//
// QueryDeploymentsCommand is gated to the Deployment role and, like every
// other Deployment-role handler, must enforce site scoping: a site-scoped
// user must not see deployment records for instances at other sites.
// ========================================================================
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
/// <summary>
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
/// now returns DB-side projections instead of whole tracked entities.
/// </summary>
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary>
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
/// instance / status / scope / paging predicates the database would.
///
/// <para>
/// The filtering moved OUT of the handler and INTO the query, so a stub that
/// ignored the arguments would make these tests pass no matter what the handler
/// passed down. Honouring them here is what keeps the scope-enforcement
/// assertions meaningful.
/// </para>
/// </summary>
private static void StubDeploymentSummaryQuery(
IDeploymentManagerRepository repo,
IEnumerable<int> instanceIds)
{
var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
repo.QueryDeploymentSummariesAsync(
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var instanceId = ci.ArgAt<int?>(0);
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
var skip = ci.ArgAt<int>(3);
var take = ci.ArgAt<int?>(4);
IEnumerable<DeploymentRecordSummary> q = rows;
if (instanceId.HasValue)
q = q.Where(r => r.InstanceId == instanceId.Value);
if (scope != null)
q = q.Where(r => scope.Contains(r.InstanceId));
q = q.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
});
}
/// <summary>
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
/// templates, applying the requested Skip/Take exactly as the database would.
/// </summary>
private void StubTemplateSummaryPaging(int count)
{
var rows = Enumerable.Range(1, count)
.Select(i => new TemplateSummary(i, $"T-{i:D3}", null, null, null, false, null, 0, 0, 0, 0, 0))
.ToList();
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var skip = ci.ArgAt<int>(0);
var take = ci.ArgAt<int?>(1);
IEnumerable<TemplateSummary> q = rows.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<TemplateSummary>)q.ToList();
});
}
[Fact]
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployer", response.Message);
}
[Fact]
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.Contains("deploy-2", response.JsonData);
}
[Fact]
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-5", response.JsonData);
}
[Fact]
public void QueryDeployments_FilteredByOutOfScopeInstance_ReturnsUnauthorized()
{
// Instance 5 belongs to site 2; user is scoped to site 1.
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 2 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The out-of-scope instance's deployment history must not be queried.
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
default, default, default, default, default);
}
[Fact]
public void QueryDeployments_FilteredByInScopeInstance_ReturnsRecords()
{
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-5", response.JsonData);
}
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
{
// ManagementService-023: the unfiltered branch now bulk-loads instances
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
// distinct InstanceId). Mock the bulk path accordingly.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
// The per-instance lookup must NOT have been used for the unfiltered
// branch — that was the N+1 the bulk load replaced.
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
{
// ManagementService-023 regression pin: the unfiltered branch must issue
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
// of how many DeploymentRecords reference distinct InstanceIds. Before the
// fix, three distinct instance ids would have produced three per-instance
// lookups (textbook N+1).
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
new("Pump3") { Id = 3, SiteId = 1 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
[Fact]
public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
{
// Admin role bypasses site scoping even with PermittedSiteIds set.
// (The user also holds Deployment so it passes the role gate.)
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Administrator", "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.Contains("deploy-2", response.JsonData);
}
// ========================================================================
// DeployArtifacts honors SiteId + site-scope enforcement (arch-review C2)
//
// MgmtDeployArtifactsCommand.SiteId is now honored: a value routes to the
// single-site deploy path; null routes fleet-wide. A site-scoped (non-Admin)
// Deployer may not deploy fleet-wide, and may not target a site outside scope.
// ========================================================================
[Fact]
public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy()
{
RegisterArtifactService();
_artifactService.DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>())
.Returns(DeploySummary());
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: 7), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
_artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
}
[Fact]
public void DeployArtifacts_FleetWide_CallsAllSitesDeploy()
{
RegisterArtifactService();
_artifactService.DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>())
.Returns(DeploySummary());
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: null), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_artifactService.Received(1).DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>());
_artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
}
[Fact]
public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
{
RegisterArtifactService();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), new[] { "3" }, "Deployer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
}
[Fact]
public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
{
RegisterArtifactService();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), new[] { "3" }, "Deployer"));
var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
_artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
}
// ========================================================================
// Additive Skip/Take paging on template/instance lists (arch-review P2)
//
// Take = null preserves the historical unlimited behaviour. When set, paging
// applies AFTER any in-memory site-scope filtering, so a scoped user never sees
// an out-of-scope entity surface into their page window.
// ========================================================================
[Fact]
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
{
// Paging moved into the repository (DB-side Skip/Take). The stub applies
// the same window the database would, so this still pins that the
// command's Skip/Take actually reach the query — the bug being that the
// handler used to load everything and slice in memory.
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// Items 11..15 (1-based) present; the pages on either side absent.
Assert.Contains("T-011", response.JsonData);
Assert.Contains("T-015", response.JsonData);
Assert.DoesNotContain("T-010", response.JsonData);
Assert.DoesNotContain("T-016", response.JsonData);
}
[Fact]
public void ListTemplates_DefaultTake_ReturnsAll()
{
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("T-001", response.JsonData);
Assert.Contains("T-030", response.JsonData);
}
[Fact]
public void ListInstances_PagingAppliesAfterSiteScopeFilter()
{
// Scoped user permitted to site 1 only. The repo returns 3 in-scope (site 1)
// instances plus 2 out-of-scope (site 2). Take:2 must yield 2 of THEIR
// instances and never an out-of-scope one.
var uiRepo = Substitute.For<ICentralUiRepository>();
uiRepo.GetInstancesFilteredAsync(Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("I-001") { Id = 1, SiteId = 1 },
new("I-002") { Id = 2, SiteId = 1 },
new("I-003") { Id = 3, SiteId = 1 },
new("I-101") { Id = 101, SiteId = 2 },
new("I-102") { Id = 102, SiteId = 2 },
});
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(Take: 2), new[] { "1" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("I-001", response.JsonData);
Assert.Contains("I-002", response.JsonData);
Assert.DoesNotContain("I-003", response.JsonData); // paged out
Assert.DoesNotContain("I-101", response.JsonData); // out of scope
Assert.DoesNotContain("I-102", response.JsonData); // out of scope
}
// ========================================================================
// SetInstanceOverrides atomicity (finding -015)
//
// A multi-override command must be all-or-nothing: if any requested override
// is invalid (unknown/locked attribute), the handler must reject the whole
// command up front WITHOUT persisting any of the overrides.
// ========================================================================
[Fact]
public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()
{
// Instance 3, template 1 with a single valid attribute "Good".
var instance = new Instance("Pump3") { Id = 3, SiteId = 1, TemplateId = 1 };
_templateRepo.GetInstanceByIdAsync(3, Arg.Any<CancellationToken>()).Returns(instance);
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute> { new("Good") { Id = 1, TemplateId = 1 } });
_templateRepo.GetOverridesByInstanceIdAsync(3, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
var actor = CreateActor();
// "Good" is valid, "Bogus" is not — the whole command must fail with
// nothing written.
var overrides = new Dictionary<string, string?> { ["Good"] = "1", ["Bogus"] = "2" };
var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// No override write occurred for the valid attribute either.
_templateRepo.DidNotReceiveWithAnyArgs()
.AddInstanceAttributeOverrideAsync(default!, default);
_templateRepo.DidNotReceiveWithAnyArgs()
.UpdateInstanceAttributeOverrideAsync(default!, default);
}
[Fact]
public void SetInstanceOverrides_AllValid_PersistsAllOverrides()
{
var instance = new Instance("Pump4") { Id = 4, SiteId = 1, TemplateId = 1 };
_templateRepo.GetInstanceByIdAsync(4, Arg.Any<CancellationToken>()).Returns(instance);
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute>
{
new("A") { Id = 1, TemplateId = 1 },
new("B") { Id = 2, TemplateId = 1 }
});
_templateRepo.GetOverridesByInstanceIdAsync(4, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
var actor = CreateActor();
var overrides = new Dictionary<string, string?> { ["A"] = "1", ["B"] = "2" };
var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.ReceivedWithAnyArgs(2)
.AddInstanceAttributeOverrideAsync(default!, default);
}
// ========================================================================
// Unexpected exception messages not leaked to callers (finding -016)
//
// MapFault must distinguish handler-curated failures (safe to surface) from
// unanticipated faults (whose raw .Message can disclose internal detail).
// ========================================================================
[Fact]
public void UnexpectedFault_ReturnsGenericMessage_NotRawExceptionText()
{
// Repository throws an unanticipated fault carrying sensitive-looking
// detail. The raw text must NOT reach the caller.
const string secret = "Server=db-internal-prod;constraint FK_secret";
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidProgramException(secret));
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.DoesNotContain(secret, response.Error);
// The correlation ID is surfaced so the operator can find the server log.
Assert.Contains(envelope.CorrelationId, response.Error);
}
// ========================================================================
// UpdateSmtpConfig — TlsMode + Credentials plumbing (preserve-if-null)
// ========================================================================
[Fact]
public void UpdateSmtpConfig_WithTlsModeAndCredentials_PersistsThem()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "OAuth2", "old@example.com")
{
Id = 1,
Port = 25,
TlsMode = "StartTLS",
Credentials = "old-secret",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("SSL", existing.TlsMode);
Assert.Equal("user:pass", existing.Credentials);
Assert.Equal("new.example.com", existing.Host);
Assert.Equal("Basic", existing.AuthType);
}
[Fact]
public void UpdateSmtpConfig_WithNullTlsModeAndCredentials_PreservesExistingValues()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "OAuth2", "old@example.com")
{
Id = 1,
Port = 25,
TlsMode = "StartTLS",
Credentials = "old-secret",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// Omitted fields are preserved, not nulled.
Assert.Equal("StartTLS", existing.TlsMode);
Assert.Equal("old-secret", existing.Credentials);
// Provided fields are still updated.
Assert.Equal("new.example.com", existing.Host);
Assert.Equal("Basic", existing.AuthType);
}
[Fact]
public void UpdateSmtpConfig_AppliesOAuth2AuthorityAndScope()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "OAuth2", "old@example.com")
{
Id = 1,
Port = 25,
OAuth2Authority = "https://old.authority/token",
OAuth2Scope = "old.scope/.default",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "new.example.com", 465, "OAuth2", "new@example.com",
OAuth2Authority: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
OAuth2Scope: "https://outlook.office365.com/.default"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", existing.OAuth2Authority);
Assert.Equal("https://outlook.office365.com/.default", existing.OAuth2Scope);
}
[Fact]
public void UpdateSmtpConfig_WithNullOAuth2AuthorityAndScope_PreservesExistingValues()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "OAuth2", "old@example.com")
{
Id = 1,
Port = 25,
OAuth2Authority = "https://old.authority/token",
OAuth2Scope = "old.scope/.default",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "OAuth2", "new@example.com"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("https://old.authority/token", existing.OAuth2Authority);
Assert.Equal("old.scope/.default", existing.OAuth2Scope);
}
// ========================================================================
// UpdateSmtpConfig — Transport (EWS) write-path gate
// ========================================================================
[Fact]
public void UpdateSmtpConfig_WithEwsTransport_PersistsTransportAndSurfacesItInPublicShape()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
Credentials = "example\\notify:fake-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Ews", existing.Transport);
Assert.Equal("https://ews.example.test/ews/exchange.asmx", existing.Host);
// The transport is a non-secret field, so it belongs on the public shape
// the response and audit afterState carry.
Assert.Contains("\"transport\":\"Ews\"", response.JsonData, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void UpdateSmtpConfig_WithUnknownTransport_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://graph.example.test/", 443, "basic", "new@example.com",
Transport: "Graph"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unknown email transport", response.Error);
Assert.Contains("Graph", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndNonHttpsHost_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "http://ews.example.test/ews/exchange.asmx", 80, "basic", "new@example.com",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("https://", response.Error);
// The offending value is echoed so the operator sees what was rejected
// (matches the delivery adapter's message style).
Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndOAuth2Auth_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "oauth2", "new@example.com",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Basic authentication", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
Credentials = "example\\notify:fake-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// A supplied credential with no colon at all — the EWS sender cannot build a
// Basic header from it, so every send would park.
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
Credentials: "passwordwithoutcolon",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("username:password", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// A trailing colon splits into two parts, but the password half is empty —
// the adapter rejects it, so the write gate must too.
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
Credentials: "example\\notify:",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("username:password", response.Error);
}
[Fact]
public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"old.example.com", "Basic", "old@example.com")
{
Id = 1,
Port = 25,
Credentials = "bare-smtp-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// Credentials omitted entirely: the gate validates the EFFECTIVE state, so the
// preserved (unusable) stored value is what gets rejected.
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
Transport: "Ews"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("username:password", response.Error);
}
[Fact]
public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
{
Id = 1,
Port = 443,
Transport = "Ews",
Credentials = "example\\notify:fake-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// Transport omitted (a pre-EWS caller, or Newtonsoft deserializing a
// payload without the field): the stored transport survives, and the
// EWS shape rules are still enforced against that preserved value.
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews2.example.test/ews/exchange.asmx", 443, "basic", "new@example.com"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Ews", existing.Transport);
Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
}
[Theory]
[InlineData("Smtp")]
[InlineData("Ews")]
[InlineData("Graph")]
[InlineData("")]
[InlineData(null)]
public void UpdateSmtpConfig_TransportAcceptance_MatchesEmailTransportParser(string? transport)
{
// Parity pin: the write gate re-implements the transport parse inline (ManagementService
// does not reference NotificationService), so this asserts the mirror accepts exactly the
// values EmailTransportParser accepts. If the parser gains a transport, this goes red.
var parserAccepts = true;
try
{
NotificationService.EmailTransportParser.Parse(transport);
}
catch (ArgumentException)
{
parserAccepts = false;
}
var notifRepo = Substitute.For<INotificationRepository>();
// A fixture valid under EITHER transport, so the transport parse is the only variable.
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
{
Id = 1,
Port = 443,
Credentials = "example\\notify:fake-password",
};
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmtpConfigCommand(
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
Transport: transport),
"Administrator");
actor.Tell(envelope);
if (parserAccepts)
{
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
else
{
var error = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unknown email transport", error.Error);
}
}
// ========================================================================
// SMS Notifications (S5) — list Type discriminator + SMS-config management
// ========================================================================
[Fact]
public void CreateNotificationList_WithSmsType_PersistsSms()
{
var notifRepo = Substitute.For<INotificationRepository>();
Commons.Entities.Notifications.NotificationList? added = null;
notifRepo.When(r => r.AddNotificationListAsync(
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateNotificationListCommand("Ops SMS", new[] { "+15551230000" },
Commons.Types.Enums.NotificationType.Sms),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(added);
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
}
[Fact]
public void CreateNotificationList_WithSmsTypeAndPhones_PersistsForSmsRecipients()
{
// S6: an SMS list builds recipients from RecipientPhones via NotificationRecipient.ForSms
// — PhoneNumber set, EmailAddress null (the off-channel RecipientEmails list is ignored).
var notifRepo = Substitute.For<INotificationRepository>();
Commons.Entities.Notifications.NotificationList? added = null;
notifRepo.When(r => r.AddNotificationListAsync(
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateNotificationListCommand(
"Ops SMS",
Array.Empty<string>(),
Commons.Types.Enums.NotificationType.Sms,
new[] { "+15551230000", "+15551230001" }),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(added);
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
Assert.Equal(2, added.Recipients.Count);
Assert.All(added.Recipients, r =>
{
Assert.Null(r.EmailAddress);
Assert.NotNull(r.PhoneNumber);
});
Assert.Equal(new[] { "+15551230000", "+15551230001" },
added.Recipients.Select(r => r.PhoneNumber).ToArray());
}
[Fact]
public void CreateNotificationList_DefaultType_IsEmail()
{
// The Type parameter is a trailing default — existing callers that omit it
// continue to produce Email lists (additive, non-breaking).
var notifRepo = Substitute.For<INotificationRepository>();
Commons.Entities.Notifications.NotificationList? added = null;
notifRepo.When(r => r.AddNotificationListAsync(
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateNotificationListCommand("Ops Email", new[] { "ops@example.com" }),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.Equal(Commons.Types.Enums.NotificationType.Email, added!.Type);
}
[Fact]
public void UpdateNotificationList_WithSmsType_PersistsSms()
{
// Updating an existing SMS list (same type) must succeed and preserve the
// SMS channel — the immutability guard only fires when Type changes.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.NotificationList("Ops")
{
Id = 7,
Type = Commons.Types.Enums.NotificationType.Sms,
};
notifRepo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
notifRepo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateNotificationListCommand(7, "Ops", new[] { "+15551230000" },
Commons.Types.Enums.NotificationType.Sms),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, existing.Type);
}
[Fact]
public void UpdateNotificationList_ChangingType_IsRejected()
{
// Attempting to change an Email list's Type to Sms must return a
// COMMAND_FAILED error — the Type field is immutable after creation.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.NotificationList("Ops")
{
Id = 8,
Type = Commons.Types.Enums.NotificationType.Email,
};
notifRepo.GetNotificationListByIdAsync(8, Arg.Any<CancellationToken>()).Returns(existing);
notifRepo.GetRecipientsByListIdAsync(8, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateNotificationListCommand(8, "Ops", new[] { "+15551230000" },
Commons.Types.Enums.NotificationType.Sms),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Type cannot be changed", response.Error);
}
[Fact]
public void UpdateNotificationList_SameEmailType_Succeeds()
{
// Updating an Email list while keeping Type = Email must succeed — the
// immutability guard must not fire on same-type updates.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.NotificationList("Alerts")
{
Id = 9,
Type = Commons.Types.Enums.NotificationType.Email,
};
notifRepo.GetNotificationListByIdAsync(9, Arg.Any<CancellationToken>()).Returns(existing);
notifRepo.GetRecipientsByListIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateNotificationListCommand(9, "Alerts-Renamed", new[] { "ops@example.com" },
Commons.Types.Enums.NotificationType.Email),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("Alerts-Renamed", existing.Name);
Assert.Equal(Commons.Types.Enums.NotificationType.Email, existing.Type);
}
[Fact]
public void UpdateSmsConfig_WithApiBaseUrlAndAuthToken_PersistsThem()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
MessagingServiceSid = "MGold",
ApiBaseUrl = "https://old.example.com",
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", "MGnew",
"https://new.example.com", "new-secret"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
Assert.Equal("MGnew", existing.MessagingServiceSid);
Assert.Equal("https://new.example.com", existing.ApiBaseUrl);
Assert.Equal("new-secret", existing.AuthToken);
}
[Fact]
public void UpdateSmsConfig_WithNullAuthTokenAndApiBaseUrl_PreservesExistingValues()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
ApiBaseUrl = "https://old.example.com",
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// AuthToken + ApiBaseUrl omitted -> preserve-if-null.
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// Omitted secret/url are preserved, not nulled.
Assert.Equal("old-secret", existing.AuthToken);
Assert.Equal("https://old.example.com", existing.ApiBaseUrl);
// Provided fields are still updated.
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
}
[Fact]
public void UpdateSmsConfig_ResultAndAudit_NeverContainAuthToken()
{
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
object? auditedAfterState = null;
_auditService.When(a => a.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()))
.Do(ci => auditedAfterState = ci.ArgAt<object?>(5));
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "super-secret-token"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The secret is applied to the entity ...
Assert.Equal("super-secret-token", existing.AuthToken);
// ... but never leaves the boundary in the response payload ...
Assert.DoesNotContain("super-secret-token", response.JsonData);
// ... and the public shape signals presence only (camelCase serializer).
Assert.Contains("hasAuthToken", response.JsonData);
// ... nor in the audit afterState.
Assert.NotNull(auditedAfterState);
Assert.DoesNotContain("super-secret-token",
ManagementActor.SerializeResult(auditedAfterState));
}
[Fact]
public void ListSmsConfigs_ProjectsAwayAuthToken()
{
var notifRepo = Substitute.For<INotificationRepository>();
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration>
{
new("ACone", "+15550000000") { Id = 1, AuthToken = "leak-me" },
});
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
// Read-only -> any authenticated user (no special role required).
var envelope = Envelope(new ListSmsConfigsCommand());
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("leak-me", response.JsonData);
Assert.Contains("hasAuthToken", response.JsonData);
}
[Fact]
public void UpdateSmsConfig_WithViewerRole_ReturnsUnauthorized()
{
// MgmtSvc-021: mutating the SMS provider config rotates the Twilio secret and
// is Admin-only (the /notifications/sms page is RequireAdmin). A read-only role
// cannot reach it.
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
"Viewer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void UpdateSmsConfig_WithDesignerRole_ReturnsUnauthorized()
{
// MgmtSvc-021 regression: a Designer is blocked from the /notifications/sms UI
// (RequireAdmin), so the actor gate must reject them too — otherwise a Designer
// could rotate a production Twilio Auth Token via the CLI/Management API.
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "rotate-me"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void UpdateSmsConfig_WithEmptyAuthToken_PreservesExistingToken()
{
// MgmtSvc-021 regression: an explicit empty/whitespace AuthToken must be treated
// as "omitted" (preserve the stored secret), never as "clear it" — a Twilio token
// is always required, so clearing it would 401 every subsequent send.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: " "),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The blank token was ignored; the stored secret survives.
Assert.Equal("old-secret", existing.AuthToken);
// Non-secret fields still updated.
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
}
[Fact]
public void UpdateSmsConfig_MessagingServiceSidOnly_PersistsNullFromNumber()
{
// UI-Med-2: a Twilio Messaging-Service-only config has no From number. The handler
// must accept and persist a null FromNumber alongside a MessagingServiceSid.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", FromNumber: null, MessagingServiceSid: "MGonly"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Null(existing.FromNumber);
Assert.Equal("MGonly", existing.MessagingServiceSid);
}
[Fact]
public void UpdateSmsConfig_MalformedAccountSid_Rejected()
{
// Task 22: AccountSid is interpolated un-escaped into the Twilio request URI,
// so a malformed SID (e.g. a path-traversal / query-injection payload) must be
// rejected at save time, closing the URI-injection door before it can be persisted.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "../evil?x=", "+15551110000"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Account SID", response.Error);
// The malformed value must never have been persisted.
Assert.Equal("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", existing.AccountSid);
}
[Fact]
public void UpdateSmsConfig_ValidSid_Accepted()
{
// Task 22: a well-formed Twilio SID ('AC' + 32 hex chars) passes the guard.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var validSid = "AC" + new string('a', 32);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, validSid, "+15551110000"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(validSid, existing.AccountSid);
}
[Fact]
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
{
// A handler-thrown ManagementCommandException carries a message that is
// intentionally safe to surface (e.g. a validation result).
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Template?)null);
var actor = CreateActor();
var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// The curated InstanceService failure message is still surfaced verbatim.
Assert.Contains("99", response.Error);
}
// ========================================================================
// ManagementService-021: Transport (#24) bundle handler coverage
//
// The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
// HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
// tests. The cases below pin the load-bearing behaviours: role gating,
// ExportBundle name resolution, ImportBundle blocker rejection, and the
// ImportBundle (EntityType, Name) dedupe.
// ========================================================================
/// <summary>
/// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
/// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
/// service collection plus minimal repositories the bundle handlers query
/// from the export side. Returns both substitutes so a test can configure
/// their behaviour.
/// </summary>
private (Commons.Interfaces.Transport.IBundleExporter Exporter,
Commons.Interfaces.Transport.IBundleImporter Importer)
AddBundleSubstitutes()
{
var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
_services.AddSingleton(exporter);
_services.AddSingleton(importer);
// The repository fan-out HandleExportBundle does at the top of its body.
// Tests that only exercise role gating still need these resolved.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
// M8 (B4): export now also fans out to instances (template repo) and sites
// (site repo) for the site/instance-scoped selection.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
_services.AddScoped(_ => siteRepo);
var externalRepo = Substitute.For<IExternalSystemRepository>();
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
_services.AddScoped(_ => externalRepo);
var notifRepo = Substitute.For<INotificationRepository>();
notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationList>());
notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
_services.AddScoped(_ => notifRepo);
var inboundRepo = Substitute.For<IInboundApiRepository>();
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
_services.AddScoped(_ => inboundRepo);
return (exporter, importer);
}
private static ExportBundleCommand AllExportCommand() =>
new(All: true,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
[Fact]
public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
{
// ExportBundle requires the Design role; an Admin-only caller is rejected.
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(AllExportCommand(), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
// PreviewBundle requires the Admin role (Design role isn't enough,
// mirroring the Central UI gating — only Admin imports cross-cutting
// configuration).
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
[Fact]
public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
{
// ResolveIds throws ManagementCommandException for unknown names; that
// curated message must surface verbatim to the caller.
AddBundleSubstitutes();
// No templates in the repo; the export selection names one anyway.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: new[] { "DoesNotExist" },
SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
var actor = CreateActor();
var envelope = Envelope(cmd, "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// The ManagementCommandException message surfaces verbatim — it names
// the missing entity type and the missing name.
Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DoesNotExist", response.Error);
}
[Fact]
public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds()
{
// M8 (B4): --sites accepts SiteIdentifier (preferred) or Name; --instances
// accepts UniqueName. The handler resolves both to surrogate ids and passes
// them on the ExportSelection.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("NORTH-01.Pump2") { Id = 102, SiteId = 11, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1, 2, 3 })));
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env",
// mix the SiteIdentifier and the friendly Name to prove the dual-key match.
SiteNames: new[] { "NORTH-01", "East Plant" },
InstanceNames: new[] { "NORTH-01.Pump1" });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101 }, captured.InstanceIds);
}
[Fact]
public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance()
{
// M8 (B4): All=true ignores per-type name lists and includes every site +
// instance, mirroring how All already includes every template/etc.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("EAST-02.Pump9") { Id = 909, SiteId = 22, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1 })));
var actor = CreateActor();
actor.Tell(Envelope(AllExportCommand(), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101, 909 }, captured.InstanceIds.OrderBy(x => x));
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
{
// A ConflictKind.Blocker in the preview must abort the import — the
// handler throws ManagementCommandException before calling ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
var blockerItem = new Commons.Types.Transport.ImportPreviewItem(
EntityType: "Template",
Name: "BlockedTemplate",
ExistingVersion: null,
IncomingVersion: 1,
Kind: Commons.Types.Transport.ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: "FK to missing site");
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[] { blockerItem }));
// A non-empty base64 payload that decodes — the handler does its own
// base64 check before reaching the importer.
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("BlockedTemplate", response.Error);
// Apply must NOT have been called — the handler aborts before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
{
// The handler dedupes by (EntityType, Name) before calling ApplyAsync —
// last-write-wins, matching the Central UI's TransportImport behavior.
// The preview here emits THREE rows for the same (Template, "Dup"):
// an Identical then a Modified then an Identical. After dedupe, only
// one resolution must reach the importer for that key.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
Commons.Types.Transport.ImportPreviewItem Row(
Commons.Types.Transport.ConflictKind kind) =>
new("Template", "Dup", ExistingVersion: 1, IncomingVersion: 2,
Kind: kind, FieldDiffJson: null, BlockerReason: null);
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[]
{
Row(Commons.Types.Transport.ConflictKind.Identical),
Row(Commons.Types.Transport.ConflictKind.Modified),
Row(Commons.Types.Transport.ConflictKind.Identical),
}));
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
// M8 (D3): the handler now calls the 5-arg ApplyAsync overload (trailing
// nameMap:), so the arrange must include the BundleNameMap arg matcher or
// NSubstitute treats it as an unmatched call and never runs the capture.
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
r => captured = r),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>())
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
// "overwrite" policy so the final (Identical) row would otherwise differ
// from the Modified row's action — proves the last-write-wins semantics.
var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Administrator");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
// Only ONE resolution survives for the (Template, "Dup") key.
var dupResolutions = captured!
.Where(r => r.EntityType == "Template" && r.Name == "Dup")
.ToList();
Assert.Single(dupResolutions);
// Last-write-wins: the final Identical row's Skip action overrides the
// earlier Modified row's Overwrite action.
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
}
// ------------------------------------------------------------------------
// M8 (D3): import name-map plumbing — PreviewBundleResult carries the
// required mappings; HandleImportBundle merges explicit specs + auto-match +
// create-missing flags into the BundleNameMap passed to ApplyAsync.
// ------------------------------------------------------------------------
/// <summary>
/// Configures the substituted importer with a Load + Preview returning the
/// supplied required mappings (and no conflicting artifact rows), and a
/// capturing ApplyAsync. Returns the session id and a getter for the captured
/// <see cref="Commons.Types.Transport.BundleNameMap"/>.
/// </summary>
private static (Guid SessionId, Func<Commons.Types.Transport.BundleNameMap?> CapturedMap) ConfigureImportForMapping(
Commons.Interfaces.Transport.IBundleImporter importer,
IReadOnlyList<Commons.Types.Transport.RequiredSiteMapping> siteMappings,
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
{
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
siteMappings,
connectionMappings));
Commons.Types.Transport.BundleNameMap? captured = null;
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Do<Commons.Types.Transport.BundleNameMap?>(m => captured = m))
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
return (sessionId, () => captured);
}
[Fact]
public void ImportBundleCommand_ExplicitSpec_OverridesAutoMatch()
{
// An explicit --map-site spec (target present → MapToExisting) wins over
// the preview's auto-match suggestion.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", "PLANT-A") });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal("NORTH-01", site.SourceSiteIdentifier);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("PLANT-A", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoSpec_FallsBackToAutoMatch()
{
// With no explicit spec, the preview's auto-match becomes a MapToExisting.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("AUTO-MATCH", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoAutoMatchWithCreateMissing_CreatesNew()
{
// No spec, no auto-match, but --create-missing-sites → CreateNew.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite", CreateMissingSites: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
Assert.Null(site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_ExplicitCreateNewSpec_CreatesNew()
{
// An explicit spec with a null target means CreateNew even without the
// create-missing flag.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", TargetSiteIdentifier: null) });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
}
[Fact]
public void ImportBundleCommand_UnresolvedWithoutCreateMissing_Fails()
{
// No spec, no auto-match, no create-missing flag → unresolved → fail
// before ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("unresolved", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("NORTH-01", response.Error);
// Apply must NOT have been called — the merge fails before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>());
}
[Fact]
public void ImportBundleCommand_ConnectionSpecAndCreateMissing_BuildMap()
{
// Connection-side mirror: an explicit spec maps to existing, and an
// unmatched second connection is created via --create-missing-connections.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
Array.Empty<Commons.Types.Transport.RequiredSiteMapping>(),
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcB", AutoMatchTargetName: null),
});
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
ConnectionMappings: new[] { new ConnectionMappingSpec("NORTH-01", "OpcA", "OpcLive") },
CreateMissingConnections: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
Assert.Equal(2, map!.Connections.Count);
var opcA = map.Connections.Single(c => c.SourceConnectionName == "OpcA");
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, opcA.Action);
Assert.Equal("OpcLive", opcA.TargetConnectionName);
var opcB = map.Connections.Single(c => c.SourceConnectionName == "OpcB");
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, opcB.Action);
Assert.Null(opcB.TargetConnectionName);
}
[Fact]
public void PreviewBundleCommand_SurfacesRequiredMappings()
{
// HandlePreviewBundle must echo the preview's required mappings on the
// PreviewBundleResult so the CLI/UI can show what needs resolving.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO"),
},
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
}));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
var success = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The result is camelCase JSON (ManagementActor.SerializeResult). The new
// PreviewBundleResult fields must surface the preview's required mappings.
Assert.Contains("requiredSiteMappings", success.JsonData);
Assert.Contains("requiredConnectionMappings", success.JsonData);
Assert.Contains("NORTH-01", success.JsonData);
Assert.Contains("North Plant", success.JsonData);
Assert.Contains("AUTO", success.JsonData);
Assert.Contains("OpcA", success.JsonData);
}
[Fact]
public void PreviewBundleCommand_ReleasesTheSession_AndForwardsTheCallersIp()
{
// Register row 43: a preview never applies, so nothing else releases the
// session — it used to hold one of MaxConcurrentImportSessions for the full
// TTL. Register row 44: the envelope's client IP must reach LoadAsync so the
// per-IP-per-hour unlock throttle can attribute the attempt.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
var envelope = Envelope(new PreviewBundleCommand(payload, null), "Administrator")
with { ClientIp = "198.51.100.9" };
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
importer.Received(1).LoadAsync(
Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), "198.51.100.9");
importer.Received(1).Abandon(sessionId);
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_StillReleasesTheSession()
{
// Register row 43: ApplyAsync releases the session, but this import aborts
// BEFORE apply — the handler must release it anyway.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[]
{
new Commons.Types.Transport.ImportPreviewItem(
EntityType: "Template",
Name: "BlockedTemplate",
ExistingVersion: null,
IncomingVersion: 1,
Kind: Commons.Types.Transport.ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: "FK to missing site"),
}));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
importer.Received(1).Abandon(sessionId);
}
[Fact]
public void ImportBundleCommand_ThrottledUnlock_SurfacesTheCuratedReason()
{
// Register row 44: a per-IP throttle denial must fail closed with a clear
// message, not MapFault's generic "an internal error occurred".
var (_, importer) = AddBundleSubstitutes();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns<Commons.Types.Transport.BundleSession>(_ =>
throw new Commons.Types.Transport.BundleUnlockThrottledException("198.51.100.9", 10));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new ImportBundleCommand(payload, "pass", "skip"), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Too many bundle unlock attempts", response.Error);
}
[Fact]
public void ImportBundleCommand_LockedBundle_SurfacesTheCuratedReason()
{
// Register row 51: the per-bundle 3-strike lockout used to reach the CLI as
// MapFault's generic "An internal error occurred (CorrelationId=...)" because
// BundleLockedException lived in Transport, which ManagementService does not
// reference. It now lives in Commons alongside BundleUnlockThrottledException
// and is curated the same way.
var (_, importer) = AddBundleSubstitutes();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns<Commons.Types.Transport.BundleSession>(_ =>
throw new Commons.Types.Transport.BundleLockedException("abc123", 3));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new ImportBundleCommand(payload, "wrong", "skip"), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("locked after 3 failed unlock attempts", response.Error);
Assert.DoesNotContain("An internal error occurred", response.Error);
}
[Fact]
public void PreviewBundleCommand_LockedBundle_SurfacesTheCuratedReason()
{
// Register row 51: preview shares the same LoadAsync fault surface as import,
// so it needs the same curated arm.
var (_, importer) = AddBundleSubstitutes();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns<Commons.Types.Transport.BundleSession>(_ =>
throw new Commons.Types.Transport.BundleLockedException("abc123", 3));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, "wrong"), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("locked after 3 failed unlock attempts", response.Error);
Assert.DoesNotContain("An internal error occurred", response.Error);
}
// ========================================================================
// Native alarm source CRUD (Task 21)
// ========================================================================
[Fact]
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
{
// Native-source add now propagates inherited rows to derived descendants,
// so the handler resolves TemplateService (no-op here: the substitute repo's
// GetAllTemplatesAsync yields no descendants).
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.ReceivedWithAnyArgs(1).AddTemplateNativeAlarmSourceAsync(default!, default);
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
[Fact]
public void AddTemplateNativeAlarmSource_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void ListTemplateNativeAlarmSources_ReturnsData()
{
_templateRepo.GetNativeAlarmSourcesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateNativeAlarmSource>
{
new("Pressure") { Id = 1, TemplateId = 1, ConnectionName = "Opc", SourceReference = "ns=2;s=T01" }
});
var actor = CreateActor();
var envelope = Envelope(new ListTemplateNativeAlarmSourcesCommand(1));
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("Pressure", response.JsonData);
}
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
{
// No prior override → Add path. The source resolves and is UNLOCKED, so the
// lock guard (#05-T13) lets the override through.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
"Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.ReceivedWithAnyArgs(1).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployer", response.Message);
}
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_BulkHappyPath_AddsAllAndSavesOnce()
{
// #12: bulk CSV apply. Both sources resolve and are unlocked → each is added,
// and the whole batch commits with a SINGLE SaveChanges (all-or-nothing txn).
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
new() { CanonicalName = "Temp", IsLocked = false, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", "ns=2;s=P", null),
new("Temp", null, "ns=2;s=T", "Active")
}),
"Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.ReceivedWithAnyArgs(2).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
// All-or-nothing: exactly one commit for the whole batch.
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()
{
// #12: all-or-nothing. If ANY entry targets a locked source, the whole batch
// is rejected before any write — no Add, no SaveChanges.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
new() { CanonicalName = "Locked", IsLocked = true, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null),
new("Locked", "Opc2", null, null)
}),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
_templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
}
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_UnresolvedSource_RejectsWholeBatch()
{
// #12: a source that does not resolve for the instance is rejected up front.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null),
new("Ghost", "Opc2", null, null)
}),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("does not resolve", response.Error, StringComparison.OrdinalIgnoreCase);
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
_templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
}
// ========================================================================
// MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move
// ========================================================================
[Fact]
public void MoveDataConnection_WithViewerRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Viewer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
[Fact]
public void MoveDataConnection_HappyPath_MovesSiteIdAndAudits()
{
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
// No name collision at the target site.
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
// No bindings reference the connection -> move allowed.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
// No native-alarm-source overrides on the source site (purpose-built load).
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// SiteId is mutated to the target.
Assert.Equal(2, conn.SiteId);
siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
Arg.Any<CancellationToken>());
_auditService.Received(1).LogAsync(
"testuser", "Move", "DataConnection", "5", "Opc1",
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_TargetSiteMissing_ReturnsError()
{
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 99), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("99", response.Error);
// SiteId unchanged; no write occurred.
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_NameCollisionAtTarget_ReturnsError()
{
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
var collidingConn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 2) { Id = 9 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection> { collidingConn });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Opc1", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByBinding_ReturnsErrorNamingInstances()
{
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
var blockingInstance = new Instance("Tank01") { Id = 7, SiteId = 1 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
// A binding references the connection -> move must be rejected.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { blockingInstance });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the blocking instance.
Assert.Contains("Tank01", response.Error);
// SiteId unchanged; no write occurred.
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_SameSite_ReturnsErrorNoWrite()
{
// Moving a connection to the site it already belongs to is a no-op and
// must be rejected before any write — and before the existence/collision
// guards even run.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("already belongs", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError()
{
// A template instantiated on the SOURCE site has a native-alarm-source
// whose ConnectionName matches the moving connection -> blocked, because
// the deploying-site flatten would no longer resolve that name.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
// Source-site instance referencing template 42.
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
var template = new Template("PumpTemplate") { Id = 42 };
template.NativeAlarmSources.Add(
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
// Source-site instances (purpose-built load with overrides eagerly loaded).
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
_services.AddScoped(_ => siteRepo);
// Only template 42 (instantiated on the source site) is scanned.
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(template);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the template and its native-alarm-source.
Assert.Contains("PumpTemplate", response.Error);
Assert.Contains("PumpFaults", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError()
{
// A source-site instance has a native-alarm-source override whose
// ConnectionNameOverride matches the moving connection -> blocked.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
sourceInstance.NativeAlarmSourceOverrides.Add(
new InstanceNativeAlarmSourceOverride("PumpFaults")
{ Id = 1, InstanceId = 7, ConnectionNameOverride = "Opc1" });
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
_services.AddScoped(_ => siteRepo);
// Template 42 has no matching native-alarm-source; the override is the blocker.
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new Template("PumpTemplate") { Id = 42 });
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the instance and its override.
Assert.Contains("Tank01", response.Error);
Assert.Contains("PumpFaults", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock()
{
// Regression for the over-blocking false positive: a template references
// the connection NAME ("Opc1") but is instantiated ONLY on another site.
// Templates are site-agnostic and ConnectionName resolves against the
// DEPLOYING site's pool, so moving site 1's "Opc1" does not break the
// other site's deployment. The move must succeed.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
// The SOURCE site (1) has an instance using template 99, which does NOT
// reference "Opc1". A different template (42) referencing "Opc1" exists
// but is instantiated only on the target/other site, so it is never loaded.
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 99 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => siteRepo);
// Template 99 (the one actually on the source site) does not reference "Opc1".
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
.Returns(new Template("OtherTemplate") { Id = 99 });
// Template 42 references "Opc1" but lives only on another site: if the
// handler scoped its scan correctly it must NEVER load template 42. We
// still stub it to prove the cross-site reference is irrelevant.
var crossSiteTemplate = new Template("PumpTemplate") { Id = 42 };
crossSiteTemplate.NativeAlarmSources.Add(
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(crossSiteTemplate);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
// Move SUCCEEDS: the cross-site template reference must not block.
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(2, conn.SiteId);
siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
Arg.Any<CancellationToken>());
// The off-site template (42) must never be scanned.
_templateRepo.DidNotReceive().GetTemplateByIdAsync(42, Arg.Any<CancellationToken>());
}
// ========================================================================
// #259: design-time ValidateTemplateCommand passes sharedScripts so a
// dangling $ref in a SHARED script's schema is caught at design time, not
// only on the deploy path.
// ========================================================================
[Fact]
public void ValidateTemplate_DanglingRefInSharedScript_ReturnsInvalidResult()
{
// Arrange
var template = new Template("T1") { Id = 1 };
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
.Returns(template);
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute>());
_templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAlarm>());
_templateRepo.GetScriptsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateScript>());
// Shared script whose parameter schema contains a dangling $ref.
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<SharedScript>
{
new SharedScript("ComputeOrder", "var x = 1;")
{
ParameterDefinitions = """{"$ref":"lib:MissingSharedSchema"}"""
}
});
// Schema library is empty → the $ref cannot be resolved and dangles.
var schemaRepo = Substitute.For<ISharedSchemaRepository>();
schemaRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(new List<SharedSchema>());
_services.AddScoped(_ => schemaRepo);
// TemplateService is required by the collision-detection step inside HandleValidateTemplate.
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(new ValidateTemplateCommand(1), "Designer");
// Act
actor.Tell(envelope);
// Assert: the handler returns ManagementSuccess whose JSON payload signals an invalid
// ValidationResult with a SchemaReference error naming the missing library entry.
var msg = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(10));
Assert.Equal(envelope.CorrelationId, msg.CorrelationId);
// The ValidationResult is JSON-serialised into JsonData; check key fields in the raw JSON
// (consistent with how other ManagementActorTests verify structured payloads).
// ValidationCategory is serialised as an integer; SchemaReference is ordinal 15.
Assert.Contains("\"isValid\":false", msg.JsonData, StringComparison.Ordinal);
Assert.Contains("MissingSharedSchema", msg.JsonData, StringComparison.Ordinal);
Assert.Contains("\"category\":15", msg.JsonData, StringComparison.Ordinal);
}
// ========================================================================
// PLAN-06 Task 11: per-system ESG TimeoutSeconds management commands
// ========================================================================
[Fact]
public void CreateExternalSystem_PersistsTimeoutSeconds()
{
var extRepo = Substitute.For<IExternalSystemRepository>();
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateExternalSystemCommand("Sys", "http://x", "none", null, TimeoutSeconds: 15),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
extRepo.Received(1).AddExternalSystemAsync(
Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(d => d.TimeoutSeconds == 15),
Arg.Any<CancellationToken>());
}
[Fact]
public void UpdateExternalSystem_NullTimeout_PreservesExisting()
{
var existing = new Commons.Entities.ExternalSystems.ExternalSystemDefinition("Sys", "http://x", "none")
{
Id = 7,
TimeoutSeconds = 42
};
var extRepo = Substitute.For<IExternalSystemRepository>();
extRepo.GetExternalSystemByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
// TimeoutSeconds omitted (null) => existing 42 must be preserved.
var envelope = Envelope(
new UpdateExternalSystemCommand(7, "Sys", "http://x", "none", null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(42, existing.TimeoutSeconds);
extRepo.Received(1).UpdateExternalSystemAsync(
Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(d => d.TimeoutSeconds == 42),
Arg.Any<CancellationToken>());
}
/// <summary>
/// Deferred-work row 27(b): the retry policy had no management surface at all — the only
/// way to set it was a direct DB edit of <c>ExternalSystemDefinitions</c>. Create must now
/// map both fields onto the entity.
/// </summary>
[Fact]
public void CreateExternalSystem_MapsRetryPolicy()
{
var extRepo = Substitute.For<IExternalSystemRepository>();
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(
new CreateExternalSystemCommand("Sys", "http://x", "none", null,
TimeoutSeconds: 15, MaxRetries: 8, RetryDelay: TimeSpan.FromSeconds(60)),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
extRepo.Received(1).AddExternalSystemAsync(
Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(
d => d.MaxRetries == 8 && d.RetryDelay == TimeSpan.FromSeconds(60)),
Arg.Any<CancellationToken>());
}
/// <summary>
/// The retry policy follows the same "omit to preserve" rule as TimeoutSeconds, so a
/// partial update (e.g. renaming a system) cannot silently reset it to 0.
/// </summary>
[Fact]
public void UpdateExternalSystem_NullRetryPolicy_PreservesExisting()
{
var existing = new Commons.Entities.ExternalSystems.ExternalSystemDefinition("Sys", "http://x", "none")
{
MaxRetries = 11,
RetryDelay = TimeSpan.FromSeconds(33)
};
var extRepo = Substitute.For<IExternalSystemRepository>();
extRepo.GetExternalSystemByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
_services.AddScoped(_ => extRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateExternalSystemCommand(7, "Renamed", "http://x", "none", null),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(11, existing.MaxRetries);
Assert.Equal(TimeSpan.FromSeconds(33), existing.RetryDelay);
}
// ========================================================================
// Area management role gate (arch-review C6): reconciled to any-of
// [Designer, Deployer]. The Central UI creates areas inside the Deployment
// Topology workflow (RequireDeployment), so a pure-Deployer must be allowed.
// ========================================================================
[Theory]
[InlineData("Designer")]
[InlineData("Deployer")]
public void CreateArea_DesignerOrDeployer_Allowed(string role)
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), role));
// The gate must let both roles through. Dispatch may still fail (no
// AreaService wired), but it must NOT be an authorization rejection.
var resp = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(resp);
}
[Fact]
public void CreateArea_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
// ========================================================================
// MoveArea (CLI/management parity follow-up from the deployment-topology
// plan): re-parenting is the same structural authoring act as create /
// update / delete, so it carries the identical any-of [Designer, Deployer]
// gate, and every validation rule is delegated to AreaService.MoveAreaAsync.
// ========================================================================
[Theory]
[InlineData("Designer")]
[InlineData("Deployer")]
public void MoveArea_DesignerOrDeployer_Allowed(string role)
{
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), role));
var resp = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(resp);
}
[Fact]
public void MoveArea_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void MoveArea_ToNewParent_ReturnsSuccessAndReparentsArea()
{
_services.AddScoped<AreaService>();
var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 };
var target = new Area("Building2") { Id = 2, SiteId = 1 };
_templateRepo.GetAreaByIdAsync(3, Arg.Any<CancellationToken>()).Returns(moving);
_templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(target);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { new("Building1") { Id = 1, SiteId = 1 }, target, moving });
var actor = CreateActor();
var envelope = Envelope(new MoveAreaCommand(3, 2), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(2, moving.ParentAreaId);
}
[Fact]
public void MoveArea_WithNullParent_MovesToSiteRoot()
{
_services.AddScoped<AreaService>();
var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 };
_templateRepo.GetAreaByIdAsync(3, Arg.Any<CancellationToken>()).Returns(moving);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { new("Building1") { Id = 1, SiteId = 1 }, moving });
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(3, null), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Null(moving.ParentAreaId);
}
[Fact]
public void MoveArea_UnderOwnDescendant_ReturnsError()
{
_services.AddScoped<AreaService>();
var parent = new Area("Building1") { Id = 1, SiteId = 1 };
var child = new Area("Line1") { Id = 2, SiteId = 1, ParentAreaId = 1 };
_templateRepo.GetAreaByIdAsync(1, Arg.Any<CancellationToken>()).Returns(parent);
_templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(child);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { parent, child });
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Designer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("descendants", response.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MoveArea_AreaNotFound_ReturnsError()
{
_services.AddScoped<AreaService>();
_templateRepo.GetAreaByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Area?)null);
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(99, null), "Designer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("99", response.Error);
}
// ========================================================================
// Remote-query actor dispatch for the OPC UA design-time commands
// (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain
// actor handlers so the management API/CLI reaches parity with the UI.
// SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated.
// ========================================================================
[Fact]
public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void BrowseNode_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.BrowseCount);
Assert.Equal("SITE1", stub.LastBrowseSite);
}
[Fact]
public void BrowseNode_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void SearchAddressSpace_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.SearchCount);
Assert.Equal("SITE1", stub.LastSearchSite);
}
[Fact]
public void SearchAddressSpace_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}"), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void VerifyEndpoint_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.VerifyCount);
Assert.Equal("SITE1", stub.LastVerifySite);
}
[Fact]
public void VerifyEndpoint_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
// ========================================================================
// Admin-gated actor dispatch for the site cert-trust commands
// (arch-review C4): TrustServerCert / RemoveServerCert / ListServerCerts.
// These mutate the site's trusted-peer PKI store, so they are gated
// Administrator — matching the Admin-gated cert-management UI. SiteIdentifier
// is REQUIRED; the handler only relays (site-side reconcile is CertStoreActor).
// ========================================================================
[Fact]
public void TrustServerCert_DesignerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void TrustServerCert_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12"), "Administrator"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void TrustServerCert_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.TrustCount);
Assert.Equal("SITE1", stub.LastTrustSite);
}
[Fact]
public void RemoveServerCert_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.RemoveCount);
Assert.Equal("SITE1", stub.LastRemoveSite);
}
[Fact]
public void RemoveServerCert_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12"), "Administrator"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void ListServerCerts_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.ListCertsCount);
Assert.Equal("SITE1", stub.LastListCertsSite);
}
[Fact]
public void ListServerCerts_DesignerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
// ── R2 T13: site delete disposes the site's cached gRPC channels (N8) ──
[Fact]
public void DeleteSite_DisposesTheSitesCachedGrpcChannels()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new Site("Seven", "site-7") { Id = 7 });
siteRepo.GetInstancesBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<Instance>)new List<Instance>());
_services.AddScoped(_ => siteRepo);
var factory = new TrackingGrpcFactory();
var cached = (TrackingClient)factory.GetOrCreate("site-7", "http://node-a:8083");
_services.AddSingleton<SiteStreamGrpcClientFactory>(factory);
var actor = CreateActor();
actor.Tell(Envelope(new DeleteSiteCommand(7), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
AwaitAssert(() =>
{
Assert.True(cached.Disposed);
Assert.Null(factory.TryGet("site-7", "http://node-a:8083"));
}, TimeSpan.FromSeconds(5));
}
private sealed class TrackingClient : SiteStreamGrpcClient
{
public TrackingClient(string endpoint) : base(endpoint) { }
public bool Disposed { get; private set; }
public override void Dispose() => Disposed = true;
public override ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory
{
public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { }
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
=> new TrackingClient(grpcEndpoint);
}
/// <summary>
/// Records remote-query relays for the actor-dispatch tests. The browse /
/// search / verify / cert-trust methods on <see cref="CommunicationService"/>
/// are virtual so the relay can be exercised without a live actor system.
/// </summary>
private sealed class StubCommunicationService : CommunicationService
{
public StubCommunicationService()
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
{
}
public int BrowseCount { get; private set; }
public string? LastBrowseSite { get; private set; }
public int SearchCount { get; private set; }
public string? LastSearchSite { get; private set; }
public int VerifyCount { get; private set; }
public string? LastVerifySite { get; private set; }
public int TrustCount { get; private set; }
public string? LastTrustSite { get; private set; }
public int RemoveCount { get; private set; }
public string? LastRemoveSite { get; private set; }
public int ListCertsCount { get; private set; }
public string? LastListCertsSite { get; private set; }
public override Task<CertTrustResult> TrustServerCertAsync(
string siteId, TrustServerCertCommand command, CancellationToken ct = default)
{
TrustCount++;
LastTrustSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<CertTrustResult> RemoveServerCertAsync(
string siteId, RemoveServerCertCommand command, CancellationToken ct = default)
{
RemoveCount++;
LastRemoveSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<CertTrustResult> ListServerCertsAsync(
string siteId, ListServerCertsCommand command, CancellationToken ct = default)
{
ListCertsCount++;
LastListCertsSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<BrowseNodeResult> BrowseNodeAsync(
string siteId, BrowseNodeCommand command, CancellationToken ct = default)
{
BrowseCount++;
LastBrowseSite = siteId;
return Task.FromResult(new BrowseNodeResult(Array.Empty<BrowseNode>(), false, null));
}
public override Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default)
{
SearchCount++;
LastSearchSite = siteId;
return Task.FromResult(new SearchAddressSpaceResult(Array.Empty<AddressSpaceMatch>(), false, null));
}
public override Task<VerifyEndpointResult> VerifyEndpointAsync(
string siteId, VerifyEndpointCommand command, CancellationToken ct = default)
{
VerifyCount++;
LastVerifySite = siteId;
return Task.FromResult(new VerifyEndpointResult(true, null, null, null));
}
}
}