From 481985e20b237be19e67b596853e50cb187c8a1f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 03:29:41 -0500 Subject: [PATCH] fix(security): preflight reads the latest sealed artifact; byte[] length predicate untranslatable on SQL Server (#815 live gate) --- .../CredentialReferencePreflight.cs | 8 ++- ...dentialReferencePreflightSqlServerTests.cs | 57 +++++++++++++++++++ .../CredentialReferencePreflightTests.cs | 12 +++- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightSqlServerTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs index a87f361f7..b553bce25 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; 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.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -53,12 +54,15 @@ public sealed class CredentialReferencePreflight(IDbContextFactory ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false); + // The latest SEALED deployment is what the fleet runs (a failed or in-flight one is not). The empty-blob check + // stays client-side: `byte[].Length` in a predicate is not translatable on SQL Server (#815 live gate — the + // whole scan threw on every boot while the EF InMemory suite passed). var latestBlob = await db.Deployments.AsNoTracking() - .Where(d => d.ArtifactBlob.Length > 0) + .Where(d => d.Status == DeploymentStatus.Sealed) .OrderByDescending(d => d.CreatedAtUtc) .Select(d => d.ArtifactBlob) .FirstOrDefaultAsync(ct).ConfigureAwait(false); - var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob); + var deployed = latestBlob is not { Length: > 0 } ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob); return Scan(ruled, devices, deployed); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightSqlServerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightSqlServerTests.cs new file mode 100644 index 000000000..6844670e9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightSqlServerTests.cs @@ -0,0 +1,57 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments; + +/// +/// The SQL-Server-backed half of (lmxopcua#815). EF InMemory runs +/// ScanAsync's deployment query as LINQ-to-Objects, so an untranslatable predicate passes there and throws on +/// every real boot — exactly what the #815 live gate found (ArtifactBlob.Length > 0). Here it must translate. +/// +[Collection(nameof(AdminUiSqlServerCollection))] +public sealed class CredentialReferencePreflightSqlServerTests(AdminUiSqlServerFixture sql) +{ + [SqlServerFact] + public async Task ScanAsync_translates_and_reads_the_latest_sealed_artifact() + { + // Far-future timestamps so this test's rows are the latest whatever else the shared database holds. + var anchor = new DateTime(2999, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var sealedId = Guid.NewGuid(); + var failedId = Guid.NewGuid(); + const string artifact = """ + {"DriverInstances":[{"DriverInstanceId":"ouc-sql","Name":"UpstreamSql","DriverType":"OpcUaClient", + "DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]} + """; + await using (var db = await sql.Factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) + { + db.Deployments.Add(new Deployment + { + DeploymentId = sealedId, RevisionHash = "preflight-sealed", CreatedBy = "t", Status = DeploymentStatus.Sealed, + CreatedAtUtc = anchor, ArtifactBlob = Encoding.UTF8.GetBytes(artifact), + }); + db.Deployments.Add(new Deployment + { + DeploymentId = failedId, RevisionHash = "preflight-failed", CreatedBy = "t", Status = DeploymentStatus.PartiallyFailed, + CreatedAtUtc = anchor.AddMinutes(1), ArtifactBlob = Encoding.UTF8.GetBytes("""{"DriverInstances":[]}"""), + }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + try + { + var findings = await new CredentialReferencePreflight(sql.Factory).ScanAsync(TestContext.Current.CancellationToken); + + findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "UpstreamSql"); + } + finally + { + await using var db = await sql.Factory.CreateDbContextAsync(CancellationToken.None); + await db.Deployments.Where(d => d.DeploymentId == sealedId || d.DeploymentId == failedId).ExecuteDeleteAsync(CancellationToken.None); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs index dbc05518c..553f9b17f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; 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.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments; @@ -48,7 +49,16 @@ public sealed class CredentialReferencePreflightTests {"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient", "DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]} """; - db.Deployments.Add(new Deployment { RevisionHash = "abc", CreatedBy = "t", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) }); + db.Deployments.Add(new Deployment + { + RevisionHash = "abc", CreatedBy = "t", Status = DeploymentStatus.Sealed, ArtifactBlob = Encoding.UTF8.GetBytes(artifact), + }); + // A newer deployment that never sealed is not what the fleet runs — it must not mask the sealed one. + db.Deployments.Add(new Deployment + { + RevisionHash = "failed", CreatedBy = "t", Status = DeploymentStatus.PartiallyFailed, + CreatedAtUtc = DateTime.UtcNow.AddMinutes(5), ArtifactBlob = Encoding.UTF8.GetBytes("""{"DriverInstances":[]}"""), + }); db.SaveChanges(); return name; }