348 lines
16 KiB
C#
348 lines
16 KiB
C#
using System.Reflection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="ArtifactRevisionHashService"/>, 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.
|
|
/// </summary>
|
|
public class ArtifactRevisionHashServiceTests
|
|
{
|
|
private readonly ArtifactRevisionHashService _sut = new();
|
|
|
|
private static DeployArtifactsCommand Command(
|
|
string deploymentId = "dep-1",
|
|
DateTimeOffset? timestamp = null,
|
|
IReadOnlyList<SharedScriptArtifact>? sharedScripts = null,
|
|
IReadOnlyList<ExternalSystemArtifact>? externalSystems = null,
|
|
IReadOnlyList<DatabaseConnectionArtifact>? databaseConnections = null,
|
|
IReadOnlyList<DataConnectionArtifact>? 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);
|
|
|
|
private static SecretArtifact Secret(string name, long rev) =>
|
|
new(name, "text/plain",
|
|
Ciphertext: [1, 2, 3],
|
|
Nonce: [4, 5],
|
|
Tag: [6, 7],
|
|
WrappedDek: [8, 9],
|
|
WrapNonce: [10, 11],
|
|
WrapTag: [12, 13],
|
|
KekId: "kek-1",
|
|
Revision: rev,
|
|
CreatedUtc: DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
|
UpdatedUtc: DateTimeOffset.Parse("2026-01-02T00:00:00Z"));
|
|
|
|
private static DeployArtifactsCommand Cmd(
|
|
IReadOnlyList<SecretArtifact>? secrets = null,
|
|
IReadOnlyList<string>? missing = null) =>
|
|
Command() with { Secrets = secrets, MissingSecrets = missing };
|
|
|
|
[Fact]
|
|
public void SecretChanges_MoveTheHash_ButOrderDoesNot()
|
|
{
|
|
var svc = new ArtifactRevisionHashService();
|
|
var a = Secret("A", rev: 1);
|
|
var b = Secret("B", rev: 1);
|
|
var baseline = svc.ComputeHash(Cmd(secrets: [a, b]));
|
|
|
|
Assert.Equal(baseline, svc.ComputeHash(Cmd(secrets: [b, a])));
|
|
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { Revision = 2 }, b])));
|
|
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { WrappedDek = [42] }, b])));
|
|
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { KekId = "kek-B" }, b])));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingSecret_HashesDifferentlyFromPresent()
|
|
{
|
|
var svc = new ArtifactRevisionHashService();
|
|
|
|
Assert.NotEqual(
|
|
svc.ComputeHash(Cmd(secrets: [Secret("A", 1)])),
|
|
svc.ComputeHash(Cmd(missing: ["A"])));
|
|
}
|
|
|
|
[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<string, DeployArtifactsCommand> ChangedFieldCases()
|
|
{
|
|
var data = new TheoryData<string, DeployArtifactsCommand>();
|
|
|
|
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<string, DeployArtifactsCommand>(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)}]");
|
|
}
|
|
}
|
|
}
|