refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,84 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
/// plaintext. These tests pin the hasher contract that the entity, the validator,
/// and the management create-path all depend on.
/// </summary>
public class ApiKeyHasherTests
{
[Fact]
public void Hash_IsDeterministic_SameInputSameOutput()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var first = hasher.Hash("some-api-key-value");
var second = hasher.Hash("some-api-key-value");
Assert.Equal(first, second);
}
[Fact]
public void Hash_DoesNotEqualPlaintext()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var hash = hasher.Hash("some-api-key-value");
Assert.NotEqual("some-api-key-value", hash);
Assert.DoesNotContain("some-api-key-value", hash);
}
[Fact]
public void Hash_DifferentInputs_ProduceDifferentHashes()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
}
[Fact]
public void Hash_DifferentPeppers_ProduceDifferentHashes()
{
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
// The pepper binds the hash to the server: a stolen DB dump is useless
// without the pepper because the same key hashes differently under it.
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("too-short")]
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
{
// The pepper must be present and of meaningful length; a missing or weak
// pepper is a deployment misconfiguration and must fail loudly.
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
}
[Fact]
public void Hash_NullInput_Throws()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
}
[Fact]
public void Default_IsUsableWithoutAPepper()
{
// The unpeppered default exists for tests and non-production wiring; it is
// still a one-way HMAC-SHA256, just without the server-binding pepper.
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
Assert.NotEqual("some-api-key-value", hash);
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
}
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
/// used by the ManagementService + CentralUI audit endpoints and the
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
/// repeated value independently, silently drop unparseable/blank elements, and
/// collapse an empty result to <c>null</c>.
/// </summary>
public class AuditQueryParamParsersTests
{
[Fact]
public void ParseEnumList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
}
[Fact]
public void ParseEnumList_EmptyInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
}
[Fact]
public void ParseEnumList_AllValuesValid_ParsesEverything()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
new[] { "ApiOutbound", "DbOutbound" });
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
}
[Fact]
public void ParseEnumList_IsCaseInsensitive()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
}
[Fact]
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
new[] { "ApiOutbound", "NotAChannel", "Notification" });
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
}
[Fact]
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
}
[Fact]
public void ParseStringList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
}
[Fact]
public void ParseStringList_TrimsValuesAndDropsBlanks()
{
var result = AuditQueryParamParsers.ParseStringList(
new[] { " site-1 ", "", " ", "site-2", null });
Assert.Equal(new[] { "site-1", "site-2" }, result);
}
[Fact]
public void ParseStringList_AllBlank_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null }));
}
}
@@ -0,0 +1,472 @@
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class OpcUaEndpointConfigSerializerTests
{
[Fact]
public void Serialize_TypedRoundtrip_PreservesAllFields()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://plant-a:4840",
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AutoAcceptUntrustedCerts = false,
SessionTimeoutMs = 90000,
OperationTimeoutMs = 20000,
PublishingIntervalMs = 500,
SamplingIntervalMs = 250,
QueueSize = 50,
KeepAliveCount = 5,
LifetimeCount = 15,
MaxNotificationsPerPublish = 200,
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 }
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.False(isLegacy);
Assert.Equal(original.EndpointUrl, round.EndpointUrl);
Assert.Equal(original.SecurityMode, round.SecurityMode);
Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts);
Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs);
Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs);
Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs);
Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs);
Assert.Equal(original.QueueSize, round.QueueSize);
Assert.Equal(original.KeepAliveCount, round.KeepAliveCount);
Assert.Equal(original.LifetimeCount, round.LifetimeCount);
Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish);
Assert.NotNull(round.Heartbeat);
Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath);
Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
}
[Fact]
public void Serialize_UsesCamelCase()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var json = OpcUaEndpointConfigSerializer.Serialize(config);
Assert.Contains("\"endpointUrl\"", json);
Assert.DoesNotContain("\"EndpointUrl\"", json);
}
[Fact]
public void Serialize_SecurityModeAsCamelCaseString()
{
var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt };
var json = OpcUaEndpointConfigSerializer.Serialize(config);
using var doc = System.Text.Json.JsonDocument.Parse(json);
Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Deserialize_NullOrEmpty_ReturnsDefaults(string? input)
{
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
Assert.False(isLegacy);
Assert.Equal("", config.EndpointUrl);
Assert.Equal(60000, config.SessionTimeoutMs);
Assert.Null(config.Heartbeat);
}
[Fact]
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
{
var legacyJson = """
{
"endpoint": "opc.tcp://legacy:4840",
"SessionTimeoutMs": "45000",
"SamplingIntervalMs": "500",
"SecurityMode": "Sign",
"AutoAcceptUntrustedCerts": "false",
"HeartbeatTagPath": "Old.Heartbeat",
"HeartbeatMaxSilence": "20"
}
""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl);
Assert.Equal(45000, config.SessionTimeoutMs);
Assert.Equal(500, config.SamplingIntervalMs);
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
Assert.False(config.AutoAcceptUntrustedCerts);
Assert.NotNull(config.Heartbeat);
Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath);
Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
}
[Fact]
public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
{
var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
[Fact]
public void Deserialize_LegacyWithMixedTypeValues_PreservesAllFields()
{
// Real-world legacy producer emitted mixed JSON value types:
// string for endpoint, number for publishInterval, etc.
var legacyJson = """
{"endpoint":"opc.tcp://scadabridge-opcua:50000","securityMode":"None","publishInterval":1000,"AutoAcceptUntrustedCerts":false,"SessionTimeoutMs":45000}
""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://scadabridge-opcua:50000", config.EndpointUrl);
Assert.Equal(OpcUaSecurityMode.None, config.SecurityMode);
Assert.False(config.AutoAcceptUntrustedCerts);
Assert.Equal(45000, config.SessionTimeoutMs);
// publishInterval is not a recognized key (the real key is PublishingIntervalMs);
// FromFlatDict ignores unknown keys, so PublishingIntervalMs stays at its POCO default of 1000.
Assert.Equal(1000, config.PublishingIntervalMs);
}
[Fact]
public void Deserialize_LegacyParsed_StatusIsLegacy()
{
var (_, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
Assert.True(isLegacy);
var result = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
}
[Theory]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("\"just a string\"")]
public void Deserialize_Malformed_ReportsMalformedNotLegacy(string input)
{
var result = OpcUaEndpointConfigSerializer.Deserialize(input);
// Genuinely unparseable input must NOT be reported as a recoverable legacy row.
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
Assert.False(result.IsLegacy);
Assert.Equal("", result.Config.EndpointUrl);
}
[Fact]
public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy()
{
// A flat object with unrecognized keys is still a parseable legacy row, not malformed.
var result = OpcUaEndpointConfigSerializer.Deserialize("{\"foo\":123}");
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
Assert.True(result.IsLegacy);
}
// ── Commons-014 regression: a corrupt typed row must not be mislabelled Legacy ──
[Fact]
public void Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy()
{
// The row IS the current typed shape (it has endpointUrl) but an enum-valued
// field holds an unrecognised string. Typed deserialization throws JsonException;
// it must NOT fall through to the legacy path and be reported as Legacy.
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"NotARealMode"}""";
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
Assert.False(result.IsLegacy);
}
[Fact]
public void Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy()
{
// endpointUrl present (typed shape) but a numeric field holds a non-numeric token.
var json = """{"endpointUrl":"opc.tcp://x:4840","sessionTimeoutMs":"not-a-number"}""";
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
Assert.False(result.IsLegacy);
}
[Fact]
public void Deserialize_ValidTypedRow_StillReportsTyped()
{
// Guard: a clean typed row is still classified Typed after the Commons-014 fix.
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"sign"}""";
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.Equal(OpcUaConfigParseStatus.Typed, result.Status);
Assert.Equal("opc.tcp://x:4840", result.Config.EndpointUrl);
Assert.Equal(OpcUaSecurityMode.Sign, result.Config.SecurityMode);
}
[Fact]
public void Deserialize_TwoElementDeconstruction_StillWorks()
{
// Backward-compat: existing callers deconstruct into (Config, IsLegacy).
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(
"""{"endpointUrl":"opc.tcp://x:4840"}""");
Assert.False(isLegacy);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
[Fact]
public void ToFlatDict_OmitsNullHeartbeat()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("opc.tcp://x:4840", dict["endpoint"]);
Assert.Equal("60000", dict["SessionTimeoutMs"]);
Assert.Equal("None", dict["SecurityMode"]);
Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]);
Assert.False(dict.ContainsKey("HeartbeatTagPath"));
Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
}
[Fact]
public void ToFlatDict_IncludesHeartbeat_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 }
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]);
Assert.Equal("45", dict["HeartbeatMaxSilence"]);
}
[Fact]
public void FromFlatDict_RoundTripsAllKeys()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["SessionTimeoutMs"] = "30000",
["SecurityMode"] = "Sign",
["HeartbeatTagPath"] = "Hb",
["HeartbeatMaxSilence"] = "15"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
Assert.Equal(30000, config.SessionTimeoutMs);
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
Assert.NotNull(config.Heartbeat);
Assert.Equal("Hb", config.Heartbeat!.TagPath);
}
// ── Layer A/B extensions: subscription tuning, auth, deadband ──
[Fact]
public void Serialize_RoundtripsNewSubscriptionScalars()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
DiscardOldest = false,
SubscriptionPriority = 200,
SubscriptionDisplayName = "ScadaBridge-Primary",
TimestampsToReturn = OpcUaTimestampsToReturn.Both
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.False(isLegacy);
Assert.False(round.DiscardOldest);
Assert.Equal((byte)200, round.SubscriptionPriority);
Assert.Equal("ScadaBridge-Primary", round.SubscriptionDisplayName);
Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn);
}
[Fact]
public void Serialize_RoundtripsDeadband()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Deadband = new OpcUaDeadbandConfig
{
Type = OpcUaDeadbandType.Percent,
Value = 2.5
}
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.NotNull(round.Deadband);
Assert.Equal(OpcUaDeadbandType.Percent, round.Deadband!.Type);
Assert.Equal(2.5, round.Deadband.Value);
}
[Theory]
[InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")]
[InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")]
[InlineData(OpcUaUserTokenType.Anonymous, "", "", "", "")]
public void Serialize_RoundtripsUserIdentity(
OpcUaUserTokenType tokenType, string user, string pass, string certPath, string certPass)
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = tokenType,
Username = user,
Password = pass,
CertificatePath = certPath,
CertificatePassword = certPass
}
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.NotNull(round.UserIdentity);
Assert.Equal(tokenType, round.UserIdentity!.TokenType);
Assert.Equal(user, round.UserIdentity.Username);
Assert.Equal(pass, round.UserIdentity.Password);
Assert.Equal(certPath, round.UserIdentity.CertificatePath);
Assert.Equal(certPass, round.UserIdentity.CertificatePassword);
}
[Fact]
public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson()
{
// Default config: UserIdentity and Deadband are null. Roundtrip should
// preserve nulls (anonymous = no auth needed in flattened JSON either).
var original = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.Null(round.UserIdentity);
Assert.Null(round.Deadband);
}
[Fact]
public void ToFlatDict_IncludesNewScalars()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
DiscardOldest = false,
SubscriptionPriority = 50,
SubscriptionDisplayName = "ScadaBridge-Edge",
TimestampsToReturn = OpcUaTimestampsToReturn.Server
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("False", dict["DiscardOldest"]);
Assert.Equal("50", dict["SubscriptionPriority"]);
Assert.Equal("ScadaBridge-Edge", dict["SubscriptionDisplayName"]);
Assert.Equal("Server", dict["TimestampsToReturn"]);
}
[Fact]
public void ToFlatDict_OmitsNullUserIdentityAndDeadband()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.False(dict.ContainsKey("UserIdentity.TokenType"));
Assert.False(dict.ContainsKey("UserIdentity.Username"));
Assert.False(dict.ContainsKey("Deadband.Type"));
Assert.False(dict.ContainsKey("Deadband.Value"));
}
[Fact]
public void ToFlatDict_IncludesUserIdentity_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "alice",
Password = "secret"
}
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("UsernamePassword", dict["UserIdentity.TokenType"]);
Assert.Equal("alice", dict["UserIdentity.Username"]);
Assert.Equal("secret", dict["UserIdentity.Password"]);
}
[Fact]
public void ToFlatDict_IncludesDeadband_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("Percent", dict["Deadband.Type"]);
Assert.Equal("1.5", dict["Deadband.Value"]);
}
[Fact]
public void FromFlatDict_MaterializesUserIdentity()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["UserIdentity.TokenType"] = "UsernamePassword",
["UserIdentity.Username"] = "bob",
["UserIdentity.Password"] = "hunter2"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.UsernamePassword, config.UserIdentity!.TokenType);
Assert.Equal("bob", config.UserIdentity.Username);
Assert.Equal("hunter2", config.UserIdentity.Password);
}
[Fact]
public void FromFlatDict_MaterializesDeadband()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["Deadband.Type"] = "Absolute",
["Deadband.Value"] = "0.25"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.Deadband);
Assert.Equal(OpcUaDeadbandType.Absolute, config.Deadband!.Type);
Assert.Equal(0.25, config.Deadband.Value);
}
[Fact]
public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity()
{
// Explicit Anonymous TokenType (different from "missing") materializes the sub-object.
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["UserIdentity.TokenType"] = "Anonymous"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
}
}
@@ -0,0 +1,191 @@
using System.Dynamic;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression:
/// a wrapped element must remain valid for deferred (script-time) access even after
/// the <see cref="JsonDocument"/> it was parsed from has been disposed.
/// </summary>
public class DynamicJsonElementTests
{
private static DynamicJsonElement Wrap(string json)
{
using var doc = JsonDocument.Parse(json);
return new DynamicJsonElement(doc.RootElement);
// doc is disposed here — a wrapper that retained a non-cloned element would
// now throw ObjectDisposedException on the first member access.
}
// ── Commons-002 regression: lifetime independence from the source document ──
[Fact]
public void MemberAccess_WorksAfterSourceDocumentDisposed()
{
dynamic obj = Wrap("""{ "name": "pump", "id": 7 }""");
Assert.Equal("pump", (string)obj.name);
Assert.Equal(7, (int)obj.id);
}
[Fact]
public void IndexAccess_WorksAfterSourceDocumentDisposed()
{
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
Assert.Equal("b", obj.items[1]);
}
[Fact]
public void NestedAccess_WorksAfterSourceDocumentDisposed()
{
dynamic obj = Wrap("""{ "outer": { "inner": { "value": 42 } } }""");
Assert.Equal(42, (int)obj.outer.inner.value);
}
[Fact]
public void ToString_WorksAfterSourceDocumentDisposed()
{
dynamic obj = Wrap("""{ "label": "site-1" }""");
Assert.Equal("site-1", obj.label.ToString());
}
[Fact]
public void Access_SurvivesGarbageCollection_OfSourceDocument()
{
// No reference to the source document is held anywhere; force collection
// and finalization to prove the wrapper does not depend on it.
var obj = MakeWrapperAndDropDocument();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
dynamic d = obj;
Assert.Equal("ok", (string)d.status);
}
private static DynamicJsonElement MakeWrapperAndDropDocument()
{
var doc = JsonDocument.Parse("""{ "status": "ok" }""");
var wrapper = new DynamicJsonElement(doc.RootElement);
doc.Dispose();
return wrapper;
}
// ── Basic conversion / access behavior ──────────────────────────
[Fact]
public void Convert_NumberToInt()
{
dynamic obj = Wrap("""{ "n": 123 }""");
Assert.Equal(123, (int)obj.n);
}
[Fact]
public void Convert_BoolFromJson()
{
dynamic obj = Wrap("""{ "flag": true }""");
Assert.True((bool)obj.flag);
}
[Fact]
public void MissingMember_Throws()
{
// TryGetMember returns false for an absent property, so the dynamic binder
// surfaces a RuntimeBinderException — the standard DynamicObject contract.
dynamic obj = Wrap("""{ "a": 1 }""");
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
() => { var _ = obj.doesNotExist; });
}
// ── Commons-006 regression: TryConvert(object) must never null out a present value ──
[Fact]
public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull()
{
// Directly exercise the DynamicObject.TryConvert contract for an `object`
// target: a present JSON object/array/string must not convert to null.
using var objDoc = JsonDocument.Parse("""{ "x": 1 }""");
var objWrapper = new DynamicJsonElement(objDoc.RootElement);
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
Assert.True(objWrapper.TryConvert(convBinder, out var result));
Assert.NotNull(result);
}
[Fact]
public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull()
{
// Only a genuinely null JSON value converts to a null object.
using var doc = JsonDocument.Parse("""{ "v": null }""");
var nullWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("v"));
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
Assert.True(nullWrapper.TryConvert(convBinder, out var result));
Assert.Null(result);
}
[Fact]
public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure()
{
// Requesting int from a JSON string is genuinely unconvertible: TryConvert
// must report false rather than a null success.
using var doc = JsonDocument.Parse("""{ "s": "not-a-number" }""");
var strWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("s"));
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(int), typeof(DynamicJsonElementTests));
Assert.False(strWrapper.TryConvert(convBinder, out var result));
Assert.Null(result);
}
// ── Commons-013 regression: integral index values other than int must work ──
[Fact]
public void IndexAccess_WithLongIndex_Works()
{
// DynamicJsonElement.Wrap surfaces JSON numbers as long; an index computed
// from a wrapped JSON number (obj.items[obj.count - 1]) arrives as a long.
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
long idx = 1L;
Assert.Equal("b", obj.items[idx]);
}
[Fact]
public void IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works()
{
// The exact failing case from Commons-013: count is a wrapped JSON number
// (unwrapped as long), so count - 1 is a long.
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ], "count": 3 }""");
Assert.Equal("c", obj.items[obj.count - 1]);
}
[Theory]
[InlineData((byte)0, "a")]
[InlineData((short)1, "b")]
public void IndexAccess_WithWideningIntegralIndex_Works(object index, string expected)
{
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
Assert.Equal(expected, obj.items[index]);
}
[Fact]
public void IndexAccess_WithLongIndexOutOfRange_Throws()
{
// An out-of-range long index is still rejected (binder surfaces the error).
dynamic obj = Wrap("""{ "items": [ "a" ] }""");
long idx = 5L;
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
() => { var _ = obj.items[idx]; });
}
}
@@ -0,0 +1,42 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class EnumTests
{
[Theory]
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary" })]
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo", "Expression" })]
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
[InlineData(typeof(NotificationStatus), new[] { "Pending", "Retrying", "Delivered", "Parked", "Discarded" })]
[InlineData(typeof(NotificationType), new[] { "Email" })]
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
{
var actualNames = Enum.GetNames(enumType);
Assert.Equal(expectedNames, actualNames);
}
[Theory]
[InlineData(typeof(DataType))]
[InlineData(typeof(InstanceState))]
[InlineData(typeof(DeploymentStatus))]
[InlineData(typeof(AlarmState))]
[InlineData(typeof(AlarmLevel))]
[InlineData(typeof(AlarmTriggerType))]
[InlineData(typeof(ConnectionHealth))]
[InlineData(typeof(NotificationStatus))]
[InlineData(typeof(NotificationType))]
public void Enum_ShouldBeSingularNamed(Type enumType)
{
// Singular names should not end with 's' (except 'Status' which is singular)
var name = enumType.Name;
Assert.False(name.EndsWith("es") && !name.EndsWith("Status"),
$"Enum {name} appears to be plural (ends with 'es').");
Assert.False(name.EndsWith("s") && !name.EndsWith("ss") && !name.EndsWith("us"),
$"Enum {name} appears to be plural (ends with 's').");
}
}
@@ -0,0 +1,72 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Enums;
/// <summary>
/// Asserts the exact member sets of the Audit Log (#23) enums.
/// Lock-in tests; any addition/removal/rename is a deliberate design change
/// that must come with a corresponding update to alog.md §4.
/// </summary>
public class AuditEnumTests
{
[Fact]
public void AuditChannel_HasExactlyExpectedMembers()
{
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
var actual = Enum.GetValues(typeof(AuditChannel))
.Cast<AuditChannel>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditKind_HasExactlyTenExpectedMembers()
{
var expected = new[]
{
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
"CachedSubmit", "CachedResolve",
};
var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(10, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditStatus_HasExactlyEightExpectedMembers()
{
var expected = new[]
{
"Submitted", "Forwarded", "Attempted", "Delivered",
"Failed", "Parked", "Discarded", "Skipped",
};
var actual = Enum.GetValues(typeof(AuditStatus))
.Cast<AuditStatus>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(8, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditForwardState_HasExactlyExpectedMembers()
{
var expected = new[] { "Pending", "Forwarded", "Reconciled" };
var actual = Enum.GetValues(typeof(AuditForwardState))
.Cast<AuditForwardState>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(expected, actual);
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for the small computed-property logic on
/// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>.
/// </summary>
public class FlatteningAndScriptScopeTests
{
[Fact]
public void ConfigurationDiff_NoChanges_HasChangesIsFalse()
{
var diff = new ConfigurationDiff { InstanceUniqueName = "inst-1" };
Assert.False(diff.HasChanges);
}
[Fact]
public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue()
{
var diff = new ConfigurationDiff
{
InstanceUniqueName = "inst-1",
AlarmChanges = new[]
{
new DiffEntry<ResolvedAlarm> { CanonicalName = "HiAlarm", ChangeType = DiffChangeType.Added }
}
};
Assert.True(diff.HasChanges);
}
[Fact]
public void ScriptScope_Root_HasNoParent()
{
Assert.False(ScriptScope.Root.HasParent);
Assert.Null(ScriptScope.Root.ParentPath);
}
[Fact]
public void ScriptScope_WithParentPath_HasParentIsTrue()
{
var scope = new ScriptScope("Pump1.Motor", "Pump1");
Assert.True(scope.HasParent);
Assert.Equal("Pump1", scope.ParentPath);
}
}
@@ -0,0 +1,91 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class ResultTests
{
[Fact]
public void Success_ShouldCreateSuccessfulResult()
{
var result = Result<int>.Success(42);
Assert.True(result.IsSuccess);
Assert.False(result.IsFailure);
Assert.Equal(42, result.Value);
}
[Fact]
public void Failure_ShouldCreateFailedResult()
{
var result = Result<int>.Failure("something went wrong");
Assert.True(result.IsFailure);
Assert.False(result.IsSuccess);
Assert.Equal("something went wrong", result.Error);
}
[Fact]
public void Value_OnFailure_ShouldThrow()
{
var result = Result<int>.Failure("error");
Assert.Throws<InvalidOperationException>(() => result.Value);
}
[Fact]
public void Error_OnSuccess_ShouldThrow()
{
var result = Result<int>.Success(42);
Assert.Throws<InvalidOperationException>(() => result.Error);
}
[Fact]
public void Match_OnSuccess_ShouldCallOnSuccess()
{
var result = Result<int>.Success(42);
var output = result.Match(
v => $"value={v}",
e => $"error={e}");
Assert.Equal("value=42", output);
}
[Fact]
public void Match_OnFailure_ShouldCallOnFailure()
{
var result = Result<int>.Failure("bad");
var output = result.Match(
v => $"value={v}",
e => $"error={e}");
Assert.Equal("error=bad", output);
}
[Fact]
public void Success_WithNullableReferenceType_ShouldWork()
{
var result = Result<string>.Success("hello");
Assert.True(result.IsSuccess);
Assert.Equal("hello", result.Value);
}
// ── Commons-011 regression: a failed Result must always carry a message ──
[Fact]
public void Failure_WithNullError_ShouldThrow()
{
Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Failure_WithBlankError_ShouldThrow(string error)
{
Assert.Throws<ArgumentException>(() => Result<int>.Failure(error));
}
}
@@ -0,0 +1,35 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class RetryPolicyTests
{
[Fact]
public void RetryPolicy_ShouldBeImmutableRecord()
{
var policy = new RetryPolicy(3, TimeSpan.FromSeconds(5));
Assert.Equal(3, policy.MaxRetries);
Assert.Equal(TimeSpan.FromSeconds(5), policy.Delay);
}
[Fact]
public void RetryPolicy_WithExpression_ShouldCreateNewInstance()
{
var original = new RetryPolicy(3, TimeSpan.FromSeconds(5));
var modified = original with { MaxRetries = 5 };
Assert.Equal(3, original.MaxRetries);
Assert.Equal(5, modified.MaxRetries);
Assert.Equal(original.Delay, modified.Delay);
}
[Fact]
public void RetryPolicy_EqualValues_ShouldBeEqual()
{
var a = new RetryPolicy(3, TimeSpan.FromSeconds(5));
var b = new RetryPolicy(3, TimeSpan.FromSeconds(5));
Assert.Equal(a, b);
}
}
@@ -0,0 +1,81 @@
using System.Collections;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
/// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths).
/// </summary>
public class ScriptArgsTests
{
[Fact]
public void Normalize_Null_ReturnsNull()
{
Assert.Null(ScriptArgs.Normalize(null));
}
[Fact]
public void Normalize_ReadOnlyDictionary_ReturnedAsIs()
{
IReadOnlyDictionary<string, object?> input =
new Dictionary<string, object?> { ["a"] = 1 };
var result = ScriptArgs.Normalize(input);
Assert.Same(input, result);
}
[Fact]
public void Normalize_PlainDictionary_ReturnedAsIs()
{
// Dictionary<string,object?> implements IReadOnlyDictionary, so it matches the
// first switch arm and is returned by reference (no defensive copy).
var input = new Dictionary<string, object?> { ["a"] = 1 };
var result = ScriptArgs.Normalize(input);
Assert.Same(input, result);
Assert.Equal(1, result!["a"]);
}
[Fact]
public void Normalize_NonGenericDictionary_KeysStringified()
{
IDictionary raw = new Hashtable { [42] = "answer" };
var result = ScriptArgs.Normalize(raw);
Assert.Equal("answer", result!["42"]);
}
[Fact]
public void Normalize_AnonymousObject_PropertiesBecomeEntries()
{
var result = ScriptArgs.Normalize(new { name = "Bob", count = 3 });
Assert.Equal("Bob", result!["name"]);
Assert.Equal(3, result["count"]);
}
[Theory]
[InlineData(42)]
[InlineData(true)]
[InlineData(3.14)]
public void Normalize_Primitive_Throws(object primitive)
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive));
}
[Fact]
public void Normalize_String_Throws()
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello"));
}
[Fact]
public void Normalize_Decimal_Throws()
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(9.99m));
}
}
@@ -0,0 +1,422 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class ScriptParametersTests
{
// ── Non-nullable scalar Get<T> ──────────────────────────────────
[Fact]
public void Get_Int_ExactTypeMatch()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42 });
Assert.Equal(42, p.Get<int>("x"));
}
[Fact]
public void Get_Int_FromLong()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
Assert.Equal(42, p.Get<int>("x"));
}
[Fact]
public void Get_Int_FromString()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "42" });
Assert.Equal(42, p.Get<int>("x"));
}
[Fact]
public void Get_Int_MissingKey_Throws()
{
var p = new ScriptParameters();
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
Assert.Contains("Parameter 'x' not found", ex.Message);
}
[Fact]
public void Get_Int_NullValue_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
Assert.Contains("Parameter 'x' value is null", ex.Message);
}
[Fact]
public void Get_Int_Unparsable_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "abc" });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
Assert.Contains("could not be parsed as Int32", ex.Message);
}
[Fact]
public void Get_String_DirectReturn()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = "hello" });
Assert.Equal("hello", p.Get<string>("s"));
}
[Fact]
public void Get_String_NullValue_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = null });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<string>("s"));
Assert.Contains("value is null", ex.Message);
}
[Fact]
public void Get_String_FromInt()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = 42 });
Assert.Equal("42", p.Get<string>("s"));
}
[Fact]
public void Get_Bool_True()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
Assert.True(p.Get<bool>("b"));
}
[Fact]
public void Get_Double_FromLong()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42L });
Assert.Equal(42.0, p.Get<double>("d"));
}
[Fact]
public void Get_Double_FromFloat()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14f });
Assert.Equal(3.14, p.Get<double>("d"), 2);
}
[Fact]
public void Get_Long_FromInt()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["l"] = 42 });
Assert.Equal(42L, p.Get<long>("l"));
}
[Fact]
public void Get_Float_FromDouble()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["f"] = 3.14 });
Assert.Equal(3.14f, p.Get<float>("f"), 2);
}
[Fact]
public void Get_DateTime_FromString()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "2026-03-22T10:30:00Z" });
var result = p.Get<DateTime>("dt");
Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result);
}
[Fact]
public void Get_DateTime_ExactType()
{
var dt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = dt });
Assert.Equal(dt, p.Get<DateTime>("dt"));
}
// ── Nullable scalar Get<T?> ─────────────────────────────────────
[Fact]
public void Get_NullableInt_MissingKey_ReturnsNull()
{
var p = new ScriptParameters();
Assert.Null(p.Get<int?>("x"));
}
[Fact]
public void Get_NullableInt_NullValue_ReturnsNull()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
Assert.Null(p.Get<int?>("x"));
}
[Fact]
public void Get_NullableInt_ValidValue_ReturnsValue()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
Assert.Equal(42, p.Get<int?>("x"));
}
// Commons-003: a parameter that is *present but unconvertible* is a caller/script
// bug and must throw — not be silently mapped to null (which a script would
// misread as "not supplied"). Genuinely absent/null still returns null.
[Fact]
public void Get_NullableInt_PresentButUnparsable_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "banana" });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
Assert.Contains("could not be parsed as Int32", ex.Message);
}
[Fact]
public void Get_NullableInt_PresentButOverflowing_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
Assert.Contains("could not be parsed as Int32", ex.Message);
}
[Fact]
public void Get_NullableDateTime_PresentButUnparsable_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "not-a-date" });
Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt"));
}
[Fact]
public void Get_NullableDouble_ValidValue()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14 });
Assert.Equal(3.14, p.Get<double?>("d"));
}
[Fact]
public void Get_NullableBool_ValidValue()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
Assert.True(p.Get<bool?>("b"));
}
// ── Array Get<T[]> ──────────────────────────────────────────────
[Fact]
public void Get_IntArray_FromListOfLongs()
{
var list = new List<object?> { 1L, 2L, 3L };
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
Assert.Equal(new[] { 1, 2, 3 }, p.Get<int[]>("arr"));
}
[Fact]
public void Get_IntArray_EmptyList()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = new List<object?>() });
Assert.Empty(p.Get<int[]>("arr"));
}
[Fact]
public void Get_IntArray_NonList_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = "not a list" });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
Assert.Contains("is not a list or array", ex.Message);
}
[Fact]
public void Get_IntArray_UnparsableElement_ThrowsWithIndex()
{
var list = new List<object?> { 1L, 2L, "bad" };
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
Assert.Contains("element at index 2", ex.Message);
Assert.Contains("'bad'", ex.Message);
Assert.Contains("Int32", ex.Message);
}
[Fact]
public void Get_IntArray_NullElement_Throws()
{
var list = new List<object?> { 1L, null, 3L };
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
Assert.Contains("element at index 1 is null", ex.Message);
}
[Fact]
public void Get_StringArray_FromListOfStrings()
{
var list = new List<object?> { "a", "b", "c" };
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
Assert.Equal(new[] { "a", "b", "c" }, p.Get<string[]>("arr"));
}
[Fact]
public void Get_DoubleArray_FromListOfNumbers()
{
var list = new List<object?> { 1.1, 2.2, 3.3 };
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get<double[]>("arr"));
}
[Fact]
public void Get_IntArray_MissingKey_Throws()
{
var p = new ScriptParameters();
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
Assert.Contains("not found", ex.Message);
}
// ── List<T> Get<List<T>> ────────────────────────────────────────
[Fact]
public void Get_ListInt_FromListOfLongs()
{
var list = new List<object?> { 10L, 20L, 30L };
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
var result = p.Get<List<int>>("items");
Assert.Equal(new List<int> { 10, 20, 30 }, result);
}
[Fact]
public void Get_ListString_FromListOfStrings()
{
var list = new List<object?> { "x", "y" };
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
Assert.Equal(new List<string> { "x", "y" }, p.Get<List<string>>("items"));
}
[Fact]
public void Get_ListInt_UnparsableElement_ThrowsWithIndex()
{
var list = new List<object?> { 1L, "oops" };
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<List<int>>("items"));
Assert.Contains("element at index 1", ex.Message);
}
[Fact]
public void Get_ListDouble_FromMixedNumbers()
{
var list = new List<object?> { 1L, 2.5, 3 };
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
var result = p.Get<List<double>>("items");
Assert.Equal(3, result.Count);
Assert.Equal(1.0, result[0]);
Assert.Equal(2.5, result[1]);
Assert.Equal(3.0, result[2]);
}
// ── Backward compatibility ──────────────────────────────────────
[Fact]
public void Indexer_Works()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = "val" });
Assert.Equal("val", p["key"]);
}
[Fact]
public void ContainsKey_Works()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 1 });
Assert.True(p.ContainsKey("key"));
Assert.False(p.ContainsKey("missing"));
}
[Fact]
public void TryGetValue_Works()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 42 });
Assert.True(p.TryGetValue("key", out var val));
Assert.Equal(42, val);
}
[Fact]
public void Count_Works()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 });
Assert.Equal(2, p.Count);
}
[Fact]
public void Enumeration_Works()
{
var dict = new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 };
var p = new ScriptParameters(dict);
var keys = p.Select(kv => kv.Key).OrderBy(k => k).ToList();
Assert.Equal(new[] { "a", "b" }, keys);
}
[Fact]
public void EmptyConstructor_ProducesEmptyDictionary()
{
var p = new ScriptParameters();
Assert.Empty(p);
Assert.False(p.ContainsKey("anything"));
}
// ── Edge cases ──────────────────────────────────────────────────
[Fact]
public void Get_Double_FromInt()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42 });
Assert.Equal(42.0, p.Get<double>("d"));
}
// ── JsonElement values (from JSON deserialization) ─────────────
[Fact]
public void Get_Int_FromJsonElement()
{
using var doc = JsonDocument.Parse("{\"x\": 42}");
var element = doc.RootElement.GetProperty("x").Clone();
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = element });
Assert.Equal(42, p.Get<int>("x"));
}
[Fact]
public void Get_IntArray_FromJsonElementList()
{
// Simulates what JsonSerializer.Deserialize<List<object?>> produces
using var doc = JsonDocument.Parse("[10, 20, 30]");
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
Assert.Equal(new[] { 10, 20, 30 }, p.Get<int[]>("items"));
}
[Fact]
public void Get_ListInt_FromJsonElementList()
{
using var doc = JsonDocument.Parse("[1, 2, 3]");
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
Assert.Equal(new List<int> { 1, 2, 3 }, p.Get<List<int>>("items"));
}
[Fact]
public void Get_String_FromJsonElement()
{
using var doc = JsonDocument.Parse("{\"s\": \"hello\"}");
var element = doc.RootElement.GetProperty("s").Clone();
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = element });
Assert.Equal("hello", p.Get<string>("s"));
}
[Fact]
public void Get_Double_FromJsonElement()
{
using var doc = JsonDocument.Parse("{\"d\": 3.14}");
var element = doc.RootElement.GetProperty("d").Clone();
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = element });
Assert.Equal(3.14, p.Get<double>("d"));
}
[Fact]
public void Get_Bool_FromJsonElement()
{
using var doc = JsonDocument.Parse("{\"b\": true}");
var element = doc.RootElement.GetProperty("b").Clone();
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = element });
Assert.True(p.Get<bool>("b"));
}
[Fact]
public void Get_Int_OverflowFromLong_Throws()
{
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
Assert.Contains("could not be parsed as Int32", ex.Message);
}
}
@@ -0,0 +1,42 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Verifies <see cref="SiteCallOperational"/> — the positional record carried on
/// the combined <c>CachedCallTelemetry</c> packet — round-trips the SourceNode
/// field through positional construction (where the parameter sits between
/// <c>SourceSite</c> and <c>Status</c>, mirroring the central <c>SiteCalls</c>
/// table column order).
/// </summary>
public class SiteCallOperationalTests
{
[Fact]
public void SiteCallOperational_carries_SourceNode()
{
// SourceNode identifies the cluster node that emitted the cached call
// (site node-a/node-b or central-a/central-b). Nullable — callsites
// pass null until INodeIdentityProvider stamping arrives in Task 14.
var trackedId = TrackedOperationId.New();
var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc);
var defaulted = new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-01",
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: nowUtc,
UpdatedAtUtc: nowUtc,
TerminalAtUtc: null);
Assert.Null(defaulted.SourceNode);
var stamped = defaulted with { SourceNode = "node-a" };
Assert.Equal("node-a", stamped.SourceNode);
Assert.Null(defaulted.SourceNode);
}
}
@@ -0,0 +1,32 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class SiteNotificationKpiSnapshotTests
{
[Fact]
public void Constructor_AssignsAllMembers()
{
var snapshot = new SiteNotificationKpiSnapshot(
SourceSiteId: "plant-a",
QueueDepth: 5,
StuckCount: 2,
ParkedCount: 1,
DeliveredLastInterval: 40,
OldestPendingAge: TimeSpan.FromMinutes(12));
Assert.Equal("plant-a", snapshot.SourceSiteId);
Assert.Equal(5, snapshot.QueueDepth);
Assert.Equal(2, snapshot.StuckCount);
Assert.Equal(1, snapshot.ParkedCount);
Assert.Equal(40, snapshot.DeliveredLastInterval);
Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
}
[Fact]
public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
{
var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null);
Assert.Null(snapshot.OldestPendingAge);
}
}
@@ -0,0 +1,119 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Regression tests for Commons-001: the check-then-act race between the timer
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
///
/// The original implementation guarded firing with a single <c>volatile bool</c> that
/// was both read by the callback and reset by the caller threads. Because the
/// check-then-set was not atomic with the timer reschedule, a callback that had
/// already entered could raise <c>Stale</c> after the period it was scheduled for
/// had been cancelled or restarted — a spurious staleness signal that, for a
/// connection-health monitor, triggers an unnecessary reconnect.
///
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
/// interleave a caller-thread operation with an in-flight callback.
/// </summary>
public class StaleTagMonitorRaceTests
{
/// <summary>
/// A value arrives (<c>OnValueReceived</c>) while a previous-period timer callback
/// is in flight, before that callback decides whether to fire. The old period has
/// been superseded, so the in-flight callback must not raise <c>Stale</c>
/// immediately; <c>Stale</c> may only fire later, for the fresh period, after a
/// full <c>MaxSilence</c> with no further values.
///
/// With the original single-volatile-bool guard the in-flight callback fired
/// <c>Stale</c> right after the value arrived (a spurious, wrong-moment signal);
/// this test detects that by checking how soon the fire lands after the value.
/// </summary>
[Fact]
public void Stale_DoesNotFirePromptly_WhenValueArrivesWhileCallbackInFlight()
{
var maxSilence = TimeSpan.FromMilliseconds(60);
using var monitor = new StaleTagMonitor(maxSilence);
DateTime? valueArrivedAt = null;
DateTime? staleFiredAt = null;
monitor.Stale += () => staleFiredAt ??= DateTime.UtcNow;
// When the (old-period) callback is entered, simulate a fresh value arriving
// on another thread before the callback's fire decision.
monitor.CallbackEnteredHook = () =>
{
monitor.CallbackEnteredHook = null; // only intercept the first callback
valueArrivedAt = DateTime.UtcNow;
monitor.OnValueReceived();
};
monitor.Start();
// Wait well past the intercepted callback and the fresh period's deadline.
Thread.Sleep(300);
monitor.Stop();
// The fresh period legitimately goes stale, so a fire is expected — but it
// must land roughly MaxSilence after the value, not immediately. A spurious
// wrong-moment fire from the superseded callback would land within a few ms.
Assert.NotNull(valueArrivedAt);
Assert.NotNull(staleFiredAt);
var delay = staleFiredAt.Value - valueArrivedAt.Value;
Assert.True(delay >= maxSilence * 0.5,
$"Stale fired only {delay.TotalMilliseconds:F0}ms after the value arrived; " +
$"expected at least {maxSilence.TotalMilliseconds * 0.5:F0}ms — the in-flight " +
"callback fired spuriously for the superseded period.");
}
/// <summary>
/// <c>Stop</c> races an in-flight timer callback. Once monitoring is stopped no
/// <c>Stale</c> signal may be delivered, even for a callback that had already
/// been entered.
/// </summary>
[Fact]
public void Stale_DoesNotFire_WhenStopRacesInFlightCallback()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.CallbackEnteredHook = () =>
{
monitor.CallbackEnteredHook = null;
monitor.Stop();
};
monitor.Start();
Thread.Sleep(200);
Assert.Equal(0, staleCount);
}
/// <summary>
/// <c>Start</c> (a restart) races an in-flight callback from the prior run. The
/// old callback belongs to a superseded period and must not fire; the new period
/// fires exactly once on its own deadline.
/// </summary>
[Fact]
public void Stale_FiresOnceForNewPeriod_WhenRestartRacesInFlightCallback()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.CallbackEnteredHook = () =>
{
monitor.CallbackEnteredHook = null;
monitor.Start(); // restart — supersedes the in-flight callback's period
};
monitor.Start();
// Old callback must be suppressed; the restarted period fires exactly once.
Thread.Sleep(250);
monitor.Stop();
Assert.Equal(1, staleCount);
}
}
@@ -0,0 +1,129 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class StaleTagMonitorTests
{
[Fact]
public void Constructor_ZeroTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
}
[Fact]
public void Constructor_NegativeTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
}
[Fact]
public async Task Stale_FiresAfterMaxSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task Stale_FiresOnlyOnce()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsTimer()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(200));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Keep resetting before the 200ms deadline
for (int i = 0; i < 5; i++)
{
await Task.Delay(100);
monitor.OnValueReceived();
}
// Should not have gone stale
Assert.Equal(0, staleCount);
}
[Fact]
public async Task OnValueReceived_AllowsStaleAfterSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Reset once
await Task.Delay(50);
monitor.OnValueReceived();
// Then go silent
await Task.Delay(250);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Wait for first stale
await Task.Delay(250);
Assert.Equal(1, staleCount);
// Reset — should allow second stale fire
monitor.OnValueReceived();
await Task.Delay(250);
Assert.Equal(2, staleCount);
}
[Fact]
public async Task Stop_PreventsStale()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Stop();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public async Task Dispose_PreventsStale()
{
var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Dispose();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public void MaxSilence_ReturnsConfiguredValue()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromSeconds(42));
Assert.Equal(TimeSpan.FromSeconds(42), monitor.MaxSilence);
}
}
@@ -0,0 +1,80 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
/// </summary>
public class TrackedOperationIdTests
{
[Fact]
public void New_ProducesUniqueIds()
{
var a = TrackedOperationId.New();
var b = TrackedOperationId.New();
Assert.NotEqual(a, b);
Assert.NotEqual(Guid.Empty, a.Value);
Assert.NotEqual(Guid.Empty, b.Value);
}
[Fact]
public void Parse_RoundTrip_PreservesValue()
{
var original = TrackedOperationId.New();
var serialized = original.ToString();
var parsed = TrackedOperationId.Parse(serialized);
Assert.Equal(original, parsed);
Assert.Equal(original.Value, parsed.Value);
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result));
Assert.Equal(default, result);
Assert.False(TrackedOperationId.TryParse(null, out var nullResult));
Assert.Equal(default, nullResult);
Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult));
Assert.Equal(default, emptyResult);
}
[Fact]
public void TryParse_ValidInput_ReturnsTrueAndId()
{
var original = TrackedOperationId.New();
var serialized = original.ToString();
Assert.True(TrackedOperationId.TryParse(serialized, out var parsed));
Assert.Equal(original, parsed);
}
[Fact]
public void Equality_BasedOnValue()
{
var guid = Guid.NewGuid();
var a = new TrackedOperationId(guid);
var b = new TrackedOperationId(guid);
Assert.Equal(a, b);
Assert.True(a == b);
Assert.False(a != b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void ToString_StandardGuidFormat()
{
var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab");
var id = new TrackedOperationId(guid);
// "D" format: 32 hex digits separated by hyphens (8-4-4-4-12).
Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString());
}
}
@@ -0,0 +1,143 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
/// </summary>
public sealed class EncryptionMetadataTests
{
[Fact]
public void Constructor_WithDocumentedValues_Succeeds()
{
// 600_000 is the design-doc production value; "abc"/"def" are placeholder
// Base64 strings, kept short for test legibility.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def");
Assert.Equal("AES-256-GCM", meta.Algorithm);
Assert.Equal("PBKDF2-SHA256", meta.Kdf);
Assert.Equal(600_000, meta.Iterations);
Assert.Equal("abc", meta.SaltB64);
Assert.Equal("def", meta.IvB64);
}
[Theory]
[InlineData("AES-128-CBC")] // weaker algorithm
[InlineData("AES-256-CBC")] // unauthenticated mode
[InlineData("aes-256-gcm")] // case must match exactly
[InlineData("")]
[InlineData("FOO")]
public void Constructor_UnknownAlgorithm_Throws(string algorithm)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: algorithm,
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Algorithm", ex.ParamName);
Assert.Contains("AES-256-GCM", ex.Message);
}
[Theory]
[InlineData("PBKDF2-SHA1")] // weaker hash
[InlineData("argon2id")] // unsupported KDF
[InlineData("pbkdf2-sha256")] // case must match
[InlineData("")]
public void Constructor_UnknownKdf_Throws(string kdf)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: kdf,
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Kdf", ex.ParamName);
Assert.Contains("PBKDF2-SHA256", ex.Message);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(1)]
[InlineData(99_999)] // one below the floor
[InlineData(10_000_001)] // one above the ceiling
[InlineData(int.MaxValue)]
public void Constructor_IterationsOutOfRange_Throws(int iterations)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Iterations", ex.ParamName);
}
[Theory]
[InlineData(100_000)] // OWASP minimum (exact)
[InlineData(600_000)] // design-doc production value
[InlineData(10_000_000)] // ceiling (exact)
public void Constructor_IterationsAtBoundary_Succeeds(int iterations)
{
// Exercises the inclusive boundary check on both ends.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def");
Assert.Equal(iterations, meta.Iterations);
}
[Fact]
public void Constructor_NullSalt_Throws()
{
// null is rejected; empty is permitted (the seed pattern used by BundleSerializer.Pack).
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: null!,
IvB64: "def"));
}
[Fact]
public void Constructor_NullIv_Throws()
{
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: null!));
}
[Fact]
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
{
// BundleSerializer.Pack re-stamps salt/iv from the ciphertext it actually
// writes, so callers (BundleExporter) construct a seed instance with empty
// placeholders. Validation must therefore accept empty here.
var seed = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: string.Empty,
IvB64: string.Empty);
Assert.Equal(string.Empty, seed.SaltB64);
Assert.Equal(string.Empty, seed.IvB64);
}
}
@@ -0,0 +1,296 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
/// (Commons-015) and is reused here only to populate manifest fixtures.
/// </summary>
public sealed class TransportRecordsTests
{
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
};
// --------------------------------------------------------------
// BundleManifest
// --------------------------------------------------------------
[Fact]
public void BundleManifest_Constructor_RoundTripsAllFields()
{
var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
var contents = new List<ManifestContentEntry>
{
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
new("Template", "Valve", 2, Array.Empty<string>()),
};
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "cli",
ExportedBy: "alice",
ScadaBridgeVersion: "0.9.0",
ContentHash: "sha256:deadbeef",
Encryption: null,
Summary: summary,
Contents: contents);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.Equal("cli", manifest.SourceEnvironment);
Assert.Equal("alice", manifest.ExportedBy);
Assert.Equal("0.9.0", manifest.ScadaBridgeVersion);
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
Assert.Null(manifest.Encryption);
Assert.Equal(summary, manifest.Summary);
Assert.Equal(2, manifest.Contents.Count);
Assert.Equal("Pump", manifest.Contents[0].Name);
}
[Fact]
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
{
var encryption = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "c2FsdA==",
IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "ui",
ExportedBy: "bob",
ScadaBridgeVersion: "0.9.0",
ContentHash: "sha256:abc",
Encryption: encryption,
Summary: summary,
Contents: new List<ManifestContentEntry>
{
new("Template", "Pump", 7, new List<string> { "dep-a" }),
});
var json = JsonSerializer.Serialize(manifest, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
Assert.Equal(manifest.ContentHash, rt.ContentHash);
Assert.Equal(manifest.Summary, rt.Summary);
Assert.Single(rt.Contents);
Assert.Equal("Pump", rt.Contents[0].Name);
Assert.Equal(7, rt.Contents[0].Version);
Assert.NotNull(rt.Encryption);
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
Assert.Equal(600_000, rt.Encryption.Iterations);
}
// --------------------------------------------------------------
// ExportSelection
// --------------------------------------------------------------
[Fact]
public void ExportSelection_Constructor_PreservesAllIdLists()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2, 3 },
SharedScriptIds: new[] { 10 },
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 },
IncludeDependencies: true);
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
Assert.Single(sel.SharedScriptIds);
Assert.Empty(sel.ExternalSystemIds);
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
Assert.True(sel.IncludeDependencies);
}
[Fact]
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2 },
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: new[] { 5 },
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
var json = JsonSerializer.Serialize(sel, JsonOpts);
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
Assert.False(rt.IncludeDependencies);
}
// --------------------------------------------------------------
// ImportPreview
// --------------------------------------------------------------
[Fact]
public void ImportPreview_Constructor_AllowsAllConflictKinds()
{
var sessionId = Guid.NewGuid();
var items = new List<ImportPreviewItem>
{
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
};
var preview = new ImportPreview(sessionId, items);
Assert.Equal(sessionId, preview.SessionId);
Assert.Equal(4, preview.Items.Count);
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
}
[Fact]
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
{
var preview = new ImportPreview(
SessionId: Guid.NewGuid(),
Items: new List<ImportPreviewItem>
{
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
new("Template", "Y", null, 1, ConflictKind.New, null, null),
});
var json = JsonSerializer.Serialize(preview, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(preview.SessionId, rt!.SessionId);
Assert.Equal(2, rt.Items.Count);
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
Assert.Null(rt.Items[1].ExistingVersion);
}
// --------------------------------------------------------------
// ImportResolution
// --------------------------------------------------------------
[Theory]
[InlineData(ResolutionAction.Add, null)]
[InlineData(ResolutionAction.Overwrite, null)]
[InlineData(ResolutionAction.Skip, null)]
[InlineData(ResolutionAction.Rename, "NewName")]
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
{
var res = new ImportResolution("Template", "Pump", action, renameTo);
Assert.Equal("Template", res.EntityType);
Assert.Equal("Pump", res.Name);
Assert.Equal(action, res.Action);
Assert.Equal(renameTo, res.RenameTo);
}
[Fact]
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
{
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
var json = JsonSerializer.Serialize(res, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(ResolutionAction.Rename, rt!.Action);
Assert.Equal("Pump_v2", rt.RenameTo);
}
// --------------------------------------------------------------
// ImportResult
// --------------------------------------------------------------
[Fact]
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
{
var bundleImportId = Guid.NewGuid();
var result = new ImportResult(
BundleImportId: bundleImportId,
Added: 3,
Overwritten: 1,
Skipped: 2,
Renamed: 1,
StaleInstanceIds: new List<int> { 100, 200, 300 },
AuditEventCorrelation: "audit-corr-001");
Assert.Equal(bundleImportId, result.BundleImportId);
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Overwritten);
Assert.Equal(2, result.Skipped);
Assert.Equal(1, result.Renamed);
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
}
[Fact]
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
{
var result = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 5,
Overwritten: 0,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "audit-corr-xyz");
var json = JsonSerializer.Serialize(result, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
Assert.Equal(5, rt.Added);
Assert.Empty(rt.StaleInstanceIds);
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
}
// --------------------------------------------------------------
// Record equality sanity (catches positional/tuple slip)
// --------------------------------------------------------------
[Fact]
public void TransportRecords_RecordValueEquality()
{
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var c = a with { Action = ResolutionAction.Overwrite };
Assert.NotEqual(a, c);
}
}
@@ -0,0 +1,80 @@
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression:
/// formatting must be culture-invariant because the formatter feeds non-UI contexts
/// (gRPC stream events, logs) where locale-dependent output would be inconsistent.
/// </summary>
public class ValueFormatterTests
{
[Fact]
public void FormatDisplayValue_Null_ReturnsEmptyString()
{
Assert.Equal("", ValueFormatter.FormatDisplayValue(null));
}
[Fact]
public void FormatDisplayValue_String_ReturnsValueUnchanged()
{
Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello"));
}
[Fact]
public void FormatDisplayValue_Collection_JoinsWithComma()
{
Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 }));
}
// ── Commons-012 regression: culture-invariant numeric/date formatting ──
[Fact]
public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
// German uses a comma as the decimal separator; invariant uses a dot.
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("3.14", ValueFormatter.FormatDisplayValue(3.14));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var dt = new DateTime(2026, 5, 16, 0, 0, 0, DateTimeKind.Utc);
var invariant = dt.ToString(CultureInfo.InvariantCulture);
Assert.Equal(invariant, ValueFormatter.FormatDisplayValue(dt));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("1.5,2.5", ValueFormatter.FormatDisplayValue(new[] { 1.5, 2.5 }));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
}