using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// Fix round 1 minor — revision-hash stability across the R03:3.2 refactor. The pre-change composer read /// every table with a SQL ORDER BY (the database's case-INSENSITIVE collation); the refactor re-sorted /// in memory with an ORDINAL comparer, which orders mixed-case keys differently ("B-1" before "a-2"). The /// same configuration would then hash differently after the upgrade: drift detection reports every node /// drifted, and a re-deploy of an unchanged config shows a "new" revision. This pins the composer's output /// BYTE-identical to a verbatim replica of the pre-change composer on the real provider. /// [Trait("Category", "SchemaCompliance")] public sealed class ConfigComposerSqlServerHashStabilityTests { [Fact] public async Task Composer_output_is_byte_identical_to_the_pre_change_composer_for_mixed_case_keys() { using var sql = new SqlServerTestDatabase(); await using (var seed = sql.NewContext()) { // Collation order a-2, B-1, c-3; ordinal order B-1, a-2, c-3. foreach (var id in new[] { "c-3", "B-1", "a-2" }) seed.Scripts.Add(new Script { ScriptId = id, Name = id, SourceCode = "return 1;", SourceHash = id }); await seed.SaveChangesAsync(); } await using var legacyDb = sql.NewContext(); var legacy = await LegacySnapshotAndFlattenAsync(legacyDb); await using var viaDb = sql.NewContext(); var current = await ConfigComposer.SnapshotAndFlattenAsync(viaDb); current.Blob.ShouldBe(legacy.Blob); current.RevisionHash.ShouldBe(legacy.RevisionHash); // The deploy path's form (validate + compose from ONE snapshot) must agree too. await using var deployDb = sql.NewContext(); var viaSnapshot = ConfigComposer.Compose(await DraftSnapshotFactory.FromConfigDbAsync(deployDb)); viaSnapshot.RevisionHash.ShouldBe(legacy.RevisionHash); } /// Verbatim replica of ConfigComposer.SnapshotAndFlattenAsync before R03:3.2 (ad2b5c40). private static async Task<(byte[] Blob, string RevisionHash)> LegacySnapshotAndFlattenAsync(OtOpcUaConfigDbContext db) { var ct = CancellationToken.None; var snapshot = new { Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct), Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct), RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct), DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct), Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct), TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct), Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct), Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct), UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct), PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct), UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct), UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct), NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct), ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct), Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct), ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct), }; var json = JsonSerializer.SerializeToUtf8Bytes(snapshot, new JsonSerializerOptions { WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, }); using var output = new MemoryStream(json.Length / 8); using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) gzip.Write(json, 0, json.Length); var blob = output.ToArray(); return (blob, Convert.ToHexStringLower(SHA256.HashData(blob))); } }