- WP-1-3: Central/site failover + dual-node recovery tests (17 tests) - WP-4: Performance testing framework for target scale (7 tests) - WP-5: Security hardening (LDAPS, JWT key length, no secrets in logs) (11 tests) - WP-6: Script sandboxing adversarial tests (28 tests, all forbidden APIs) - WP-7: Recovery drill test scaffolds (5 tests) - WP-8: Observability validation (structured logs, correlation IDs, metrics) (6 tests) - WP-9: Message contract compatibility (forward/backward compat) (18 tests) - WP-10: Deployment packaging (installation guide, production checklist, topology) - WP-11: Operational runbooks (failover, troubleshooting, maintenance) 92 new tests, all passing. Zero warnings.
330 lines
11 KiB
C#
330 lines
11 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 ──
|
|
|
|
[Fact]
|
|
public void RoundTrip_ConnectionStateChanged_Succeeds()
|
|
{
|
|
var msg = new ConnectionStateChanged("site-01", true, DateTimeOffset.UtcNow);
|
|
var json = JsonSerializer.Serialize(msg);
|
|
var deserialized = JsonSerializer.Deserialize<ConnectionStateChanged>(json, Options);
|
|
|
|
Assert.NotNull(deserialized);
|
|
Assert.Equal("site-01", deserialized!.SiteId);
|
|
Assert.True(deserialized.IsConnected);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|