using System.IO.Compression; using System.Text; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; 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; /// /// R03:3.2 — StartDeployment used to read the whole config DB twice: once for the validator, /// once for the composer. An edit landing between the two shipped an artifact the validator never saw. /// The deploy now validates and composes ONE snapshot, so an edit that lands mid-deploy is either /// validated (it was in the snapshot) or absent from the artifact (it was not) — never shipped unseen. /// public sealed class AdminOperationsActorSingleSnapshotTests : ControlPlaneActorTestBase { private const string LateEquipmentId = "EQ-late-and-NOT-canonical"; /// /// An Equipment with a non-canonical id (the validator rejects those with EquipmentIdNotDerived) /// is committed by another context while the snapshot read is in progress, AFTER the Equipment table /// has been read. The validator cannot see it, so the deploy is Accepted — and the artifact must not /// carry it either. Positive control: the row really landed in the DB, and a deploy started after it /// is Rejected on exactly that rule. /// [Fact] public void Edit_landing_between_validate_and_compose_cannot_ship_unvalidated() { // One shared root: an InMemory store is scoped to the internal service provider, and a context // carrying an interceptor builds its own provider — without the shared root the intercepting // context would read an EMPTY database and the whole test would be vacuous. var dbName = Guid.NewGuid().ToString("N"); var root = new InMemoryDatabaseRoot(); var plain = new RootedDbFactory(dbName, root, interceptor: null); using (var db = plain.CreateDbContext()) { // A Tag to materialise — Tags is read AFTER Equipment by the snapshot factory, so an insert // fired on the first Tag lands "between" the two tables of one read. db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}", }); db.Devices.Add(new Device { DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}" }); db.Tags.Add(new Tag { TagId = "tag-speed", DeviceId = "dev", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); db.SaveChanges(); } var injecting = new RootedDbFactory(dbName, root, new LateEquipmentInterceptor(plain)); var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(injecting, coordinator.Ref, Enumerable.Empty())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); var reply = ExpectMsg(PresenceBudget); reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, reply.Message); coordinator.ExpectMsg(PresenceBudget); using (var verify = plain.CreateDbContext()) { // The injection happened... verify.Equipment.Any(e => e.EquipmentId == LateEquipmentId).ShouldBeTrue(); // ...and the shipped artifact does not contain what the validator never saw. var row = verify.Deployments.Single(); Inflate(row.ArtifactBlob).ShouldNotContain(LateEquipmentId); // The coordinator is a probe here, so seal the row by hand to let the next deploy through. row.Status = DeploymentStatus.Sealed; verify.SaveChanges(); } // Positive control: now that the row is visible to a fresh read, the gate rejects it. actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); var second = ExpectMsg(PresenceBudget); second.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); second.Message.ShouldNotBeNull(); second.Message.ShouldContain("EquipmentIdNotDerived"); } private static string Inflate(byte[] blob) { using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress); using var reader = new StreamReader(gz, Encoding.UTF8); return reader.ReadToEnd(); } /// On the first Tag materialised, commits a non-canonical Equipment through ANOTHER context. private sealed class LateEquipmentInterceptor(IDbContextFactory other) : IMaterializationInterceptor { private int _fired; public object InitializedInstance(MaterializationInterceptionData materializationData, object entity) { if (entity is Tag && Interlocked.Exchange(ref _fired, 1) == 0) { using var db = other.CreateDbContext(); db.Equipment.Add(new Equipment { EquipmentUuid = Guid.NewGuid(), EquipmentId = LateEquipmentId, Name = "late", UnsLineId = "line-a", MachineCode = "m", }); db.SaveChanges(); } return entity; } } private sealed class RootedDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor? interceptor) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() { var builder = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName, root); if (interceptor is not null) builder.AddInterceptors(interceptor); return new OtOpcUaConfigDbContext(builder.Options); } } }