Files
scadalink-design/tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs

1024 lines
40 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"));
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);
}
// ========================================================================
// 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(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()
{
// Records for instances 1 (site 1, in scope) and 2 (site 2, out of scope).
_templateRepo.GetInstanceByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump1") { Id = 1, SiteId = 1 });
_templateRepo.GetInstanceByIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new Instance("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);
}
[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);
}
[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);
}
}