Add 33 new management message records, ManagementActor handlers, and CLI commands to close all functionality gaps between the Central UI and the Management CLI. New capabilities include: - Template member CRUD (attributes, alarms, scripts, compositions) - Shared script CRUD - Database connection definition CRUD - Inbound API method CRUD - LDAP scope rule management - API key enable/disable - Area update - Remote event log and parked message queries - Missing get/update commands for templates, sites, instances, data connections, external systems, notifications, and SMTP config Includes 12 new ManagementActor unit tests covering authorization, happy-path queries, and error handling. Updates CLI README and component design documents (Component-CLI.md, Component-ManagementService.md).
482 lines
17 KiB
C#
482 lines
17 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ScadaLink.Commons.Entities.Instances;
|
|
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.Management;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.ManagementService;
|
|
using ScadaLink.TemplateEngine;
|
|
using ScadaLink.TemplateEngine.Services;
|
|
|
|
namespace ScadaLink.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"));
|
|
|
|
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);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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);
|
|
Assert.Contains("Database connection lost", 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(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);
|
|
Assert.Contains("Connection refused", response.Error);
|
|
}
|
|
}
|