1499 lines
61 KiB
C#
1499 lines
61 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
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.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;
|
|
|
|
public ManagementActorTests()
|
|
{
|
|
_templateRepo = Substitute.For<ITemplateEngineRepository>();
|
|
_auditService = Substitute.For<IAuditService>();
|
|
|
|
_services = new ServiceCollection();
|
|
_services.AddScoped(_ => _templateRepo);
|
|
_services.AddScoped(_ => _auditService);
|
|
_services.AddScoped<InstanceService>();
|
|
}
|
|
|
|
private IActorRef CreateActor()
|
|
{
|
|
var sp = _services.BuildServiceProvider();
|
|
return Sys.ActorOf(Props.Create(() => new ManagementActor(
|
|
sp, NullLogger<ManagementActor>.Instance)));
|
|
}
|
|
|
|
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
|
|
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
|
|
command, Guid.NewGuid().ToString("N"));
|
|
|
|
private static ManagementEnvelope ScopedEnvelope(object command, string[] permittedSiteIds, params string[] roles) =>
|
|
new(new AuthenticatedUser("scopeduser", "Scoped User", roles, permittedSiteIds),
|
|
command, Guid.NewGuid().ToString("N"));
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
Shutdown();
|
|
}
|
|
|
|
// ========================================================================
|
|
// 1. Authorization test -- admin command with wrong role
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateSiteCommand_WithNoRoles_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", null));
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Deployment", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
|
|
{
|
|
// ManagementService-018: QueryAuditLogCommand used to fall through to the
|
|
// default "any authenticated user" case, allowing a Deployment-only or
|
|
// no-role caller to read the configuration audit log via /management
|
|
// even though /api/audit/query enforces OperationalAuditRoles. The fix
|
|
// gates this legacy command to Admin so the older route is never looser
|
|
// than the new REST endpoint.
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 2. Read-only query passes without special role
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ListTemplatesCommand_WithNoRoles_ReturnsSuccess()
|
|
{
|
|
var templates = new List<Template> { new("Template1") { Id = 1 } };
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(templates);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListTemplatesCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListSitesCommand_WithDesignRole_ReturnsSuccess()
|
|
{
|
|
// ListSitesCommand is read-only, any role should work
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Sites.Site>());
|
|
_services.AddScoped(_ => siteRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListSitesCommand(), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 3. Template list test -- verify data flows through
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ListTemplatesCommand_ReturnsTemplateData()
|
|
{
|
|
var templates = new List<Template>
|
|
{
|
|
new("PumpTemplate") { Id = 1, Description = "Pump" },
|
|
new("ValveTemplate") { Id = 2, Description = "Valve" }
|
|
};
|
|
_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.NotNull(response.JsonData);
|
|
Assert.Contains("PumpTemplate", response.JsonData);
|
|
Assert.Contains("ValveTemplate", response.JsonData);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 4. Instance create test
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void CreateInstanceCommand_WithDeploymentRole_ReturnsSuccess()
|
|
{
|
|
var createdInstance = new Instance("Pump1") { Id = 42, TemplateId = 1, SiteId = 1 };
|
|
|
|
// InstanceService calls GetTemplateByIdAsync to verify template exists
|
|
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new Template("PumpTemplate") { Id = 1 });
|
|
|
|
// InstanceService calls GetInstanceByUniqueNameAsync to check uniqueness
|
|
_templateRepo.GetInstanceByUniqueNameAsync("Pump1", Arg.Any<CancellationToken>())
|
|
.Returns((Instance?)null);
|
|
|
|
// InstanceService calls AddInstanceAsync then SaveChangesAsync
|
|
_templateRepo.AddInstanceAsync(Arg.Any<Instance>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(1);
|
|
|
|
_auditService.LogAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new CreateInstanceCommand("Pump1", 1, 1),
|
|
"Deployment");
|
|
|
|
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);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 5. Error handling test -- service throws exception
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
|
|
{
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListTemplatesCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// An unanticipated repository fault must NOT leak its raw message to the
|
|
// caller (finding ManagementService-016); a generic message is returned.
|
|
Assert.DoesNotContain("Database connection lost", response.Error);
|
|
Assert.Contains(envelope.CorrelationId, response.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstanceCommand_WhenServiceFails_ReturnsManagementError()
|
|
{
|
|
// Template not found -- InstanceService will return Result.Failure
|
|
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
|
|
.Returns((Template?)null);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new CreateInstanceCommand("BadInst", 99, 1),
|
|
"Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Role boundary tests -- verify each category
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void DesignCommand_WithAdminRole_ReturnsUnauthorized()
|
|
{
|
|
// CreateTemplateCommand requires "Design" role, "Admin" alone is insufficient
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new CreateTemplateCommand("T1", null, null),
|
|
"Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdminCommand_WithAdminRole_Succeeds()
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(1);
|
|
_services.AddScoped(_ => siteRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new CreateSiteCommand("NewSite", "NS1", "Desc"),
|
|
"Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoleCheck_IsCaseInsensitive()
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(1);
|
|
_services.AddScoped(_ => siteRepo);
|
|
|
|
var actor = CreateActor();
|
|
// "admin" lowercase should still match "Admin" requirement
|
|
var envelope = Envelope(
|
|
new CreateSiteCommand("Site2", "S2", null),
|
|
"admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// New command authorization tests
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
// ========================================================================
|
|
// New command read-only query tests (no role required)
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ListSharedScripts_WithNoRoles_ReturnsSuccess()
|
|
{
|
|
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
|
|
_services.AddScoped<SharedScriptService>();
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListSharedScriptsCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListDatabaseConnections_WithNoRoles_ReturnsSuccess()
|
|
{
|
|
var extRepo = Substitute.For<IExternalSystemRepository>();
|
|
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
|
|
_services.AddScoped(_ => extRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListDatabaseConnectionsCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListApiMethods_WithNoRoles_ReturnsSuccess()
|
|
{
|
|
var apiRepo = Substitute.For<IInboundApiRepository>();
|
|
apiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
|
|
_services.AddScoped(_ => apiRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListApiMethodsCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListScopeRules_WithAdminRole_ReturnsSuccess()
|
|
{
|
|
var secRepo = Substitute.For<ISecurityRepository>();
|
|
secRepo.GetScopeRulesForMappingAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Security.SiteScopeRule>());
|
|
_services.AddScoped(_ => secRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListScopeRulesCommand(1), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// New command error handling tests
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ListDatabaseConnections_WhenRepoThrows_ReturnsError()
|
|
{
|
|
var extRepo = Substitute.For<IExternalSystemRepository>();
|
|
extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("Connection refused"));
|
|
_services.AddScoped(_ => extRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListDatabaseConnectionsCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// Raw repository fault text is not leaked (finding ManagementService-016).
|
|
Assert.DoesNotContain("Connection refused", response.Error);
|
|
Assert.Contains(envelope.CorrelationId, response.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Site-scope enforcement tests (findings ManagementService-001 / -002)
|
|
//
|
|
// A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must
|
|
// not be able to read or act on entities belonging to a site outside their
|
|
// scope. The handlers must throw SiteScopeViolationException, which the
|
|
// actor maps to ManagementUnauthorized.
|
|
// ========================================================================
|
|
|
|
private void AddSiteRepoWithSite(int siteId, string identifier)
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetSiteByIdAsync(siteId, Arg.Any<CancellationToken>())
|
|
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
|
|
siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
|
|
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
|
|
_services.AddScoped(_ => siteRepo);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
// Instance belongs to site 2; user is scoped to site 1.
|
|
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
|
|
{
|
|
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
var uiRepo = Substitute.For<ICentralUiRepository>();
|
|
uiRepo.GetAreaTreeBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Instances.Area>());
|
|
_services.AddScoped(_ => uiRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(new Commons.Entities.Sites.DataConnection("Conn5", "OpcUa", 2) { Id = 5 });
|
|
_services.AddScoped(_ => siteRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSite_OutOfScopeForAdminUser_ReturnsSuccess()
|
|
{
|
|
// Admin role bypasses site scoping even when PermittedSiteIds is set.
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryEventLogs_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
// Site SITE2 has Id 2; user scoped to site 1.
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetryParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DiscardParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebugSnapshot_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
|
{
|
|
// Instance 9 belongs to site 2; user scoped to site 1.
|
|
_templateRepo.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Pump9") { Id = 9, SiteId = 2 });
|
|
AddSiteRepoWithSite(2, "SITE2");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Serialization (finding ManagementService-007)
|
|
//
|
|
// Command results are serialized with System.Text.Json configured with
|
|
// ReferenceHandler.IgnoreCycles, so an entity graph with a bidirectional
|
|
// navigation property does not throw. Property names are camelCase, which
|
|
// the CLI's case-insensitive deserializer accepts.
|
|
// ========================================================================
|
|
|
|
private sealed class CyclicNode
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public CyclicNode? Parent { get; set; }
|
|
public List<CyclicNode> Children { get; set; } = new();
|
|
}
|
|
|
|
[Fact]
|
|
public void SerializeResult_WithCyclicGraph_DoesNotThrow()
|
|
{
|
|
var parent = new CyclicNode { Name = "Parent" };
|
|
var child = new CyclicNode { Name = "Child", Parent = parent };
|
|
parent.Children.Add(child); // parent <-> child cycle
|
|
|
|
var json = ManagementActor.SerializeResult(parent);
|
|
|
|
Assert.Contains("Parent", json);
|
|
Assert.Contains("Child", json);
|
|
}
|
|
|
|
[Fact]
|
|
public void SerializeResult_UsesCamelCasePropertyNames()
|
|
{
|
|
var json = ManagementActor.SerializeResult(new CyclicNode { Name = "X" });
|
|
|
|
Assert.Contains("\"name\"", json);
|
|
Assert.DoesNotContain("\"Name\"", json);
|
|
}
|
|
|
|
// ========================================================================
|
|
// PipeTo fault mapping (finding ManagementService-004)
|
|
//
|
|
// Command processing is piped back via PipeTo; a fault raised inside
|
|
// DispatchCommand must be mapped to ManagementError by the failure
|
|
// continuation rather than escaping or being silently dropped.
|
|
// ========================================================================
|
|
|
|
// ========================================================================
|
|
// ResolveRolesCommand dead-code removal (finding ManagementService-011 / -008)
|
|
//
|
|
// The two-step ResolveRoles + command flow is retired: the HTTP endpoint does
|
|
// LDAP auth and role resolution itself. The actor must no longer dispatch
|
|
// ResolveRolesCommand — a stray ClusterClient sender hitting it gets a uniform
|
|
// ManagementError rather than an unauthenticated role-mapping enumeration.
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ResolveRolesCommand_IsNoLongerDispatched_ReturnsManagementError()
|
|
{
|
|
var secRepo = Substitute.For<ISecurityRepository>();
|
|
_services.AddScoped(_ => secRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ResolveRolesCommand(new[] { "cn=admins" }));
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// No role-mapping data is enumerated/leaked back to the caller.
|
|
secRepo.DidNotReceiveWithAnyArgs().GetAllMappingsAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownCommandType_FaultMappedToManagementError()
|
|
{
|
|
// ManagementEnvelope.Command is typed object; an unrecognised payload
|
|
// makes DispatchCommand throw NotSupportedException. The PipeTo failure
|
|
// continuation must map it to ManagementError.
|
|
var actor = CreateActor();
|
|
var envelope = Envelope("not-a-command");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
}
|
|
|
|
// ========================================================================
|
|
// QueryDeployments authorization + site-scope (findings -014 / -017)
|
|
//
|
|
// QueryDeploymentsCommand is gated to the Deployment role and, like every
|
|
// other Deployment-role handler, must enforce site scoping: a site-scoped
|
|
// user must not see deployment records for instances at other sites.
|
|
// ========================================================================
|
|
|
|
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
|
|
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
|
|
|
|
[Fact]
|
|
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new QueryDeploymentsCommand(), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Deployment", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
|
|
{
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
{
|
|
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
});
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new QueryDeploymentsCommand(), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("deploy-1", response.JsonData);
|
|
Assert.Contains("deploy-2", response.JsonData);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
|
|
{
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("deploy-5", response.JsonData);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_FilteredByOutOfScopeInstance_ReturnsUnauthorized()
|
|
{
|
|
// Instance 5 belongs to site 2; user is scoped to site 1.
|
|
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Pump5") { Id = 5, SiteId = 2 });
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment");
|
|
|
|
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().GetDeploymentsByInstanceIdAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_FilteredByInScopeInstance_ReturnsRecords()
|
|
{
|
|
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("deploy-5", response.JsonData);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
|
|
{
|
|
// ManagementService-023: the unfiltered branch now bulk-loads instances
|
|
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
|
|
// distinct InstanceId). Mock the bulk path accordingly.
|
|
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Instance>
|
|
{
|
|
new("Pump1") { Id = 1, SiteId = 1 },
|
|
new("Pump2") { Id = 2, SiteId = 2 },
|
|
});
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
{
|
|
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
});
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("deploy-1", response.JsonData);
|
|
Assert.DoesNotContain("deploy-2", response.JsonData);
|
|
// The per-instance lookup must NOT have been used for the unfiltered
|
|
// branch — that was the N+1 the bulk load replaced.
|
|
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
|
|
{
|
|
// ManagementService-023 regression pin: the unfiltered branch must issue
|
|
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
|
|
// of how many DeploymentRecords reference distinct InstanceIds. Before the
|
|
// fix, three distinct instance ids would have produced three per-instance
|
|
// lookups (textbook N+1).
|
|
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Instance>
|
|
{
|
|
new("Pump1") { Id = 1, SiteId = 1 },
|
|
new("Pump2") { Id = 2, SiteId = 2 },
|
|
new("Pump3") { Id = 3, SiteId = 1 },
|
|
});
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
{
|
|
DeploymentRecordFor(1), DeploymentRecordFor(2), DeploymentRecordFor(3),
|
|
DeploymentRecordFor(1), DeploymentRecordFor(3) // duplicates: still no extra lookups
|
|
});
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
|
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
|
|
{
|
|
// Admin role bypasses site scoping even with PermittedSiteIds set.
|
|
// (The user also holds Deployment so it passes the role gate.)
|
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
{
|
|
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
});
|
|
_services.AddScoped(_ => deployRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Admin", "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("deploy-1", response.JsonData);
|
|
Assert.Contains("deploy-2", response.JsonData);
|
|
}
|
|
|
|
// ========================================================================
|
|
// SetInstanceOverrides atomicity (finding -015)
|
|
//
|
|
// A multi-override command must be all-or-nothing: if any requested override
|
|
// is invalid (unknown/locked attribute), the handler must reject the whole
|
|
// command up front WITHOUT persisting any of the overrides.
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()
|
|
{
|
|
// Instance 3, template 1 with a single valid attribute "Good".
|
|
var instance = new Instance("Pump3") { Id = 3, SiteId = 1, TemplateId = 1 };
|
|
_templateRepo.GetInstanceByIdAsync(3, Arg.Any<CancellationToken>()).Returns(instance);
|
|
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<TemplateAttribute> { new("Good") { Id = 1, TemplateId = 1 } });
|
|
_templateRepo.GetOverridesByInstanceIdAsync(3, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
|
|
|
|
var actor = CreateActor();
|
|
// "Good" is valid, "Bogus" is not — the whole command must fail with
|
|
// nothing written.
|
|
var overrides = new Dictionary<string, string?> { ["Good"] = "1", ["Bogus"] = "2" };
|
|
var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// No override write occurred for the valid attribute either.
|
|
_templateRepo.DidNotReceiveWithAnyArgs()
|
|
.AddInstanceAttributeOverrideAsync(default!, default);
|
|
_templateRepo.DidNotReceiveWithAnyArgs()
|
|
.UpdateInstanceAttributeOverrideAsync(default!, default);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetInstanceOverrides_AllValid_PersistsAllOverrides()
|
|
{
|
|
var instance = new Instance("Pump4") { Id = 4, SiteId = 1, TemplateId = 1 };
|
|
_templateRepo.GetInstanceByIdAsync(4, Arg.Any<CancellationToken>()).Returns(instance);
|
|
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<TemplateAttribute>
|
|
{
|
|
new("A") { Id = 1, TemplateId = 1 },
|
|
new("B") { Id = 2, TemplateId = 1 }
|
|
});
|
|
_templateRepo.GetOverridesByInstanceIdAsync(4, Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
|
|
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
|
|
|
|
var actor = CreateActor();
|
|
var overrides = new Dictionary<string, string?> { ["A"] = "1", ["B"] = "2" };
|
|
var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
_templateRepo.ReceivedWithAnyArgs(2)
|
|
.AddInstanceAttributeOverrideAsync(default!, default);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Unexpected exception messages not leaked to callers (finding -016)
|
|
//
|
|
// MapFault must distinguish handler-curated failures (safe to surface) from
|
|
// unanticipated faults (whose raw .Message can disclose internal detail).
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void UnexpectedFault_ReturnsGenericMessage_NotRawExceptionText()
|
|
{
|
|
// Repository throws an unanticipated fault carrying sensitive-looking
|
|
// detail. The raw text must NOT reach the caller.
|
|
const string secret = "Server=db-internal-prod;constraint FK_secret";
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidProgramException(secret));
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListTemplatesCommand());
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
Assert.DoesNotContain(secret, response.Error);
|
|
// The correlation ID is surfaced so the operator can find the server log.
|
|
Assert.Contains(envelope.CorrelationId, response.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// UpdateSmtpConfig — TlsMode + Credentials plumbing (preserve-if-null)
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void UpdateSmtpConfig_WithTlsModeAndCredentials_PersistsThem()
|
|
{
|
|
var notifRepo = Substitute.For<INotificationRepository>();
|
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
|
"old.example.com", "OAuth2", "old@example.com")
|
|
{
|
|
Id = 1,
|
|
Port = 25,
|
|
TlsMode = "StartTLS",
|
|
Credentials = "old-secret",
|
|
};
|
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
|
_services.AddScoped(_ => notifRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
|
|
"Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
Assert.Equal("SSL", existing.TlsMode);
|
|
Assert.Equal("user:pass", existing.Credentials);
|
|
Assert.Equal("new.example.com", existing.Host);
|
|
Assert.Equal("Basic", existing.AuthType);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateSmtpConfig_WithNullTlsModeAndCredentials_PreservesExistingValues()
|
|
{
|
|
var notifRepo = Substitute.For<INotificationRepository>();
|
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
|
"old.example.com", "OAuth2", "old@example.com")
|
|
{
|
|
Id = 1,
|
|
Port = 25,
|
|
TlsMode = "StartTLS",
|
|
Credentials = "old-secret",
|
|
};
|
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
|
_services.AddScoped(_ => notifRepo);
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
|
|
"Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
|
// Omitted fields are preserved, not nulled.
|
|
Assert.Equal("StartTLS", existing.TlsMode);
|
|
Assert.Equal("old-secret", existing.Credentials);
|
|
// Provided fields are still updated.
|
|
Assert.Equal("new.example.com", existing.Host);
|
|
Assert.Equal("Basic", existing.AuthType);
|
|
}
|
|
|
|
[Fact]
|
|
public void 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), "Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// The curated InstanceService failure message is still surfaced verbatim.
|
|
Assert.Contains("99", response.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// ManagementService-021: Transport (#24) bundle handler coverage
|
|
//
|
|
// The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
|
|
// HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
|
|
// tests. The cases below pin the load-bearing behaviours: role gating,
|
|
// ExportBundle name resolution, ImportBundle blocker rejection, and the
|
|
// ImportBundle (EntityType, Name) dedupe.
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
|
|
/// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
|
|
/// service collection plus minimal repositories the bundle handlers query
|
|
/// from the export side. Returns both substitutes so a test can configure
|
|
/// their behaviour.
|
|
/// </summary>
|
|
private (Commons.Interfaces.Transport.IBundleExporter Exporter,
|
|
Commons.Interfaces.Transport.IBundleImporter Importer)
|
|
AddBundleSubstitutes()
|
|
{
|
|
var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
|
|
var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
|
|
_services.AddSingleton(exporter);
|
|
_services.AddSingleton(importer);
|
|
|
|
// The repository fan-out HandleExportBundle does at the top of its body.
|
|
// Tests that only exercise role gating still need these resolved.
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Template>());
|
|
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
|
|
|
|
var externalRepo = Substitute.For<IExternalSystemRepository>();
|
|
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
|
|
externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
|
|
_services.AddScoped(_ => externalRepo);
|
|
|
|
var notifRepo = Substitute.For<INotificationRepository>();
|
|
notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Notifications.NotificationList>());
|
|
notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
|
|
_services.AddScoped(_ => notifRepo);
|
|
|
|
var inboundRepo = Substitute.For<IInboundApiRepository>();
|
|
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
|
|
_services.AddScoped(_ => inboundRepo);
|
|
|
|
return (exporter, importer);
|
|
}
|
|
|
|
private static ExportBundleCommand AllExportCommand() =>
|
|
new(All: true,
|
|
TemplateNames: null, SharedScriptNames: null,
|
|
ExternalSystemNames: null, DatabaseConnectionNames: null,
|
|
NotificationListNames: null, SmtpConfigurationNames: null,
|
|
ApiMethodNames: null,
|
|
IncludeDependencies: false, Passphrase: null,
|
|
SourceEnvironment: "test-env");
|
|
|
|
[Fact]
|
|
public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
|
|
{
|
|
// ExportBundle requires the Design role; an Admin-only caller is rejected.
|
|
AddBundleSubstitutes();
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(AllExportCommand(), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
// PreviewBundle requires the Admin role (Design role isn't enough,
|
|
// mirroring the Central UI gating — only Admin imports cross-cutting
|
|
// configuration).
|
|
AddBundleSubstitutes();
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
AddBundleSubstitutes();
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Admin", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
|
|
{
|
|
// ResolveIds throws ManagementCommandException for unknown names; that
|
|
// curated message must surface verbatim to the caller.
|
|
AddBundleSubstitutes();
|
|
// No templates in the repo; the export selection names one anyway.
|
|
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Template>());
|
|
|
|
var cmd = new ExportBundleCommand(
|
|
All: false,
|
|
TemplateNames: new[] { "DoesNotExist" },
|
|
SharedScriptNames: null,
|
|
ExternalSystemNames: null, DatabaseConnectionNames: null,
|
|
NotificationListNames: null, SmtpConfigurationNames: null,
|
|
ApiMethodNames: null,
|
|
IncludeDependencies: false, Passphrase: null,
|
|
SourceEnvironment: "test-env");
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(cmd, "Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
// The ManagementCommandException message surfaces verbatim — it names
|
|
// the missing entity type and the missing name.
|
|
Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("DoesNotExist", response.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void 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>())
|
|
.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"), "Admin");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
|
Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("BlockedTemplate", response.Error);
|
|
|
|
// Apply must NOT have been called — the handler aborts before it.
|
|
importer.DidNotReceive().ApplyAsync(
|
|
Arg.Any<Guid>(),
|
|
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
|
|
{
|
|
// The handler dedupes by (EntityType, Name) before calling ApplyAsync —
|
|
// last-write-wins, matching the Central UI's TransportImport behavior.
|
|
// The preview here emits THREE rows for the same (Template, "Dup"):
|
|
// an Identical then a Modified then an Identical. After dedupe, only
|
|
// one resolution must reach the importer for that key.
|
|
var (_, importer) = AddBundleSubstitutes();
|
|
|
|
var sessionId = Guid.NewGuid();
|
|
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
|
.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;
|
|
importer.ApplyAsync(
|
|
Arg.Any<Guid>(),
|
|
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
|
|
r => captured = r),
|
|
Arg.Any<string>(),
|
|
Arg.Any<CancellationToken>())
|
|
.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"), "Admin");
|
|
|
|
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);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Native alarm source CRUD (Task 21)
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
|
|
"Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
_templateRepo.ReceivedWithAnyArgs(1).AddTemplateNativeAlarmSourceAsync(default!, default);
|
|
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddTemplateNativeAlarmSource_WithDeploymentRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
|
|
"Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Design", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListTemplateNativeAlarmSources_ReturnsData()
|
|
{
|
|
_templateRepo.GetNativeAlarmSourcesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<TemplateNativeAlarmSource>
|
|
{
|
|
new("Pressure") { Id = 1, TemplateId = 1, ConnectionName = "Opc", SourceReference = "ns=2;s=T01" }
|
|
});
|
|
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(new ListTemplateNativeAlarmSourcesCommand(1));
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Pressure", response.JsonData);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
|
|
{
|
|
// No prior override → Add path.
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
|
"Deployment");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
_templateRepo.ReceivedWithAnyArgs(1).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
|
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
var envelope = Envelope(
|
|
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
|
"Design");
|
|
|
|
actor.Tell(envelope);
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Deployment", response.Message);
|
|
}
|
|
}
|