using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any /// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value /// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read". /// public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase { [Theory] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")] [InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")] [InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")] public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config) { var probe = new RecordingProbe(driverType); var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid())); var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); reply.Ok.ShouldBeFalse(); reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused"); reply.Message.ShouldContain("lmxopcua#815"); probe.Calls.ShouldBe(0); } /// Positive control: a namespaced reference does reach the probe (the refusal is not blanket). [Fact] public void A_namespaced_reference_reaches_the_probe() { var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid())); ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue(); probe.Calls.ShouldBe(1); } /// /// lmxopcua#835: a reference naming ANOTHER instance's secret never reaches the probe, whether the caller names /// its own driver or not (a null owner — a not-yet-created driver — is the stricter case). /// [Theory] [InlineData("DRV-evil")] [InlineData(null)] public void A_reference_naming_another_instances_secret_never_reaches_the_probe(string? ownerId) { var dbName = Guid.NewGuid().ToString("N"); var factory = NewInMemoryDbFactory(dbName); SeedGalaxyDrivers(factory); var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/Victim/apikey"}}""", 5, Guid.NewGuid(), ownerId)); var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); reply.Ok.ShouldBeFalse(); reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused"); reply.Message.ShouldContain("lmxopcua#835"); probe.Calls.ShouldBe(0); } /// lmxopcua#849: an env: reference named after ANOTHER instance never reaches the probe either. [Theory] [InlineData("env:OTOPCUA_GALAXY_VICTIM_API_KEY")] [InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")] public void An_env_reference_naming_another_instance_never_reaches_the_probe(string reference) { var factory = NewInMemoryDbFactory(); SeedGalaxyDrivers(factory); var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference), 5, Guid.NewGuid(), "DRV-evil")); var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); reply.Ok.ShouldBeFalse(); reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849"); probe.Calls.ShouldBe(0); } /// Positive control: a shared env variable naming no instance reaches the probe. [Fact] public void A_shared_env_variable_reaches_the_probe() { var factory = NewInMemoryDbFactory(); SeedGalaxyDrivers(factory); var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""", 5, Guid.NewGuid())); ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue(); probe.Calls.ShouldBe(1); } /// Positive control: the owner's own secret (and a shared one) does reach the probe. [Theory] [InlineData("secret:galaxy/DRV-victim/apikey")] [InlineData("secret:galaxy/shared-gw/apikey")] public void The_owners_own_or_a_shared_secret_reaches_the_probe(string reference) { var factory = NewInMemoryDbFactory(); SeedGalaxyDrivers(factory); var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference), 5, Guid.NewGuid(), "DRV-victim")); ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue(); probe.Calls.ShouldBe(1); } /// /// #835 review I-1: Test Connect from the victim's OWN device modal with an unsaved endpoint (the modal merges the /// stored DriverConfig with the typed device config) must not send the victim's own secret to that endpoint. /// [Fact] public void An_unsaved_endpoint_cannot_use_the_named_owners_own_secret() { var factory = NewInMemoryDbFactory(); SeedGalaxyDrivers(factory); var probe = new RecordingProbe(DriverTypeNames.Galaxy); var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""", 5, Guid.NewGuid(), "DRV-victim")); var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); reply.Ok.ShouldBeFalse(); reply.Message.ShouldNotBeNull().ShouldContain("save the change first"); probe.Calls.ShouldBe(0); } /// lmxopcua#836: an absolute OpcUaClient user-certificate path never reaches the probe. [Fact] public void An_absolute_user_certificate_path_never_reaches_the_probe() { var probe = new RecordingProbe(DriverTypeNames.OpcUaClient); var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect( DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","AuthType":"Certificate","UserCertificatePath":"/etc/ssl/private/host.pfx"}""", 5, Guid.NewGuid())); var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); reply.Ok.ShouldBeFalse(); reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#836"); probe.Calls.ShouldBe(0); } private static void SeedGalaxyDrivers(Microsoft.EntityFrameworkCore.IDbContextFactory factory) { using var db = factory.CreateDbContext(); db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance { DriverInstanceId = "DRV-victim", ClusterId = "c", Name = "Victim", DriverType = DriverTypeNames.Galaxy, DriverConfig = """{"Gateway":{"Endpoint":"http://gw:5001"}}""", }); db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance { DriverInstanceId = "DRV-evil", ClusterId = "c", Name = "Evil", DriverType = DriverTypeNames.Galaxy, DriverConfig = "{}", }); db.SaveChanges(); } private sealed class RecordingProbe(string driverType) : IDriverProbe { private int _calls; public int Calls => Volatile.Read(ref _calls); public string DriverType => driverType; public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { Interlocked.Increment(ref _calls); return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero)); } } }