fix(security): preflight reads the latest sealed artifact; byte[] length predicate untranslatable on SQL Server (#815 live gate)

This commit is contained in:
Joseph Doherty
2026-11-11 03:29:41 -05:00
parent 2a87d96fac
commit 481985e20b
3 changed files with 74 additions and 3 deletions
@@ -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<OtOpcUaConfig
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking().Where(d => 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);
}
@@ -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;
/// <summary>
/// The SQL-Server-backed half of <see cref="CredentialReferencePreflightTests"/> (lmxopcua#815). EF InMemory runs
/// <c>ScanAsync</c>'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 (<c>ArtifactBlob.Length &gt; 0</c>). Here it must translate.
/// </summary>
[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);
}
}
}
@@ -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;
}