2adeacc655
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
88 lines
5.0 KiB
C#
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)));
|
|
}
|
|
}
|