Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerSqlServerHashStabilityTests.cs
T
Joseph Doherty 2adeacc655 fix(controlplane): keep the revision hash byte-identical to the pre-refactor composer
The one-snapshot refactor re-sorted every collection in memory with an ordinal comparer, while the old
composer's SQL ORDER BY used the database's case-insensitive collation; mixed-case keys therefore re-hashed
an unchanged configuration (spurious drift fleet-wide, a "new" revision on a no-op redeploy). The snapshot
factory now reads every composed table ORDER BY its natural key server-side and Compose serialises in the
order given. Pinned byte-for-byte against a verbatim replica of the old composer on the real SQL Server.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:48:40 -04:00

88 lines
5.0 KiB
C#

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;
/// <summary>
/// Fix round 1 minor — revision-hash stability across the R03:3.2 refactor. The pre-change composer read
/// every table with a SQL <c>ORDER BY</c> (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.
/// </summary>
[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);
}
/// <summary>Verbatim replica of <c>ConfigComposer.SnapshotAndFlattenAsync</c> before R03:3.2 (ad2b5c40).</summary>
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)));
}
}