Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
T
Joseph Doherty 205a06a0f1 feat(transport): optional detached bundle signing with a pluggable key source (ScadaBridge#45)
Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.

- The bundle format does not change: bundleFormatVersion stays 1,
  manifest.json and the AES-GCM associated data are untouched, and older
  importers open entries by name and ignore manifest.sig. Three entries fit
  within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
  work. A signature that fails to verify against a trusted key is always
  refused. RequireSignedBundles additionally refuses unsigned bundles and
  bundles signed by an untrusted key. A refusal is BundleSignatureException
  (Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
  shown in the import wizard. They are also returned on PreviewBundleResult
  and recorded on the BundleImported audit row. BundleExported records
  Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
  are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
  ${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
  fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
  pre-signing-encrypted were exported by origin/main 3ff463b6, before this
  change, and are imported end to end with signing off and with signing
  configured.

The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:42:28 -05:00

6061 lines
277 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class ManagementActorTests : TestKit, IDisposable
{
private readonly ITemplateEngineRepository _templateRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
private ArtifactDeploymentService _artifactService = null!;
/// <summary>Initializes a new instance of the <c>ManagementActorTests</c> class.</summary>
public ManagementActorTests()
{
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_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>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance,
new ArtifactRevisionHashService(),
new SiteSecretsCollector(Substitute.For<ZB.MOM.WW.Secrets.Abstractions.ISecretStore>()));
_services.AddScoped(_ => _artifactService);
}
/// <summary>
/// Registers a REAL <see cref="DeploymentService"/> over substituted repositories
/// (its methods are not virtual, so it cannot be substituted itself — the same
/// approach the CentralUI bUnit tests take). Area lookups go through the
/// fixture's shared <see cref="_templateRepo"/>.
/// </summary>
private (IDeploymentManagerRepository DeployRepo, IFlatteningPipeline Pipeline, ISiteRepository SiteRepo) RegisterDeploymentService()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
var pipeline = Substitute.For<IFlatteningPipeline>();
var siteRepo = Substitute.For<ISiteRepository>();
pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
var service = new DeploymentService(
deployRepo, siteRepo, _templateRepo, pipeline,
new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance),
new OperationLockManager(), _auditService,
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService(),
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
Options.Create(new CommunicationOptions()),
NullLogger<DeploymentService>.Instance);
_services.AddScoped(_ => service);
return (deployRepo, pipeline, siteRepo);
}
private static Result<ArtifactDeploymentSummary> DeploySummary() =>
Result<ArtifactDeploymentSummary>.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0));
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
}
// ========================================================================
// 1. Authorization test -- admin command with wrong role
// ========================================================================
/// <summary>Verifies that create site command, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that create site command, when with no roles, returns unauthorized.</summary>
[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).
// ========================================================================
/// <summary>Verifies that list secured writes, when viewer only, unauthorized.</summary>
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that list secured writes, when secured write roles, allowed.</summary>
/// <param name="role">The role.</param>
[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));
}
/// <summary>Verifies that deployment command, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that query audit log command, when with no roles, returns unauthorized.</summary>
[Fact]
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
{
// QueryAuditLogCommand used to fall through to the
// default "any authenticated user" case, allowing a Deployment-only or
// no-role caller to read the configuration audit log via /management
// even though /api/audit/query enforces OperationalAuditRoles. The fix
// gates this legacy command to Admin so the older route is never looser
// than the new REST endpoint.
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Administrator", response.Message);
}
/// <summary>Verifies that query audit log command, when with deployment role, returns unauthorized.</summary>
[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
// ========================================================================
/// <summary>Verifies that list templates command, when with no roles, returns success.</summary>
[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);
}
/// <summary>Verifies that list sites command, when with design role, returns success.</summary>
[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
// ========================================================================
/// <summary>Verifies that list templates command: returns template data.</summary>
[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
// ========================================================================
/// <summary>Verifies that create instance command, when with deployment role, returns success.</summary>
[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);
}
/// <summary>Verifies that set native alarm source override, when on locked source, returns management error.</summary>
[Fact]
public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
{
// The ManagementActor must reject an override targeting a source
// that is locked at the template level, before persisting anything.
var pipeline = Substitute.For<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
// ========================================================================
/// <summary>Verifies that list templates command, when repo throws, returns management error.</summary>
[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; a generic message is returned.
Assert.DoesNotContain("Database connection lost", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
/// <summary>Verifies that create instance command, when service fails, returns management error.</summary>
[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
// ========================================================================
/// <summary>Verifies that design command, when with admin role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that admin command, when with admin role, succeeds.</summary>
[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);
}
/// <summary>Verifies that role check: is case insensitive.</summary>
[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
// ========================================================================
/// <summary>Verifies that shared script create, when with admin role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that database connection create, when with deployment role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that api method create, when with admin role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that update api method, when non compiling script, succeeds but returns compile warning.</summary>
[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);
}
/// <summary>Verifies that add template attribute, when with deployment role, returns unauthorized.</summary>
[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);
}
// ========================================================================
// ElementDataType accept + validate on attribute add/update
// ========================================================================
/// <summary>Verifies that add list attribute, when with string element type, persists both columns.</summary>
[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);
}
/// <summary>Verifies that add list attribute, when with no element type, returns management error.</summary>
[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);
}
/// <summary>Verifies that add list attribute, when with binary element type, returns management error.</summary>
[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);
}
/// <summary>Verifies that add scalar attribute, when with element type, returns management error.</summary>
[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);
}
/// <summary>Verifies that add list attribute, when with malformed default, returns management error.</summary>
[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);
}
/// <summary>Verifies that add list attribute, when with type mismatched default, returns management error.</summary>
[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);
}
/// <summary>Verifies that update scalar attribute, when with element type, returns management error.</summary>
[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);
}
/// <summary>Verifies that add attribute, when with unrecognised data type, returns curated management error.</summary>
[Fact]
public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError()
{
// A bogus DataType string used to throw ArgumentException
// from Enum.Parse, which MapFault masks as a generic "An internal error
// occurred" message. TryParse now raises a curated ManagementCommandException
// whose message names the offending token.
_services.AddScoped<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);
}
// ========================================================================
// Strict single-value enum parsing in
// ParseDataType / ParseElementDataType / the AlarmTriggerType parse sites.
// Raw Enum.Parse/TryParse admits ordinals ("2"), undefined values ("99" —
// a deploy-time bomb that saves fine and only detonates at deploy), and
// (Medium-wave T8 nuance) comma-joined name lists that TryParse/Parse
// bitwise-OR together regardless of [Flags]. ParseEnumStrict rejects all
// three, and — for AlarmTriggerType specifically — replaces a masked
// "An internal error occurred" (raw Enum.Parse's uncaught
// ArgumentException) with a curated, field-naming error.
// ========================================================================
/// <summary>Verifies that add attribute, when with non name data type token, returns curated error.</summary>
/// <param name="token">The token.</param>
[Theory]
[InlineData("2")] // ordinal for a DEFINED member (Float)
[InlineData("99")] // undefined ordinal — the deploy-time-bomb case
[InlineData("String,Int32")] // comma-joined names — TryParse ORs 4|1 into DateTime (5)
public void AddAttribute_WithNonNameDataTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): the unguarded `Enum.TryParse` in the old
// ParseDataType has no numeric guard, no comma guard, and never checks
// Enum.IsDefined. Each of these tokens parsed successfully pre-fix (to
// Float, an undefined value, and DateTime respectively) and the command
// proceeded past the parser into TemplateService, failing later with
// "Template with ID 1 not found." instead of this curated message —
// verified by stashing this fix and re-running (see task report).
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", token, 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(token, response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.DoesNotContain("internal error", response.Error);
}
/// <summary>Verifies that add attribute, when with typo data type, returns curated error naming valid values.</summary>
[Fact]
public void AddAttribute_WithTypoDataType_ReturnsCuratedErrorNamingValidValues()
{
// The pre-fix ParseDataType already curated a plain typo (that part of
// the earlier fix was correct), but its message named no valid set — this asserts
// the new "Valid values:" suffix, which is absent pre-fix.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "Sting", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised data type 'Sting'", response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.Contains("Boolean", response.Error);
Assert.Contains("List", response.Error);
}
/// <summary>Verifies that add attribute, when with defined data type name, parses and proceeds past guard.</summary>
/// <param name="name">The name.</param>
[Theory]
[InlineData("Boolean")]
[InlineData("Int32")]
[InlineData("Float")]
[InlineData("Double")]
[InlineData("String")]
[InlineData("DateTime")]
[InlineData("Binary")]
public void AddAttribute_WithDefinedDataTypeName_ParsesAndProceedsPastGuard(string name)
{
// No repository stub: GetTemplateByIdAsync returns null (NSubstitute
// default), so a token the guard ACCEPTS reaches AddAttributeAsync's
// "Template not found" failure. A token the guard REJECTS would never
// reach the service at all (see the tests above), so this proves every
// defined name still parses.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", name, null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// <summary>Verifies that add attribute, when with defined list data type name and element type, parses and proceeds past guard.</summary>
[Fact]
public void AddAttribute_WithDefinedListDataTypeNameAndElementType_ParsesAndProceedsPastGuard()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Whatever", "List", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// <summary>Verifies that update attribute, when with comma joined data type, returns curated error.</summary>
[Fact]
public void UpdateAttribute_WithCommaJoinedDataType_ReturnsCuratedError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "String,Int32", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised data type", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// <summary>Verifies that add list attribute, when with non name element type token, returns curated error.</summary>
/// <param name="token">The token.</param>
[Theory]
[InlineData("2")] // ordinal for a DEFINED, valid element type (Float)
[InlineData("99")] // undefined ordinal
[InlineData("String,Int32")] // comma-joined — combines to DateTime (also a valid element type)
public void AddListAttribute_WithNonNameElementTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): each of these tokens parsed successfully
// (Float / an undefined value that fails a LATER, differently-worded
// "requires a valid element type" check / DateTime) rather than being
// rejected at the parse boundary with this message.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, token),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised element type", response.Error);
Assert.Contains(token, response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// <summary>Verifies that add list attribute, when with typo element type, returns curated error naming valid values.</summary>
[Fact]
public void AddListAttribute_WithTypoElementType_ReturnsCuratedErrorNamingValidValues()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Sting"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised element type 'Sting'", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// <summary>Verifies that add alarm, when with non name trigger type token, returns curated error.</summary>
/// <param name="token">The token.</param>
[Theory]
[InlineData("0")] // ordinal for a DEFINED member (ValueMatch)
[InlineData("99")] // undefined ordinal — the deploy-time-bomb case
[InlineData("ValueMatch,RangeViolation")] // comma-joined — Enum.Parse ORs 0|1 into RangeViolation
public void AddAlarm_WithNonNameTriggerTypeToken_ReturnsCuratedError(string token)
{
// PROOF OF FAILURE (pre-fix): AddAlarm/UpdateAlarm built TriggerType via
// raw `Enum.Parse`, which has none of ParseDeploymentStatus's guards.
// Each of these tokens parsed SILENTLY (to ValueMatch, an undefined
// value, and RangeViolation respectively — no exception at all) and the
// command proceeded into TemplateService, failing later with
// "Template with ID 1 not found." instead of this curated message.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", token, 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised alarm trigger type", response.Error);
Assert.Contains(token, response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// <summary>Verifies that add alarm, when with typo trigger type, returns curated error instead of masked internal error.</summary>
[Fact]
public void AddAlarm_WithTypoTriggerType_ReturnsCuratedErrorInsteadOfMaskedInternalError()
{
// PROOF OF FAILURE (pre-fix): unlike ParseDataType (already curated by
// the earlier fix), the TriggerType sites used raw `Enum.Parse`. A typo's
// ArgumentException is uncaught by any handler-local guard and reaches
// MapFault as an unanticipated fault — masked to a generic
// "An internal error occurred (CorrelationId=...)" message, exactly the
// masking this task's defect description calls out. Post-fix,
// ParseEnumStrict raises a curated ManagementCommandException instead.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", "ValueMach", 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Unrecognised alarm trigger type 'ValueMach'", response.Error);
Assert.Contains("Valid values:", response.Error);
Assert.DoesNotContain("internal error", response.Error);
}
/// <summary>Verifies that add alarm, when with defined trigger type name, parses and proceeds past guard.</summary>
/// <param name="name">The name.</param>
[Theory]
[InlineData("ValueMatch")]
[InlineData("RangeViolation")]
[InlineData("RateOfChange")]
[InlineData("HiLo")]
[InlineData("Expression")]
public void AddAlarm_WithDefinedTriggerTypeName_ParsesAndProceedsPastGuard(string name)
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "AlarmX", name, 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Template with ID 1 not found", response.Error);
}
/// <summary>Verifies that update alarm, when with ordinal trigger type, returns curated error.</summary>
[Fact]
public void UpdateAlarm_WithOrdinalTriggerType_ReturnsCuratedError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAlarmCommand(5, "AlarmX", "0", 5, null, null, false),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Unrecognised alarm trigger type", response.Error);
Assert.Contains("Valid values:", response.Error);
}
/// <summary>Verifies that add template alarm: carries is mes reportable.</summary>
[Fact]
public void AddTemplateAlarm_CarriesIsMesReportable()
{
// The MES-reportable flag rides AddTemplateAlarmCommand as a trailing
// optional (additive-only wire contract) and must reach the TemplateAlarm
// the handler hands to TemplateService — verified at the repository
// boundary, which is where the entity is persisted.
var template = new Template("T1") { Id = 1 };
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
_templateRepo.GetAllTemplatesForAnalysisAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
TemplateAlarm? added = null;
_templateRepo
.When(r => r.AddTemplateAlarmAsync(Arg.Any<TemplateAlarm>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<TemplateAlarm>());
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAlarmCommand(1, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.True(added!.IsMesReportable);
}
/// <summary>Verifies that update template alarm: carries is mes reportable.</summary>
[Fact]
public void UpdateTemplateAlarm_CarriesIsMesReportable()
{
var template = new Template("T1") { Id = 1 };
var existing = new TemplateAlarm("HighTemp")
{
Id = 5,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
PriorityLevel = 100,
IsMesReportable = false
};
_templateRepo.GetTemplateAlarmByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
TemplateAlarm? updated = null;
_templateRepo
.When(r => r.UpdateTemplateAlarmAsync(Arg.Any<TemplateAlarm>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<TemplateAlarm>());
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAlarmCommand(5, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
"Designer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(updated);
Assert.True(updated!.IsMesReportable);
}
/// <summary>Verifies that set instance alarm override: passes is mes reportable override.</summary>
[Fact]
public void SetInstanceAlarmOverride_PassesIsMesReportableOverride()
{
// false is the interesting case: it is distinct from the null "inherit"
// default, so a dropped argument would read as "inherit" rather than
// "explicitly not reportable".
var template = new Template("T1") { Id = 1 };
template.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 5,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
PriorityLevel = 100
});
var instance = new Instance("Inst1") { Id = 10, TemplateId = 1, SiteId = 1 };
_templateRepo.GetInstanceByIdAsync(10, Arg.Any<CancellationToken>()).Returns(instance);
// The alarm-override check resolves members off the lean analysis projection
// (review 2026-10-21 04/P6, 08/P1).
_templateRepo.GetAllTemplatesForAnalysisAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
_templateRepo.GetAlarmOverrideAsync(10, "HighTemp", Arg.Any<CancellationToken>())
.Returns((InstanceAlarmOverride?)null);
InstanceAlarmOverride? persisted = null;
_templateRepo
.When(r => r.AddInstanceAlarmOverrideAsync(Arg.Any<InstanceAlarmOverride>(), Arg.Any<CancellationToken>()))
.Do(ci => persisted = ci.Arg<InstanceAlarmOverride>());
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceAlarmOverrideCommand(10, "HighTemp", null, null, IsMesReportableOverride: false),
"Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(persisted);
Assert.False(persisted!.IsMesReportableOverride);
}
/// <summary>Verifies that update attribute, when valid scalar, persists value and keeps fixed fields.</summary>
[Fact]
public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
{
// Documents the fixed-field contract on update —
// ValidateAttributeOverride(existing, proposed) runs for every update, so
// a matching DataType/ElementDataType lets the Value change persist while
// the fixed type columns are never copied from the proposed attribute.
var template = new Template("T1") { Id = 1 };
var existing = new TemplateAttribute("Count")
{
Id = 5, TemplateId = 1, DataType = Commons.Types.Enums.DataType.Int32,
ElementDataType = null, Value = "0"
};
_templateRepo.GetTemplateAttributeByIdAsync(5, Arg.Any<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
}
/// <summary>Verifies that update api key, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that add scope rule, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that update area, when with admin role, returns unauthorized.</summary>
[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)
// ========================================================================
/// <summary>Verifies that list shared scripts, when with no roles, returns success.</summary>
[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);
}
/// <summary>Verifies that list database connections, when with no roles, returns success.</summary>
[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);
}
/// <summary>Verifies that list api methods, when with no roles, returns success.</summary>
[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);
}
/// <summary>Verifies that list scope rules, when with admin role, returns success.</summary>
[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
// ========================================================================
/// <summary>Verifies that list database connections, when repo throws, returns error.</summary>
[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.
Assert.DoesNotContain("Connection refused", response.Error);
Assert.Contains(envelope.CorrelationId, response.Error);
}
// ========================================================================
// Site-scope enforcement tests
//
// A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must
// not be able to read or act on entities belonging to a site outside their
// scope. The handlers must throw SiteScopeViolationException, which the
// actor maps to ManagementUnauthorized.
// ========================================================================
private void AddSiteRepoWithSite(int siteId, string identifier)
{
var siteRepo = Substitute.For<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);
}
/// <summary>Verifies that get instance, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
// ========================================================================
// Gitea #40: MgmtDeployArea is scoped by the area's OWNING site, and an
// unresolvable area fails closed for a scoped caller (register row 98).
// ========================================================================
/// <summary>Verifies that deploy area, when the area is out of scope for a site scoped user, returns unauthorized.</summary>
[Fact]
public void DeployArea_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Area 12 belongs to site 2; user is scoped to site 1.
_templateRepo.GetAreaByIdAsync(12, Arg.Any<CancellationToken>())
.Returns(new Area("Line") { Id = 12, SiteId = 2 });
var actor = CreateActor();
var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(12), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that deploy area, when the area does not exist and the caller is site scoped, returns unauthorized without an existence oracle.</summary>
[Fact]
public void DeployArea_NonexistentAreaForSiteScopedUser_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that deploy area, when the area does not exist and the caller is system wide, returns a clean not-found error.</summary>
[Fact]
public void DeployArea_NonexistentAreaForSystemWideUser_ReturnsNotFoundError()
{
var actor = CreateActor();
var envelope = Envelope(new MgmtDeployAreaCommand(999), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("not found", response.Error, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that deploy area is refused for a Designer.</summary>
[Fact]
public void DeployArea_WithDesignerRole_ReturnsUnauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployAreaCommand(12), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>
/// The in-scope success path of <c>MgmtDeployArea</c>: the handler resolves the
/// REAL <see cref="DeploymentService"/> (its methods are not virtual, so it runs
/// over substituted repositories), relays the command's <c>Force</c> verbatim to
/// <c>DeployAreaAsync</c>, and returns the bulk summary as the success payload.
///
/// <para>
/// <c>Force</c> is observable without a site: the one instance in the area is
/// up-to-date, so unforced it is reported as an up-to-date skip; forced it is
/// attempted, and the substituted flatten refuses it, so it comes back as a
/// failed row — neither path ever reaches the site.
/// </para>
/// </summary>
/// <param name="force">The command's <c>Force</c> flag.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DeployArea_InScopeForSiteScopedUser_RelaysForce_AndReturnsSummary(bool force)
{
var (deployRepo, pipeline, siteRepo) = RegisterDeploymentService();
var area = new Area("Line") { Id = 12, SiteId = 1 };
_templateRepo.GetAreaByIdAsync(12, Arg.Any<CancellationToken>()).Returns(area);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<Area>)new List<Area> { area });
siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new Site("Site 1", "site-1") { Id = 1 });
var instance = new Instance("Pump5") { Id = 5, SiteId = 1, AreaId = 12, TemplateId = 10, State = InstanceState.Enabled };
siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<Instance>)new List<Instance> { instance });
deployRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()).Returns(instance);
// Up-to-date: the deployed snapshot's hash equals the freshly recomputed one.
var prior = new DeploymentRecord("prior-5", "admin")
{
InstanceId = 5, Status = DeploymentStatus.Success, RevisionHash = "sha256:5",
};
deployRepo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns((IReadOnlyDictionary<int, string>)new Dictionary<int, string> { [5] = "sha256:5" });
deployRepo.GetCurrentDeploymentStatusesAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyDictionary<int, DeploymentRecord>)new Dictionary<int, DeploymentRecord> { [5] = prior });
deployRepo.GetCurrentDeploymentStatusAsync(5, Arg.Any<CancellationToken>()).Returns(prior);
pipeline.ComputeRevisionHashAsync(5, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success("sha256:5"));
pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("flatten refused by test"));
var actor = CreateActor();
var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(12, force), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(10));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
var summary = JsonSerializer.Deserialize<SiteDeploymentSummary>(
response.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
Assert.Equal("site-1", summary.SiteIdentifier);
if (force)
{
var row = Assert.Single(summary.InstanceResults);
Assert.Equal("Pump5", row.UniqueName);
Assert.False(row.Success);
Assert.Equal(1, summary.FailureCount);
Assert.Empty(summary.SkippedInstances ?? []);
}
else
{
Assert.Empty(summary.InstanceResults);
var skipped = Assert.Single(summary.SkippedInstances!);
Assert.Equal("Pump5", skipped.UniqueName);
Assert.Contains("up-to-date", skipped.Reason);
}
}
/// <summary>Verifies that get instance, when in scope for site scoped user, returns success.</summary>
[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));
}
// ========================================================================
// Register row 98: the scope gate fails CLOSED on an unresolvable target.
//
// EnforceSiteScopeForInstance / EnforceSiteScopeForIdentifier used to skip
// EnforceSiteScope entirely when the id did not resolve, so a site-scoped
// caller PASSED the gate and fell through into the handler body. A gate that
// cannot prove the target is inside the caller's scope must treat it as
// outside. System-wide callers return at the guards above the lookup and are
// untouched — they still get the handler's ordinary "not found".
// ========================================================================
/// <summary>Verifies that get instance, when nonexistent id for site scoped user, returns unauthorized.</summary>
[Fact]
public void GetInstance_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// _templateRepo is unstubbed for id 999 → GetInstanceByIdAsync returns null.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The denial must not double as an existence oracle: the message says the
// instance is not in scope, never whether it exists.
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that get instance, when nonexistent id for site scoped user, is indistinguishable from out of scope.</summary>
[Fact]
public void GetInstance_NonexistentIdForSiteScopedUser_IsIndistinguishableFromOutOfScope()
{
// The security property: a scoped caller cannot tell "instance 7 lives at a
// site you may not see" apart from "instance 999 does not exist" — both are
// the same ManagementUnauthorized shape.
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"));
var outOfScope = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
actor.Tell(ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer"));
var nonexistent = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(outOfScope.GetType(), nonexistent.GetType());
}
/// <summary>Verifies that get instance, when nonexistent id for admin user, is unchanged.</summary>
[Fact]
public void GetInstance_NonexistentIdForAdminUser_IsUnchanged()
{
// An Administrator is system-wide, so the gate returns before the lookup:
// the handler runs and answers for itself. NOT a ManagementUnauthorized.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
/// <summary>Verifies that get instance, when nonexistent id for system wide deployer, is unchanged.</summary>
[Fact]
public void GetInstance_NonexistentIdForSystemWideDeployer_IsUnchanged()
{
// The other system-wide arm: a Deployer with NO site grants. Same rule,
// same untouched behaviour.
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(999), Array.Empty<string>(), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
/// <summary>Verifies that get instance, when in scope id for site scoped user, is still allowed.</summary>
[Fact]
public void GetInstance_InScopeIdForSiteScopedUser_IsStillAllowed()
{
// The fail-closed change must not deny a legitimate in-scope target.
_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));
}
/// <summary>Verifies that get site, when nonexistent id for site scoped user, returns unauthorized.</summary>
[Fact]
public void GetSite_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// HandleGetSite resolves the site itself and carried its own copy of the
// fail-OPEN shape (`if (site != null) EnforceSiteScope(...)`), so a scoped
// caller used to get success/null for any id — an enumeration oracle.
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that get site, when nonexistent id for admin user, is unchanged.</summary>
[Fact]
public void GetSite_NonexistentIdForAdminUser_IsUnchanged()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Administrator");
actor.Tell(envelope);
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
/// <summary>Verifies that get data connection, when nonexistent id for site scoped user, returns unauthorized.</summary>
[Fact]
public void GetDataConnection_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
{
// Third copy of the same inline fail-OPEN shape.
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.DataConnection?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new GetDataConnectionCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that query event logs, when nonexistent site identifier for site scoped user, returns unauthorized.</summary>
[Fact]
public void QueryEventLogs_NonexistentSiteIdentifierForSiteScopedUser_ReturnsUnauthorized()
{
// EnforceSiteScopeForIdentifier shares the shape: an identifier that
// resolves to no Site row is a denial for a scoped caller.
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any<CancellationToken>())
.Returns((Commons.Entities.Sites.Site?)null);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(
new QueryEventLogsCommand("GHOST"),
new[] { "1" },
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that query event logs, when in scope site identifier for site scoped user, is still allowed.</summary>
[Fact]
public void QueryEventLogs_InScopeSiteIdentifierForSiteScopedUser_IsStillAllowed()
{
AddSiteRepoWithSite(1, "SITE1");
var actor = CreateActor();
var envelope = ScopedEnvelope(
new QueryEventLogsCommand("SITE1"),
new[] { "1" },
"Deployer");
actor.Tell(envelope);
// The scope gate passes; whatever the (unwired) CommunicationService does
// next is not this test's business — only that it is NOT a scope denial.
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
/// <summary>Verifies that get site, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that list areas, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that get data connection, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that get site, when out of scope for admin user, returns success.</summary>
[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));
}
/// <summary>Verifies that query event logs, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
private ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator RegisterPendingUpdates()
{
var evaluator = Substitute.For<ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator>();
evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
DateTimeOffset.UtcNow,
[
new(1, "S1", "Site 1", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.OutOfDate, null, 10, 4, 0),
new(2, "S2", "Site 2", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.Current, null, 10, 6, 0),
])));
_services.AddScoped(_ => evaluator);
// The real process-wide cache the Host registers via AddDeploymentManager (Gitea #91).
_services.AddSingleton(Microsoft.Extensions.Options.Options.Create(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentManagerOptions()));
_services.AddSingleton<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier(
Microsoft.Extensions.Logging.Abstractions.NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance));
_services.AddSingleton<
ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesCache,
ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesCache>();
return evaluator;
}
/// <summary>
/// Gitea #91: repeated CLI calls inside one cache TTL share the process-wide
/// pending-updates memo instead of each running a full fleet flatten.
/// </summary>
[Fact]
public void GetPendingUpdates_RepeatedCalls_ShareOneFleetEvaluation()
{
var evaluator = RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
}
/// <summary>Gitea #63: a system-wide Deployer gets fleet totals.</summary>
[Fact]
public void GetPendingUpdates_SystemWideDeployer_ReturnsFleetTotals()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = JsonDocument.Parse(response.JsonData);
Assert.Equal(10, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
Assert.Equal(1, doc.RootElement.GetProperty("staleArtifactSiteCount").GetInt32());
Assert.Equal(2, doc.RootElement.GetProperty("sites").GetArrayLength());
Assert.Equal("OutOfDate", doc.RootElement.GetProperty("sites")[0].GetProperty("artifactState").GetString());
}
/// <summary>Gitea #63: a site-scoped Deployer's totals exclude other sites.</summary>
[Fact]
public void GetPendingUpdates_SiteScopedDeployer_SeesOnlyPermittedSites()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new GetPendingUpdatesCommand(), new[] { "1" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = JsonDocument.Parse(response.JsonData);
Assert.Equal(4, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
Assert.Equal(1, doc.RootElement.GetProperty("sites").GetArrayLength());
}
/// <summary>Gitea #63: a non-Deployer is refused.</summary>
[Fact]
public void GetPendingUpdates_Viewer_IsUnauthorized()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that query parked messages, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that retry parked message, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that discard parked message, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that debug snapshot, when out of scope for site scoped user, returns unauthorized.</summary>
[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);
}
// ========================================================================
// Site scoping: list filtering + resolve-before-scope oracles
// (arch-review 2026-08-19)
// ========================================================================
/// <summary>
/// The unfiltered list used to return EVERY site's connections to any
/// authenticated caller, handing a scoped caller exactly the rows
/// <c>GetDataConnection</c> denies them.
/// </summary>
[Fact]
public void ListDataConnections_SiteScopedUser_ReturnsOnlyPermittedSitesConnections()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new("ConnA", "OpcUa", 1) { Id = 1 },
new("ConnB", "OpcUa", 2) { Id = 2 },
});
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListDataConnectionsCommand(null), new[] { "1" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("ConnA", response.JsonData);
Assert.DoesNotContain("ConnB", response.JsonData);
}
/// <summary>Verifies that list data connections, when system wide user, returns every connection.</summary>
[Fact]
public void ListDataConnections_SystemWideUser_ReturnsEveryConnection()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new("ConnA", "OpcUa", 1) { Id = 1 },
new("ConnB", "OpcUa", 2) { Id = 2 },
});
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
actor.Tell(Envelope(new ListDataConnectionsCommand(null), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("ConnA", response.JsonData);
Assert.Contains("ConnB", response.JsonData);
}
/// <summary>Verifies that list data connections, when scoped administrator, returns every connection.</summary>
[Fact]
public void ListDataConnections_ScopedAdministrator_ReturnsEveryConnection()
{
// Administrator bypasses scope even with PermittedSiteIds set — same rule
// EnforceSiteScope applies everywhere else.
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new("ConnA", "OpcUa", 1) { Id = 1 },
new("ConnB", "OpcUa", 2) { Id = 2 },
});
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListDataConnectionsCommand(null), new[] { "1" }, "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("ConnA", response.JsonData);
Assert.Contains("ConnB", response.JsonData);
}
/// <summary>Verifies that list data connections, when explicit out of scope site filter, returns unauthorized.</summary>
[Fact]
public void ListDataConnections_ExplicitOutOfScopeSiteFilter_ReturnsUnauthorized()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("ConnB", "OpcUa", 2) { Id = 2 } });
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new ListDataConnectionsCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>
/// The not-found check used to run before the scope check, so an
/// out-of-scope caller could tell a real instance id from a fake one by the
/// error they got back. Both cases must now answer with the same denial.
/// </summary>
[Fact]
public void DebugSnapshot_NonexistentInstanceForSiteScopedUser_ReturnsUnauthorized()
{
_templateRepo.GetInstanceByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Instance?)null);
var actor = CreateActor();
var envelope = ScopedEnvelope(new DebugSnapshotCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that debug snapshot, when nonexistent instance for system wide user, still reports not found.</summary>
[Fact]
public void DebugSnapshot_NonexistentInstanceForSystemWideUser_StillReportsNotFound()
{
// The fail-closed arm must not change the unscoped caller's answer.
_templateRepo.GetInstanceByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Instance?)null);
var actor = CreateActor();
actor.Tell(Envelope(new DebugSnapshotCommand(999), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("not found", response.Error);
}
// ========================================================================
// Serialization
//
// 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
{
/// <summary>Gets or sets the name.</summary>
public string Name { get; set; } = "";
/// <summary>Gets or sets the parent.</summary>
public CyclicNode? Parent { get; set; }
/// <summary>Gets or sets the children.</summary>
public List<CyclicNode> Children { get; set; } = new();
}
/// <summary>Verifies that serialize result, when with cyclic graph, does not throw.</summary>
[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);
}
/// <summary>Verifies that serialize result: uses camel case property names.</summary>
[Fact]
public void SerializeResult_UsesCamelCasePropertyNames()
{
var json = ManagementActor.SerializeResult(new CyclicNode { Name = "X" });
Assert.Contains("\"name\"", json);
Assert.DoesNotContain("\"Name\"", json);
}
// ========================================================================
// PipeTo fault mapping
//
// 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
//
// 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.
// ========================================================================
/// <summary>Verifies that resolve roles command, when is no longer dispatched, returns management error.</summary>
[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);
}
/// <summary>Verifies that unknown command type: fault mapped to management error.</summary>
[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-projection equivalent of <see cref="DeploymentRecordFor"/>, on the site
/// given. QueryDeployments returns DB-side projections joined to their instance,
/// not whole tracked entities — and the join is what carries the
/// <c>SiteId</c> the scope filter is evaluated against.
/// </summary>
private static DeploymentListRow DeploymentRowFor(int instanceId, int siteId) =>
new(instanceId, "deploy-" + instanceId, instanceId, "Pump" + instanceId, siteId,
DeploymentStatus.Success, null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary>
/// Stubs the single paged deployment read so it applies the same
/// instance / status / site-scope / paging predicates the database would.
///
/// <para>
/// The filtering lives entirely in the query now (register row 37 folded the
/// handler's own second repository method away), so a stub that ignored the
/// filter would make these tests pass no matter what the handler passed down.
/// Honouring it here is what keeps the scope-enforcement assertions meaningful.
/// </para>
/// </summary>
/// <param name="repo">The substituted repository to stub.</param>
/// <param name="rows">The seed rows, as (instance id, site id) pairs.</param>
private static void StubDeploymentPageQuery(
IDeploymentManagerRepository repo,
params (int InstanceId, int SiteId)[] rows)
{
var all = rows.Select(r => DeploymentRowFor(r.InstanceId, r.SiteId)).ToList();
repo.QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var filter = ci.ArgAt<DeploymentListFilter>(0);
var page = Math.Max(1, ci.ArgAt<int>(1));
var size = Math.Max(1, ci.ArgAt<int>(2));
IEnumerable<DeploymentListRow> q = all;
if (filter.InstanceId.HasValue)
q = q.Where(r => r.InstanceId == filter.InstanceId.Value);
if (filter.Status.HasValue)
q = q.Where(r => r.Status == filter.Status.Value);
if (filter.SiteIdScope != null)
q = q.Where(r => filter.SiteIdScope.Contains(r.SiteId));
var matched = q.ToList();
var slice = matched.Skip((page - 1) * size).Take(size).ToList();
return new DeploymentListPage(slice, matched.Count);
});
}
/// <summary>Captures the filter the handler pushed into the paged read.</summary>
private static DeploymentListFilter CapturedDeploymentFilter(IDeploymentManagerRepository repo)
{
var call = repo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
return (DeploymentListFilter)call.GetArguments()[0]!;
}
/// <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();
});
}
/// <summary>Verifies that query deployments, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that query deployments, when unfiltered with deployment role, returns all records.</summary>
[Fact]
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 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);
}
/// <summary>Verifies that query deployments, when filtered by instance id, returns instance records.</summary>
[Fact]
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (5, 1));
_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);
}
/// <summary>Verifies that query deployments, when filtered by out of scope instance, returns unauthorized.</summary>
[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().QueryDeploymentListPageAsync(default!, default, default);
}
/// <summary>Verifies that query deployments, when filtered by in scope instance, returns records.</summary>
[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>();
StubDeploymentPageQuery(deployRepo, (5, 1));
_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);
}
/// <summary>Verifies that query deployments, when unfiltered for site scoped user, drops out of scope records.</summary>
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 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);
Assert.Equal(new[] { 1 }, CapturedDeploymentFilter(deployRepo).SiteIdScope);
}
/// <summary>Verifies that query deployments, when unfiltered for site scoped user, reads no instances at all.</summary>
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_ReadsNoInstancesAtAll()
{
// Register row 87. The scoped branch used to resolve the site grant into an
// instance-id LIST, which meant loading EVERY instance in the system on
// every scoped query (a bulk load that had itself replaced an N+1 of
// per-record lookups). The grant now rides as a site-id filter resolved by
// the query's own join, so neither instance read may happen.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2), (3, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
/// <summary>Verifies that query deployments, when site scoped user with unparseable grant, sees nothing.</summary>
[Fact]
public void QueryDeployments_SiteScopedUserWithUnparseableGrant_SeesNothing()
{
// A permitted-site id that is not an integer is dropped, never ignored:
// ignoring it would silently widen the grant to fleet-wide. A grant of
// nothing but garbage therefore yields an EMPTY scope — a real "sees
// nothing" filter, not "no filter".
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "not-an-id" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
Assert.Empty(CapturedDeploymentFilter(deployRepo).SiteIdScope!);
}
/// <summary>Verifies that query deployments, when unfiltered for admin user, returns all records.</summary>
[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.)
// The UI's SiteScopeService now agrees — see SiteScopeServiceTests
// (register row 86).
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 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);
Assert.Null(CapturedDeploymentFilter(deployRepo).SiteIdScope);
}
// ── Loud status parsing + bounded page size (register rows 87 / 88) ────────
/// <summary>Verifies that query deployments, when unknown status, fails loudly naming the valid values.</summary>
[Fact]
public void QueryDeployments_UnknownStatus_FailsLoudlyNamingTheValidValues()
{
// Register row 88: `--status Sucess` used to fall back to "no filter" and
// return EVERY status — a plausible, complete-looking answer to a question
// the caller did not ask.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "Sucess"), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Sucess", response.Error);
Assert.Contains("Success", response.Error);
Assert.Contains("Failed", response.Error);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
}
/// <summary>Verifies that query deployments, when ordinal status, is rejected like any unknown value.</summary>
/// <param name="status">The status.</param>
[Theory]
[InlineData("2")]
[InlineData("+1")]
[InlineData("-1")]
public void QueryDeployments_OrdinalStatus_IsRejectedLikeAnyUnknownValue(string status)
{
// Register row 88 postscript (Wave 6): Enum.TryParse accepts ordinals, so
// `--status 2` silently resolved to InProgress despite the documented
// names-only contract. Same guard as AuditQueryParamParsers.LooksNumeric.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: status), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Success", response.Error);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
}
/// <summary>Verifies that query deployments, when known status, is case insensitive.</summary>
/// <param name="status">The status.</param>
[Theory]
[InlineData("success")]
[InlineData("SUCCESS")]
public void QueryDeployments_KnownStatus_IsCaseInsensitive(string status)
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: status), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, CapturedDeploymentFilter(deployRepo).Status);
}
/// <summary>Verifies that query deployments, when numeric status, is rejected.</summary>
[Fact]
public void QueryDeployments_NumericStatus_IsRejected()
{
// Enum.TryParse happily accepts "7" and yields an undefined enum value, so
// the parse needs the IsDefined guard as well as the TryParse.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "7"), "Deployer"));
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that query deployments, when non-positive page size, takes the default not unlimited.</summary>
/// <param name="requested">The requested.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void QueryDeployments_NonPositivePageSize_TakesTheDefault_NotUnlimited(int requested)
{
// Register row 87: a non-positive page size used to mean "unlimited" all the
// way down to the repository, materializing the whole retention window.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, Enumerable.Range(1, 200).Select(i => (i, 1)).ToArray());
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: requested), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.DefaultPageSize, (int)call.GetArguments()[2]!);
// The total travels with the bounded page, so a caller can tell a truncated
// page from a complete one.
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(200, result.GetProperty("totalCount").GetInt32());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("deployments").GetArrayLength());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("pageSize").GetInt32());
}
/// <summary>Verifies that query deployments, when oversized page size, is capped at the maximum.</summary>
[Fact]
public void QueryDeployments_OversizedPageSize_IsCappedAtTheMaximum()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: int.MaxValue), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.MaxPageSize, (int)call.GetArguments()[2]!);
}
/// <summary>Verifies that query deployments: page is floored at one.</summary>
[Fact]
public void QueryDeployments_PageIsFlooredAtOne()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Page: 0), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(1, result.GetProperty("page").GetInt32());
}
// ========================================================================
// 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.
// ========================================================================
/// <summary>Verifies that deploy artifacts, when with site id, calls single site deploy.</summary>
[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);
}
/// <summary>Verifies that deploy artifacts, when fleet wide, calls all sites deploy.</summary>
[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);
}
/// <summary>Verifies that deploy artifacts, when site scoped user, out of scope site unauthorized.</summary>
[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);
}
/// <summary>Verifies that deploy artifacts, when site scoped user, fleet wide unauthorized.</summary>
[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.
// ========================================================================
/// <summary>Verifies that list templates, when skip take, returns requested window.</summary>
[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);
}
/// <summary>Verifies that list templates, when default take, returns all.</summary>
[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);
}
/// <summary>
/// Verifies that list instances pages and site-scopes IN SQL (review 2026-10-21 08/P2): a
/// scoped user's permitted sites are passed as the repository's site filter, so paging
/// can never surface an out-of-scope instance, and Skip/Take/search reach the query.
/// </summary>
[Fact]
public void ListInstances_PagingAppliesAfterSiteScopeFilter()
{
var uiRepo = Substitute.For<ICentralUiRepository>();
IReadOnlyCollection<int>? passedSites = null;
uiRepo.GetInstancesPagedAsync(
Arg.Do<IReadOnlyCollection<int>?>(s => passedSites = s), null, "I-", 0, 2, Arg.Any<CancellationToken>())
.Returns((new List<Instance>
{
new("I-001") { Id = 1, SiteId = 1 },
new("I-002") { Id = 2, SiteId = 1 },
}, 3));
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(SearchTerm: "I-", Take: 2), new[] { "1", "not-an-int" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("I-001", response.JsonData);
Assert.Contains("I-002", response.JsonData);
Assert.StartsWith("[", response.JsonData.TrimStart()); // response shape unchanged: a bare array
Assert.Equal(new[] { 1 }, passedSites); // non-integer grant dropped, never widened
uiRepo.DidNotReceive().GetInstancesFilteredAsync(
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
/// <summary>
/// A scoped user's explicit SiteId is intersected with their grant; an out-of-grant site
/// yields an empty filter (nothing), and an unscoped caller's SiteId passes through.
/// </summary>
[Fact]
public void ListInstances_SiteIdIsIntersectedWithTheScope_AndUnlimitedTakeIsUnbounded()
{
var uiRepo = Substitute.For<ICentralUiRepository>();
var calls = new List<(IReadOnlyCollection<int>? Sites, int Take)>();
uiRepo.GetInstancesPagedAsync(Arg.Any<IReadOnlyCollection<int>?>(), Arg.Any<int?>(), Arg.Any<string?>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
calls.Add(((IReadOnlyCollection<int>?)ci[0], (int)ci[4]));
return (Array.Empty<Instance>(), 0);
});
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(SiteId: 2), new[] { "1" }, "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ListInstancesCommand(SiteId: 2), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ListInstancesCommand(), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Empty(calls[0].Sites!);
Assert.Equal(new[] { 2 }, calls[1].Sites);
Assert.Null(calls[2].Sites);
Assert.Equal(int.MaxValue, calls[2].Take);
}
// ========================================================================
// 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.
// ========================================================================
/// <summary>ScadaBridge#105: a bulk attribute override differing from the member only by case is rejected with a hint, nothing stored.</summary>
[Fact]
public void SetInstanceOverrides_CaseVariantName_IsRejectedWithHint_PersistsNothing()
{
var instance = new Instance("Pump5") { Id = 5, SiteId = 1, TemplateId = 1 };
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()).Returns(instance);
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute> { new("Setpoint") { Id = 1, TemplateId = 1 } });
_templateRepo.GetOverridesByInstanceIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
var actor = CreateActor();
actor.Tell(Envelope(new SetInstanceOverridesCommand(5, new Dictionary<string, string?> { ["setpoint"] = "1" }), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("did you mean 'Setpoint'", response.Error);
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceAttributeOverrideAsync(default!, default);
}
/// <summary>ScadaBridge#105: a bulk native-source override differing only by case is rejected, nothing stored.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_CaseVariantName_IsRejectedWithHint()
{
StubFlattenedNativeSources("Pressure");
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("pressure", "Opc2", null, null)
}),
"Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("did you mean 'Pressure'", response.Error);
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
}
/// <summary>ScadaBridge#105: a single native-source override differing only by case is rejected, nothing stored.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverride_CaseVariantName_IsRejectedWithHint()
{
StubFlattenedNativeSources("Pressure");
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "PRESSURE", "Opc2", null, null), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("did you mean 'Pressure'", response.Error);
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
}
/// <summary>Verifies that set instance overrides, when with one invalid attribute, persists no overrides.</summary>
[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);
}
/// <summary>Verifies that set instance overrides, when all valid, persists all overrides.</summary>
[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).
// ========================================================================
/// <summary>Verifies that unexpected fault, when returns generic message, not raw exception text.</summary>
[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)
// ========================================================================
/// <summary>Verifies that update smtp config, when with tls mode and credentials, persists them.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with null tls mode and credentials, preserves existing values.</summary>
[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);
}
/// <summary>Verifies that update smtp config: applies oauth2 authority and scope.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with null oauth2 authority and scope, preserves existing values.</summary>
[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
// ========================================================================
/// <summary>Verifies that update smtp config, when with ews transport, persists transport and surfaces it in public shape.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with unknown transport, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with ews transport and non https host, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with ews transport and oauth2 auth, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with ews transport and malformed credentials, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with ews transport and empty credential half, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when flipping to ews with stored bad credentials, returns management error.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when with null transport, preserves existing value.</summary>
[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);
}
/// <summary>Verifies that update smtp config, when transport acceptance, matches email transport parser.</summary>
/// <param name="transport">The transport.</param>
[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
// ========================================================================
/// <summary>Verifies that create notification list, when with sms type, persists sms.</summary>
[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);
}
/// <summary>Verifies that create notification list, when with sms type and phones, persists for sms recipients.</summary>
[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());
}
/// <summary>Verifies that create notification list, when default type, is email.</summary>
[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);
}
/// <summary>Verifies that update notification list, when with sms type, persists sms.</summary>
[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);
}
/// <summary>Verifies that update notification list, when changing type, is rejected.</summary>
[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);
}
/// <summary>Verifies that update notification list, when same email type, succeeds.</summary>
[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);
}
/// <summary>Verifies that update sms config, when with api base url and auth token, persists them.</summary>
[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);
}
/// <summary>Verifies that update sms config, when with null auth token and api base url, preserves existing values.</summary>
[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);
}
/// <summary>Verifies that update sms config, when result and audit, never contain auth token.</summary>
[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));
}
/// <summary>Verifies that list sms configs: projects away auth token.</summary>
[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);
}
/// <summary>Verifies that update sms config, when with viewer role, returns unauthorized.</summary>
[Fact]
public void UpdateSmsConfig_WithViewerRole_ReturnsUnauthorized()
{
// 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);
}
/// <summary>Verifies that update sms config, when with designer role, returns unauthorized.</summary>
[Fact]
public void UpdateSmsConfig_WithDesignerRole_ReturnsUnauthorized()
{
// 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);
}
/// <summary>Verifies that update sms config, when with empty auth token, preserves existing token.</summary>
[Fact]
public void UpdateSmsConfig_WithEmptyAuthToken_PreservesExistingToken()
{
// 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);
}
/// <summary>Verifies that update sms config, when messaging service sid only, persists null from number.</summary>
[Fact]
public void UpdateSmsConfig_MessagingServiceSidOnly_PersistsNullFromNumber()
{
// 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);
}
/// <summary>Verifies that update sms config, when malformed account sid, rejected.</summary>
[Fact]
public void UpdateSmsConfig_MalformedAccountSid_Rejected()
{
// 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);
}
/// <summary>Verifies that update sms config, when valid sid, accepted.</summary>
[Fact]
public void UpdateSmsConfig_ValidSid_Accepted()
{
// 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);
}
/// <summary>Verifies that curated handler failure: surfaces the curated message.</summary>
[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);
}
// ========================================================================
// Transport 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>());
// 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");
/// <summary>Verifies that export bundle command, when with admin role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that preview bundle command, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that export bundle command, when with unknown template name, returns management error.</summary>
[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);
}
/// <summary>Verifies that export bundle command, when with site and instance names, resolves to ids.</summary>
[Fact]
public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds()
{
// --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);
}
/// <summary>Verifies that export bundle command, when with all, includes every site and instance.</summary>
[Fact]
public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance()
{
// 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));
}
/// <summary>Verifies that import bundle command, when with blocker row, aborts before apply.</summary>
[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>());
}
/// <summary>Verifies that import bundle command, when duplicate preview items, dedupe per entity type and name.</summary>
[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;
// 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);
}
// ------------------------------------------------------------------------
// 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);
}
/// <summary>Verifies that import bundle command, when explicit spec, overrides auto match.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when no spec, falls back to auto match.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when no auto match with create missing, creates new.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when explicit create new spec, creates new.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when unresolved without create missing, fails.</summary>
[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?>());
}
/// <summary>Verifies that import bundle command, when connection spec and create missing, build map.</summary>
[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);
}
/// <summary>Verifies that preview bundle command: surfaces required mappings.</summary>
[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);
}
/// <summary>Verifies that preview bundle command, when releases the session, and forwards the callers ip.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when with blocker row, still releases the session.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when throttled unlock, surfaces the curated reason.</summary>
[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);
}
/// <summary>Verifies that import bundle command, when locked bundle, surfaces the curated reason.</summary>
[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);
}
/// <summary>Verifies that preview bundle command, when locked bundle, surfaces the curated reason.</summary>
[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);
}
/// <summary>ScadaBridge#45: a signature-policy refusal reaches the CLI as the curated reason, on preview and import.</summary>
[Fact]
public void BundleCommands_SignatureRejected_SurfaceTheCuratedReason()
{
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.BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles."));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
var preview = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
var import = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
foreach (var response in new[] { preview, import })
{
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires signed bundles", response.Error);
Assert.DoesNotContain("An internal error occurred", response.Error);
}
}
/// <summary>ScadaBridge#45: the preview result carries the bundle's signature status and signer.</summary>
[Fact]
public void PreviewBundleCommand_ReportsSignatureStatus()
{
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),
SignatureStatus = Commons.Types.Transport.BundleSignatureStatus.Verified,
SignerKeyId = "sha256:abc",
SignerName = "dev-cluster",
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId, Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(Convert.ToBase64String(new byte[] { 1 }), null), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = System.Text.Json.JsonDocument.Parse(response.JsonData);
var root = doc.RootElement;
string Prop(string name) => root.EnumerateObject()
.Single(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)).Value.GetString()!;
Assert.Equal("Verified", Prop("SignatureStatus"));
Assert.Equal("sha256:abc", Prop("SignerKeyId"));
Assert.Equal("dev-cluster", Prop("SignerName"));
}
// ========================================================================
// Native alarm source CRUD
// ========================================================================
/// <summary>Verifies that add template native alarm source, when with design role, returns success.</summary>
[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);
}
/// <summary>Verifies that add template native alarm source, when with deployment role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that list template native alarm sources: returns data.</summary>
[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);
}
/// <summary>Verifies that set instance native alarm source override, when with deployment role, returns success.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
{
// No prior override → Add path. The source resolves and is UNLOCKED, so the
// lock guard 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);
}
/// <summary>Gitea #59: the instance override command carries IsMesReportableOverride onto the persisted row.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverride_CarriesIsMesReportableOverride()
{
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);
InstanceNativeAlarmSourceOverride? added = null;
_templateRepo
.When(r => r.AddInstanceNativeAlarmSourceOverrideAsync(Arg.Any<InstanceNativeAlarmSourceOverride>(), Arg.Any<CancellationToken>()))
.Do(ci => added = ci.Arg<InstanceNativeAlarmSourceOverride>());
CreateActor().Tell(Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", null, null, null, IsMesReportableOverride: true),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.True(added!.IsMesReportableOverride);
}
/// <summary>Verifies that set instance native alarm source override, when with design role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that set instance native alarm source overrides, when bulk happy path, adds all and saves once.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_BulkHappyPath_AddsAllAndSavesOnce()
{
// 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);
}
private void StubFlattenedNativeSources(params string[] names)
{
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = names
.Select(n => new ResolvedNativeAlarmSource { CanonicalName = n, IsLocked = false, Source = "Template" })
.ToList()
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
}
/// <summary>
/// ScadaBridge#104: an entry that leaves the MES flag unspecified (a legacy 4-column CSV)
/// keeps the stored MES override on an existing row while the retarget fields are applied.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_KeepsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal("Opc2", existing.ConnectionNameOverride);
Assert.True(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104 control: an entry that specifies the flag (a 5-column CSV with a blank
/// MesReportable) still clears the stored MES override to inherit.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesSpecifiedNull_ClearsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Null(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104: a new override row whose entry leaves the MES flag unspecified inherits
/// it, even if the entry carries a stray value.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_NewRowInherits()
{
StubFlattenedNativeSources("Pressure");
InstanceNativeAlarmSourceOverride? added = null;
_templateRepo.AddInstanceNativeAlarmSourceOverrideAsync(
Arg.Do<InstanceNativeAlarmSourceOverride>(o => added = o), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: true, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.Null(added!.IsMesReportableOverride);
}
/// <summary>Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()
{
// 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);
}
/// <summary>Verifies that set instance native alarm source overrides, when unresolved source, rejects whole batch.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_UnresolvedSource_RejectsWholeBatch()
{
// 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 — guarded cross-site move
// ========================================================================
/// <summary>Verifies that move data connection, when with viewer role, returns unauthorized.</summary>
[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);
}
/// <summary>Verifies that move data connection, when happy path, moves site id and audits.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when target site missing, returns error.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when name collision at target, returns error.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when blocked by binding, returns error naming instances.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when same site, returns error no write.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when blocked by template native alarm source on source site, returns error.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when blocked by instance override on source site, returns error.</summary>
[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>());
}
/// <summary>Verifies that move data connection, when template referenced only by other site, does not block.</summary>
[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>());
}
// ========================================================================
// 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.
// ========================================================================
/// <summary>Verifies that validate template, when dangling ref in shared script, returns invalid result.</summary>
[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);
}
// ========================================================================
// Per-system ESG TimeoutSeconds management commands
// ========================================================================
/// <summary>Verifies that create external system: persists timeout seconds.</summary>
[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>());
}
/// <summary>Verifies that update external system, when null timeout, preserves existing.</summary>
[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.
// ========================================================================
/// <summary>Verifies that create area, when designer or deployer, allowed.</summary>
/// <param name="role">The role.</param>
[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);
}
/// <summary>Verifies that create area, when viewer only, unauthorized.</summary>
[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.
// ========================================================================
/// <summary>Verifies that move area, when designer or deployer, allowed.</summary>
/// <param name="role">The role.</param>
[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);
}
/// <summary>Verifies that move area, when viewer only, unauthorized.</summary>
[Fact]
public void MoveArea_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that move area, when to new parent, returns success and reparents area.</summary>
[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);
}
/// <summary>Verifies that move area, when with null parent, moves to site root.</summary>
[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);
}
/// <summary>Verifies that move area, when under own descendant, returns error.</summary>
[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);
}
/// <summary>Verifies that move area, when area not found, returns error.</summary>
[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.
// ========================================================================
/// <summary>Verifies that browse node, when without site identifier, returns error.</summary>
[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);
}
/// <summary>Verifies that browse node, when designer, relays to site.</summary>
[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);
}
/// <summary>Verifies that browse node, when viewer only, unauthorized.</summary>
[Fact]
public void BrowseNode_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that search address space, when without site identifier, returns error.</summary>
[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);
}
/// <summary>Verifies that search address space, when designer, relays to site.</summary>
[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);
}
/// <summary>Verifies that search address space, when viewer only, unauthorized.</summary>
[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));
}
/// <summary>Verifies that verify endpoint, when without site identifier, returns error.</summary>
[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);
}
/// <summary>Verifies that verify endpoint, when designer, relays to site.</summary>
[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);
}
/// <summary>Verifies that verify endpoint, when viewer only, unauthorized.</summary>
[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).
// ========================================================================
/// <summary>Verifies that trust server cert, when designer only, unauthorized.</summary>
[Fact]
public void TrustServerCert_DesignerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that trust server cert, when without site identifier, returns error.</summary>
[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);
}
/// <summary>Verifies that trust server cert, when admin, relays to site.</summary>
[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);
}
/// <summary>Gitea #43: an admin trust decision is persisted to the central audit log with the real actor and outcome.</summary>
[Fact]
public void TrustServerCert_Admin_WritesCentralAuditRow()
{
var writer = Substitute.For<ICentralAuditWriter>();
_services.AddSingleton(writer);
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var evt = Assert.Single(writer.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
Assert.Equal("Security", evt.Category);
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
Assert.Equal("SITE1/AB12", evt.Target);
Assert.False(string.IsNullOrEmpty(evt.Actor));
}
/// <summary>Gitea #43: a removal is audited too.</summary>
[Fact]
public void RemoveServerCert_Admin_WritesCentralAuditRow()
{
var writer = Substitute.For<ICentralAuditWriter>();
_services.AddSingleton(writer);
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var evt = Assert.Single(writer.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
Assert.Contains("\"kind\":\"CertTrustRemove\"", evt.DetailsJson);
}
/// <summary>Verifies that remove server cert, when admin, relays to site.</summary>
[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);
}
/// <summary>Verifies that remove server cert, when without site identifier, returns error.</summary>
[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);
}
/// <summary>Verifies that list server certs, when admin, relays to site.</summary>
[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);
}
/// <summary>Verifies that list server certs, when designer only, unauthorized.</summary>
[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) ──
/// <summary>Verifies that delete site: disposes the sites cached grpc channels.</summary>
[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
{
/// <summary>Initializes a new instance of the <c>TrackingClient</c> class.</summary>
/// <param name="endpoint">The endpoint.</param>
public TrackingClient(string endpoint) : base(endpoint) { }
/// <summary>Gets or sets the disposed.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public override void Dispose() => Disposed = true;
/// <inheritdoc />
public override ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory
{
/// <summary>Initializes a new instance of the <c>TrackingGrpcFactory</c> class.</summary>
public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { }
/// <inheritdoc />
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
{
/// <summary>Initializes a new instance of the <c>StubCommunicationService</c> class.</summary>
public StubCommunicationService()
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
{
}
/// <summary>Gets or sets the browse count.</summary>
public int BrowseCount { get; private set; }
/// <summary>Gets or sets the last browse site.</summary>
public string? LastBrowseSite { get; private set; }
/// <summary>Gets or sets the search count.</summary>
public int SearchCount { get; private set; }
/// <summary>Gets or sets the last search site.</summary>
public string? LastSearchSite { get; private set; }
/// <summary>Gets or sets the verify count.</summary>
public int VerifyCount { get; private set; }
/// <summary>Gets or sets the last verify site.</summary>
public string? LastVerifySite { get; private set; }
/// <summary>Gets or sets the trust count.</summary>
public int TrustCount { get; private set; }
/// <summary>Gets or sets the last trust site.</summary>
public string? LastTrustSite { get; private set; }
/// <summary>Gets or sets the remove count.</summary>
public int RemoveCount { get; private set; }
/// <summary>Gets or sets the last remove site.</summary>
public string? LastRemoveSite { get; private set; }
/// <summary>Gets or sets the list certs count.</summary>
public int ListCertsCount { get; private set; }
/// <summary>Gets or sets the last list certs site.</summary>
public string? LastListCertsSite { get; private set; }
/// <inheritdoc />
public override Task<CertTrustResult> TrustServerCertAsync(
string siteId, TrustServerCertCommand command, CancellationToken ct = default)
{
TrustCount++;
LastTrustSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
/// <inheritdoc />
public override Task<CertTrustResult> RemoveServerCertAsync(
string siteId, RemoveServerCertCommand command, CancellationToken ct = default)
{
RemoveCount++;
LastRemoveSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
/// <inheritdoc />
public override Task<CertTrustResult> ListServerCertsAsync(
string siteId, ListServerCertsCommand command, CancellationToken ct = default)
{
ListCertsCount++;
LastListCertsSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
/// <inheritdoc />
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));
}
/// <inheritdoc />
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));
}
/// <inheritdoc />
public override Task<VerifyEndpointResult> VerifyEndpointAsync(
string siteId, VerifyEndpointCommand command, CancellationToken ct = default)
{
VerifyCount++;
LastVerifySite = siteId;
return Task.FromResult(new VerifyEndpointResult(true, null, null, null));
}
}
}