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; /// /// 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. /// public class SecretProjectionTests : TestKit, IDisposable { private readonly IAuditService _auditService; private readonly ServiceCollection _services; public SecretProjectionTests() { _auditService = Substitute.For(); _services = new ServiceCollection(); _services.AddScoped(_ => _auditService); } private IActorRef CreateActor() { var sp = _services.BuildServiceProvider(); return Sys.ActorOf(Props.Create(() => new ManagementActor( sp, NullLogger.Instance))); } private static ManagementEnvelope Envelope(object command, params string[] roles) => new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty()), command, Guid.NewGuid().ToString("N")); void IDisposable.Dispose() => Shutdown(); // ======================================================================== // Task 7 — DataConnection config secret elision // ======================================================================== private ISiteRepository AddSiteRepoWithConnection(DataConnection conn) { var siteRepo = Substitute.For(); siteRepo.GetDataConnectionByIdAsync(conn.Id, Arg.Any()).Returns(conn); siteRepo.GetAllDataConnectionsAsync(Arg.Any()) .Returns(new List { 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(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(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(TimeSpan.FromSeconds(5)); await siteRepo.Received(1).UpdateDataConnectionAsync( Arg.Is(c => c.PrimaryConfiguration!.Contains("opc-secret") && !c.PrimaryConfiguration.Contains("***REDACTED***")), Arg.Any()); } [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(TimeSpan.FromSeconds(5)); _auditService.Received().LogAsync(Arg.Any(), "Update", "DataConnection", Arg.Any(), Arg.Any(), Arg.Is(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any()); } // ======================================================================== // Task 8 — ExternalSystem AuthConfiguration elision // ======================================================================== private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def) { var repo = Substitute.For(); repo.GetExternalSystemByIdAsync(def.Id, Arg.Any()).Returns(def); repo.GetAllExternalSystemsAsync(Arg.Any()) .Returns(new List { def }); _services.AddScoped(_ => repo); return repo; } [Fact] public void GetExternalSystem_ResponseOmitsAuthConfiguration() { AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey") { Id = 1, AuthConfiguration = """{"apiKey":"es-secret"}""", }); var actor = CreateActor(); actor.Tell(Envelope(new GetExternalSystemCommand(1), "Viewer")); var resp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.DoesNotContain("es-secret", resp.JsonData); Assert.Contains("hasAuthConfiguration", resp.JsonData); } [Fact] public void ListExternalSystems_ResponseOmitsAuthConfiguration() { AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey") { Id = 1, AuthConfiguration = """{"apiKey":"es-secret"}""", }); var actor = CreateActor(); actor.Tell(Envelope(new ListExternalSystemsCommand(), "Viewer")); var resp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.DoesNotContain("es-secret", resp.JsonData); Assert.Contains("hasAuthConfiguration", resp.JsonData); } [Fact] public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret() { var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey") { Id = 1, AuthConfiguration = """{"apiKey":"es-secret"}""", }; var repo = AddExternalSystemRepo(stored); var actor = CreateActor(); actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", null), "Designer")); ExpectMsg(TimeSpan.FromSeconds(5)); await repo.Received(1).UpdateExternalSystemAsync( Arg.Is(d => d.AuthConfiguration == """{"apiKey":"es-secret"}"""), Arg.Any()); } [Fact] public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration() { var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey") { Id = 1, AuthConfiguration = """{"apiKey":"es-secret"}""", }; AddExternalSystemRepo(stored); var actor = CreateActor(); actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", """{"apiKey":"newsecret"}"""), "Designer")); ExpectMsg(TimeSpan.FromSeconds(5)); _auditService.Received().LogAsync(Arg.Any(), "Update", "ExternalSystem", Arg.Any(), Arg.Any(), Arg.Is(o => !ManagementActor.SerializeResult(o).Contains("newsecret")), Arg.Any()); } // ======================================================================== // Task 9 — DatabaseConnection ConnectionString elision // ======================================================================== private IExternalSystemRepository AddDatabaseConnectionRepo(DatabaseConnectionDefinition def) { var repo = Substitute.For(); repo.GetDatabaseConnectionByIdAsync(def.Id, Arg.Any()).Returns(def); repo.GetAllDatabaseConnectionsAsync(Arg.Any()) .Returns(new List { def }); _services.AddScoped(_ => repo); return repo; } [Fact] public void GetDatabaseConnection_ResponseOmitsConnectionString() { AddDatabaseConnectionRepo(new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 }); var actor = CreateActor(); actor.Tell(Envelope(new GetDatabaseConnectionCommand(1), "Viewer")); var resp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.DoesNotContain("sql-secret", resp.JsonData); Assert.Contains("hasConnectionString", resp.JsonData); } [Fact] public void ListDatabaseConnections_ResponseOmitsConnectionString() { AddDatabaseConnectionRepo(new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 }); var actor = CreateActor(); actor.Tell(Envelope(new ListDatabaseConnectionsCommand(), "Viewer")); var resp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.DoesNotContain("sql-secret", resp.JsonData); Assert.Contains("hasConnectionString", resp.JsonData); } [Fact] public async Task UpdateDatabaseConnection_NullConnectionString_PreservesStoredSecret() { var stored = new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 }; var repo = AddDatabaseConnectionRepo(stored); var actor = CreateActor(); actor.Tell(Envelope(new UpdateDatabaseConnectionDefCommand(1, "db-renamed", null), "Designer")); ExpectMsg(TimeSpan.FromSeconds(5)); await repo.Received(1).UpdateDatabaseConnectionAsync( Arg.Is(d => d.ConnectionString == "Server=db;Password=sql-secret"), Arg.Any()); } }