Files
ScadaBridge/tests/ScadaLink.Commons.Tests/Messages/CompatibilityTests.cs
T
Joseph Doherty ac96b83b08 fix(high-severity): close 9 of 10 open High findings across 8 modules
Comm-016: delete dead HandleConnectionStateChanged + _debugSubscriptions /
_inProgressDeployments tracking + ConnectionStateChanged message record.
Disconnect detection is owned by the transport layers (gRPC keepalive PING
~25s; Ask-timeout at CommunicationService). Updates the
Component-Communication.md design doc to make that explicit.

SnF-018: NotificationForwarder.DeliverAsync now discards a corrupt buffered
payload (Warning log + return true) instead of returning false and parking
the row — honoring the design's "notifications do not park" invariant.

DM-018: reconciliation no longer force-sets Enabled, preserving an
intentional Disabled state after central failover.

ESG-018: DeliverBufferedAsync (both ExternalSystemClient + DatabaseGateway)
catches JsonException and returns false, turning a corrupt buffered row
into a parked operation instead of a retry-forever poison message.

InboundAPI-022: register ActiveNodeGate as IActiveNodeGate in the Central
DI branch so standby-node gating is actually wired up in production.

NS-019: remove orphaned NotificationDeliveryService /
INotificationDeliveryService / NotificationResult; central notification
delivery now lives entirely in NotificationOutbox.

SEL-016: normalise From/To filters to UTC before ISO-string compare so
non-UTC DateTimeOffset clients no longer get spuriously excluded events.

TE-017: include Description on attributes/alarms and a HashableConnections
projection (protocol, endpoint JSON, failover count) in the revision hash
and DiffService; staleness detection now catches description-only and
connection-endpoint edits.

Transport-001 and Transport-002 (also High) remain Open — they're being
handled in a follow-up batch because both touch BundleImporter.cs and
must serialise.
2026-05-28 05:40:15 -04:00

367 lines
13 KiB
C#

