fix(management): elide DataConnection config secrets from responses and audit (arch-review C3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Secret-elision projection tests (arch-review C3): connection-config secrets
|
||||
/// (OPC UA passwords, external-system auth config, DB connection strings) must
|
||||
/// never leave the management boundary via List/Get responses or audit afterState,
|
||||
/// and sentinel/omitted round-trips through Update must preserve the stored secret.
|
||||
/// </summary>
|
||||
public class SecretProjectionTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
public SecretProjectionTests()
|
||||
{
|
||||
_auditService = Substitute.For<IAuditService>();
|
||||
_services = new ServiceCollection();
|
||||
_services.AddScoped(_ => _auditService);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// ========================================================================
|
||||
// Task 7 — DataConnection config secret elision
|
||||
// ========================================================================
|
||||
|
||||
private ISiteRepository AddSiteRepoWithConnection(DataConnection conn)
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(conn.Id, Arg.Any<CancellationToken>()).Returns(conn);
|
||||
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection> { conn });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
return siteRepo;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDataConnection_ResponseOmitsPasswords()
|
||||
{
|
||||
AddSiteRepoWithConnection(new DataConnection("c1", "OpcUa", 1)
|
||||
{
|
||||
Id = 1,
|
||||
PrimaryConfiguration = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Password":"opc-secret"}}""",
|
||||
});
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new GetDataConnectionCommand(1), "Viewer"));
|
||||
|
||||
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.DoesNotContain("opc-secret", resp.JsonData);
|
||||
Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListDataConnections_ResponseOmitsPasswords()
|
||||
{
|
||||
AddSiteRepoWithConnection(new DataConnection("c1", "OpcUa", 1)
|
||||
{
|
||||
Id = 1,
|
||||
PrimaryConfiguration = """{"UserIdentity":{"Password":"opc-secret"}}""",
|
||||
});
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ListDataConnectionsCommand(null), "Viewer"));
|
||||
|
||||
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.DoesNotContain("opc-secret", resp.JsonData);
|
||||
Assert.Contains("hasSecrets", resp.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
|
||||
{
|
||||
var stored = new DataConnection("c1", "OpcUa", 1)
|
||||
{
|
||||
Id = 1,
|
||||
PrimaryConfiguration = """{"Endpoint":"opc.tcp://old","UserIdentity":{"Password":"opc-secret"}}""",
|
||||
};
|
||||
var siteRepo = AddSiteRepoWithConnection(stored);
|
||||
|
||||
var actor = CreateActor();
|
||||
var incoming = """{"Endpoint":"opc.tcp://new","UserIdentity":{"Password":"***REDACTED***"}}""";
|
||||
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", incoming, null, 3), "Designer"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
await siteRepo.Received(1).UpdateDataConnectionAsync(
|
||||
Arg.Is<DataConnection>(c => c.PrimaryConfiguration!.Contains("opc-secret")
|
||||
&& !c.PrimaryConfiguration.Contains("***REDACTED***")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
|
||||
{
|
||||
var stored = new DataConnection("c1", "OpcUa", 1)
|
||||
{
|
||||
Id = 1,
|
||||
PrimaryConfiguration = """{"Password":"opc-secret"}""",
|
||||
};
|
||||
AddSiteRepoWithConnection(stored);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", """{"Password":"newpw"}""", null, 3), "Designer"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "DataConnection",
|
||||
Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user