using System.Reflection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Tests for , the deterministic hash behind the /// Sites page's "artifacts out of date" indicator. The hash must depend on the artifact /// CONTENT only — never on deployment id, timestamp, or repository row order — or the /// indicator either never clears or never fires. /// public class ArtifactRevisionHashServiceTests { private readonly ArtifactRevisionHashService _sut = new(); private static DeployArtifactsCommand Command( string deploymentId = "dep-1", DateTimeOffset? timestamp = null, IReadOnlyList? sharedScripts = null, IReadOnlyList? externalSystems = null, IReadOnlyList? databaseConnections = null, IReadOnlyList? dataConnections = null) => new(deploymentId, sharedScripts ?? [ new SharedScriptArtifact("Alpha", "return 1;", "{\"p\":1}", "{\"r\":1}"), new SharedScriptArtifact("Beta", "return 2;", null, null) ], externalSystems ?? [ new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", "{\"key\":\"k\"}", "[{\"Name\":\"MoveOut\"},{\"Name\":\"MoveIn\"}]", 30, 6, TimeSpan.FromSeconds(120)), new ExternalSystemArtifact("Erp", "https://erp", "BasicAuth", null, null) ], databaseConnections ?? [ new DatabaseConnectionArtifact("Historian", "Server=h;", 4, TimeSpan.FromSeconds(30)), new DatabaseConnectionArtifact("Mes", "Server=m;", 0, TimeSpan.Zero) ], NotificationLists: null, dataConnections ?? [ new DataConnectionArtifact("Opc", "OpcUa", "{\"url\":\"opc.tcp://x\"}", null, 3), new DataConnectionArtifact("Mx", "MxGateway", "{\"host\":\"gw\"}", "{\"host\":\"gw2\"}", 5) ], SmtpConfigurations: null, timestamp ?? DateTimeOffset.UtcNow); [Fact] public void ComputeHash_PreSecretsGoldenValue_IsUnchanged() { // Captured on HEAD 738e4587 — BEFORE Task 4 wired Secrets/MissingSecrets into the // projection — using the fixed default baseline (one of each existing artifact // kind) with a pinned deploymentId/timestamp. A command with Secrets == null and // MissingSecrets == null must keep hashing to this exact value after Task 4, or a // site with no secret references would spuriously flip to "out of date". var golden = Command( deploymentId: "golden", timestamp: DateTimeOffset.Parse("2026-09-28T00:00:00Z")); Assert.Equal( "sha256:949f2c9c6d80432f94e9c2ad2a669b020a33c4a69653cf1b7b2b0aa217a467b0", _sut.ComputeHash(golden)); } [Fact] public void ComputeHash_SameInputTwice_ProducesSameHash() { var timestamp = DateTimeOffset.UtcNow; Assert.Equal( _sut.ComputeHash(Command(timestamp: timestamp)), _sut.ComputeHash(Command(timestamp: timestamp))); } [Fact] public void ComputeHash_IsSha256HexEncoded() { var hash = _sut.ComputeHash(Command()); // Same encoding as RevisionHashService: "sha256:" + 64 lower-case hex chars. Assert.StartsWith("sha256:", hash); var digest = hash["sha256:".Length..]; Assert.Equal(64, digest.Length); Assert.All(digest, c => Assert.Contains(c, "0123456789abcdef")); } [Fact] public void ComputeHash_ReorderedArtifactLists_ProducesSameHash() { // Repository row order must never leak into the hash — otherwise an unrelated // insert would flag every site out of date. var baseline = Command(); var reordered = baseline with { SharedScripts = baseline.SharedScripts!.Reverse().ToList(), ExternalSystems = baseline.ExternalSystems!.Reverse().ToList(), DatabaseConnections = baseline.DatabaseConnections!.Reverse().ToList(), DataConnections = baseline.DataConnections!.Reverse().ToList() }; Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(reordered)); } [Fact] public void ComputeHash_ReorderedExternalSystemMethods_ProducesSameHash() { // MethodDefinitionsJson is itself a serialized collection; it is re-canonicalised // with the methods sorted by name so method row order cannot move the hash either. var a = Command(externalSystems: [ new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "[{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"}]") ]); var b = Command(externalSystems: [ new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "[{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"}]") ]); Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } [Fact] public void ComputeHash_ReorderedCamelCaseMethodNames_ProducesSameHash() { // The method-name lookup is case-insensitive so that a naming-policy change at the // producer (PascalCase anonymous type today) cannot silently degrade the sort into a // no-op and let repository row order move the hash again. var a = Command(externalSystems: [ new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "[{\"name\":\"MoveOut\"},{\"name\":\"MoveIn\"}]") ]); var b = Command(externalSystems: [ new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "[{\"name\":\"MoveIn\"},{\"name\":\"MoveOut\"}]") ]); Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } [Fact] public void ComputeHash_UnparseableMethodDefinitions_StillParticipatesInTheHash() { // Non-array / non-JSON payloads pass through verbatim: the order guarantee is lost // (there is nothing to sort), but a content change must still move the hash. var a = Command(externalSystems: [new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json")]); var b = Command(externalSystems: [new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json either")]); Assert.NotEqual(_sut.ComputeHash(a), _sut.ComputeHash(b)); } [Fact] public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash() { // The whole point of the stamp: two deploys of identical definitions must hash the // same, or every redeploy would look like a change. var a = Command("dep-1", DateTimeOffset.UtcNow); var b = Command("dep-2", DateTimeOffset.UtcNow.AddDays(3)); Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } [Fact] public void ComputeHash_EmptyAndNullArtifactLists_HashIdentically() { var withNulls = new DeployArtifactsCommand( "dep", null, null, null, null, null, null, DateTimeOffset.UtcNow); var withEmpties = new DeployArtifactsCommand( "dep", [], [], [], [], [], [], DateTimeOffset.UtcNow); Assert.Equal(_sut.ComputeHash(withNulls), _sut.ComputeHash(withEmpties)); } [Fact] public void ComputeHash_NotificationAndSmtpArtifacts_DoNotAffectTheHash() { // Central never populates these (notification delivery is central-only), so they // are excluded from the projection entirely. var baseline = Command(); var withCentralOnlyPayload = baseline with { NotificationLists = [new NotificationListArtifact("Ops", ["a@b.c"])], SmtpConfigurations = [new SmtpConfigurationArtifact("Main", "smtp", 25, "Basic", "a@b.c", null, null, null)] }; Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(withCentralOnlyPayload)); } public static TheoryData ChangedFieldCases() { var data = new TheoryData(); void Add(string field, DeployArtifactsCommand command) => data.Add(field, command); // SharedScriptArtifact — every field. Add("script.Name", Command(sharedScripts: [new SharedScriptArtifact("Renamed", "c", "p", "r")])); Add("script.Code", Command(sharedScripts: [new SharedScriptArtifact("S", "changed", "p", "r")])); Add("script.ParameterDefinitions", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "changed", "r")])); Add("script.ReturnDefinition", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "changed")])); // ExternalSystemArtifact — every field. Add("externalSystem.Name", Command(externalSystems: [new ExternalSystemArtifact("Renamed", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.EndpointUrl", Command(externalSystems: [new ExternalSystemArtifact("E", "changed", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.AuthType", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "BasicAuth", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.AuthConfiguration", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "changed", "[]", 1, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.MethodDefinitionsJson", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[{\"Name\":\"M\"}]", 1, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.TimeoutSeconds", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 99, 2, TimeSpan.FromSeconds(3))])); Add("externalSystem.MaxRetries", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 99, TimeSpan.FromSeconds(3))])); Add("externalSystem.RetryDelay", Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(99))])); // DatabaseConnectionArtifact — every field. Add("databaseConnection.Name", Command(databaseConnections: [new DatabaseConnectionArtifact("Renamed", "cs", 1, TimeSpan.FromSeconds(2))])); Add("databaseConnection.ConnectionString", Command(databaseConnections: [new DatabaseConnectionArtifact("D", "changed", 1, TimeSpan.FromSeconds(2))])); Add("databaseConnection.MaxRetries", Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 99, TimeSpan.FromSeconds(2))])); Add("databaseConnection.RetryDelay", Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(99))])); // DataConnectionArtifact — every field. Add("dataConnection.Name", Command(dataConnections: [new DataConnectionArtifact("Renamed", "OpcUa", "p", "b", 1)])); Add("dataConnection.Protocol", Command(dataConnections: [new DataConnectionArtifact("C", "MxGateway", "p", "b", 1)])); Add("dataConnection.PrimaryConfigurationJson", Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "changed", "b", 1)])); Add("dataConnection.BackupConfigurationJson", Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "changed", 1)])); Add("dataConnection.FailoverRetryCount", Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 99)])); return data; } [Theory] [MemberData(nameof(ChangedFieldCases))] public void ComputeHash_EveryArtifactField_ParticipatesInTheHash( string changedField, DeployArtifactsCommand changed) { // Each case is the same command with ONE artifact field differing from the // per-class baseline below; a field silently dropped from the projection would // make the two hashes equal and leave that edit invisible to the indicator. var baselines = new Dictionary(StringComparer.Ordinal) { ["script"] = Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "r")]), ["externalSystem"] = Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]), ["databaseConnection"] = Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(2))]), ["dataConnection"] = Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 1)]), }; var baseline = baselines[changedField.Split('.')[0]]; Assert.NotEqual(_sut.ComputeHash(baseline), _sut.ComputeHash(changed)); } [Fact] public void HashableRecords_PropertiesDeclaredAlphabetically() { // Artifact-hash determinism depends entirely on the private Hashable* records // declaring their properties in alphabetical order (System.Text.Json emits // properties in CLR declaration order and does not sort). Without this guard a // contributor could silently change every site's artifact hash — flagging the // whole fleet out of date — by adding a property out of order. Mirrors // RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically. var nested = typeof(ArtifactRevisionHashService) .GetNestedTypes(BindingFlags.NonPublic) .Where(t => t.Name.StartsWith("Hashable", StringComparison.Ordinal)) .ToList(); Assert.NotEmpty(nested); foreach (var type in nested) { var propNames = type .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.Name != "EqualityContract") .Select(p => p.Name) .ToList(); var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList(); Assert.True( propNames.SequenceEqual(sorted), $"{type.Name} properties must be declared alphabetically. " + $"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]"); } } }