Phase 8: Production readiness — failover tests, security hardening, sandboxing, deployment docs
- 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.
This commit is contained in:
329
tests/ScadaLink.Commons.Tests/Messages/CompatibilityTests.cs
Normal file
329
tests/ScadaLink.Commons.Tests/Messages/CompatibilityTests.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user