using System.Text.Json;
using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.Commons.Messages.Communication;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Messages;
/// <summary>
/// WP-9 (Phase 8): Message contract compatibility tests.
/// Verifies forward compatibility (unknown fields), backward compatibility (missing optional fields),
/// and version skew scenarios for all critical message types.
/// </summary>
public class CompatibilityTests
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true
};
// ── Forward Compatibility: unknown fields are ignored ──
[Fact]
public void ForwardCompat_DeployInstanceCommand_UnknownFieldIgnored()
{
var json = """
{
"DeploymentId": "dep-1",
"InstanceUniqueName": "inst-1",
"RevisionHash": "abc123",
"FlattenedConfigurationJson": "{}",
"DeployedBy": "admin",
"Timestamp": "2025-01-01T00:00:00+00:00",
"FutureField": "unknown-value",
"AnotherNewField": 42
}
""";
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
Assert.NotNull(msg);
Assert.Equal("dep-1", msg!.DeploymentId);
Assert.Equal("abc123", msg.RevisionHash);
}
[Fact]
public void ForwardCompat_SiteHealthReport_UnknownFieldIgnored()
{
var json = """
{
"SiteId": "site-01",
"SequenceNumber": 5,
"ReportTimestamp": "2025-01-01T00:00:00+00:00",
"DataConnectionStatuses": {},
"TagResolutionCounts": {},
"ScriptErrorCount": 0,
"AlarmEvaluationErrorCount": 0,
"StoreAndForwardBufferDepths": {},
"DeadLetterCount": 0,
"FutureMetric": 99
}
""";
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
Assert.NotNull(msg);
Assert.Equal("site-01", msg!.SiteId);
Assert.Equal(5, msg.SequenceNumber);
}
[Fact]
public void ForwardCompat_ScriptCallRequest_UnknownFieldIgnored()
{
var json = """
{
"CorrelationId": "corr-1",
"InstanceUniqueName": "inst-1",
"ScriptName": "OnTrigger",
"Parameters": {},
"Timestamp": "2025-01-01T00:00:00+00:00",
"NewExecutionMode": "parallel"
}
""";
var msg = JsonSerializer.Deserialize<ScriptCallRequest>(json, Options);
Assert.NotNull(msg);
Assert.Equal("corr-1", msg!.CorrelationId);
Assert.Equal("OnTrigger", msg.ScriptName);
}
[Fact]
public void ForwardCompat_AttributeValueChanged_UnknownFieldIgnored()
{
var json = """
{
"InstanceUniqueName": "inst-1",
"AttributeName": "Temperature",
"TagPath": "opc:ns=2;s=Temp",
"Value": 42.5,
"Quality": "Good",
"Timestamp": "2025-01-01T00:00:00+00:00",
"SourceInfo": {"origin": "future-feature"}
}
""";
var msg = JsonSerializer.Deserialize<AttributeValueChanged>(json, Options);
Assert.NotNull(msg);
Assert.Equal("Temperature", msg!.AttributeName);
}
// ── Backward Compatibility: missing optional fields ──
[Fact]
public void BackwardCompat_DeploymentStatusResponse_MissingErrorMessage()
{
var json = """
{
"DeploymentId": "dep-1",
"InstanceUniqueName": "inst-1",
"Status": 2,
"Timestamp": "2025-01-01T00:00:00+00:00"
}
""";
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
Assert.NotNull(msg);
Assert.Equal("dep-1", msg!.DeploymentId);
Assert.Null(msg.ErrorMessage);
}
[Fact]
public void BackwardCompat_ScriptCallResult_MissingReturnValue()
{
var json = """
{
"CorrelationId": "corr-1",
"Success": false,
"ErrorMessage": "Script not found"
}
""";
var msg = JsonSerializer.Deserialize<ScriptCallResult>(json, Options);
Assert.NotNull(msg);
Assert.False(msg!.Success);
Assert.Null(msg.ReturnValue);
}
[Fact]
public void BackwardCompat_DeployArtifactsCommand_MissingOptionalLists()
{
var json = """
{
"DeploymentId": "dep-1",
"Timestamp": "2025-01-01T00:00:00+00:00"
}
""";
var msg = JsonSerializer.Deserialize<DeployArtifactsCommand>(json, Options);
Assert.NotNull(msg);
Assert.Equal("dep-1", msg!.DeploymentId);
Assert.Null(msg.SharedScripts);
Assert.Null(msg.ExternalSystems);
}
[Fact]
public void BackwardCompat_InstanceLifecycleResponse_MissingErrorMessage()
{
var json = """
{
"CommandId": "cmd-1",
"InstanceUniqueName": "inst-1",
"Success": true,
"Timestamp": "2025-01-01T00:00:00+00:00"
}
""";
var msg = JsonSerializer.Deserialize<InstanceLifecycleResponse>(json, Options);
Assert.NotNull(msg);
Assert.True(msg!.Success);
Assert.Null(msg.ErrorMessage);
}
// ── Version Skew: old message format still deserializable ──
[Fact]
public void VersionSkew_OldDeployCommand_DeserializesWithDefaults()
{
// Simulate an older version that only had DeploymentId and InstanceUniqueName
var json = """
{
"DeploymentId": "dep-old",
"InstanceUniqueName": "inst-old",
"RevisionHash": "old-hash",
"FlattenedConfigurationJson": "{}",
"DeployedBy": "admin",
"Timestamp": "2024-06-01T00:00:00+00:00"
}
""";
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
Assert.NotNull(msg);
Assert.Equal("dep-old", msg!.DeploymentId);
Assert.Equal("old-hash", msg.RevisionHash);
}
[Fact]
public void VersionSkew_OldHealthReport_DeserializesCorrectly()
{
// Older version without DeadLetterCount
var json = """
{
"SiteId": "site-old",
"SequenceNumber": 1,
"ReportTimestamp": "2024-06-01T00:00:00+00:00",
"DataConnectionStatuses": {"conn1": 0},
"TagResolutionCounts": {},
"ScriptErrorCount": 0,
"AlarmEvaluationErrorCount": 0,
"StoreAndForwardBufferDepths": {}
}
""";
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
Assert.NotNull(msg);
Assert.Equal("site-old", msg!.SiteId);
Assert.Equal(0, msg.DeadLetterCount); // Default value
}
// ── Round-trip serialization for all key message types ──
// Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed
// alongside the dead ConnectionStateChanged message record. No production
// code emits or receives this message — disconnect detection is owned by
// the gRPC keepalive and the Ask-timeout path.
[Fact]
public void RoundTrip_AlarmStateChanged_Succeeds()
{
var msg = new AlarmStateChanged("inst-1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal(AlarmState.Active, deserialized!.State);
Assert.Equal("HighTemp", deserialized.AlarmName);
}
[Fact]
public void RoundTrip_HeartbeatMessage_Succeeds()
{
var msg = new HeartbeatMessage("site-01", "node-a", true, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<HeartbeatMessage>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("site-01", deserialized!.SiteId);
Assert.Equal("node-a", deserialized.NodeHostname);
}
[Fact]
public void RoundTrip_DisableInstanceCommand_Succeeds()
{
var msg = new DisableInstanceCommand("cmd-1", "inst-1", DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<DisableInstanceCommand>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("cmd-1", deserialized!.CommandId);
}
[Fact]
public void RoundTrip_EnableInstanceCommand_Succeeds()
{
var msg = new EnableInstanceCommand("cmd-2", "inst-1", DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<EnableInstanceCommand>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("cmd-2", deserialized!.CommandId);
}
[Fact]
public void RoundTrip_DeleteInstanceCommand_Succeeds()
{
var msg = new DeleteInstanceCommand("cmd-3", "inst-1", DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<DeleteInstanceCommand>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("cmd-3", deserialized!.CommandId);
}
// ── Additive-only evolution: new fields added as nullable ──
[Fact]
public void AdditiveEvolution_NewNullableFields_DoNotBreakDeserialization()
{
// The design mandates additive-only evolution for message contracts.
// New fields must be nullable/optional so old producers don't break new consumers.
// This test verifies the pattern works with System.Text.Json.
var minimalJson = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":1,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(minimalJson, Options);
Assert.NotNull(msg);
Assert.Null(msg!.ErrorMessage); // Optional field defaults to null
}
[Fact]
public void EnumDeserialization_UnknownValue_HandledGracefully()
{
// If a newer version adds a new enum value, older consumers should handle it.
// System.Text.Json will deserialize unknown numeric enum values as the numeric value.
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":99,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
Assert.NotNull(msg);
Assert.Equal((DeploymentStatus)99, msg!.Status);
}
// ── DeploymentManager-006: query-the-site-before-redeploy contracts ──
[Fact]
public void RoundTrip_DeploymentStateQueryRequest_Succeeds()
{
var msg = new DeploymentStateQueryRequest("corr-1", "inst-1", DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryRequest>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("corr-1", deserialized!.CorrelationId);
Assert.Equal("inst-1", deserialized.InstanceUniqueName);
}
[Fact]
public void RoundTrip_DeploymentStateQueryResponse_Deployed_Succeeds()
{
var msg = new DeploymentStateQueryResponse(
"corr-1", "inst-1", true, "dep-9", "sha256:abc", DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
Assert.NotNull(deserialized);
Assert.True(deserialized!.IsDeployed);
Assert.Equal("dep-9", deserialized.AppliedDeploymentId);
Assert.Equal("sha256:abc", deserialized.AppliedRevisionHash);
}
[Fact]
public void RoundTrip_DeploymentStateQueryResponse_NotDeployed_NullApplied()
{
// When the instance is not deployed at the site, the applied identity
// fields are null — verified to survive a JSON round-trip.
var msg = new DeploymentStateQueryResponse(
"corr-1", "inst-1", false, null, null, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
Assert.NotNull(deserialized);
Assert.False(deserialized!.IsDeployed);
Assert.Null(deserialized.AppliedDeploymentId);
Assert.Null(deserialized.AppliedRevisionHash);
}
}