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:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent 73a393076a
commit bf6bd8de5a
3 changed files with 447 additions and 64 deletions

View File

@@ -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);
}
}