fix(management-service): resolve ManagementService-014..017 — site-scope enforcement on QueryDeployments, atomic override validation, curated fault messages, test coverage
This commit is contained in:
@@ -218,7 +218,10 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
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);
|
||||
// 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]
|
||||
@@ -480,7 +483,9 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Connection refused", response.Error);
|
||||
// Raw repository fault text is not leaked (finding ManagementService-016).
|
||||
Assert.DoesNotContain("Connection refused", response.Error);
|
||||
Assert.Contains(envelope.CorrelationId, response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -757,4 +762,262 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user