50a77a8170
TimeoutSeconds is the per-system HTTP timeout ExternalSystemClient reads at call time and is deployed to sites (proto field 6), but Transport bundles silently dropped it: the DTO never carried it, export never wrote it, the importer's Add path reset it to 0, the Overwrite path left the target's stale value untouched, and ArtifactDiff never compared it (so a bundle whose only change was a tuned timeout previewed as Identical). Adds TimeoutSeconds as an additive-optional trailing field on ExternalSystemDto (schemaVersion stays 1.1, bundleFormatVersion unchanged) and threads it through EntitySerializer (export + import), BundleImporter (Add and Overwrite), and ArtifactDiff.CompareExternalSystem. Old-bundle semantics pinned: an absent field (null) falls back to the entity default on Add and leaves the target's existing value untouched on Overwrite/diff. Beefed up the RoundTripEquivalenceTests external-system fixture with a non-default TimeoutSeconds so the reflection round-trip guard actually exercises the field instead of comparing default against default. Swept the rest of the ExternalSystemDto shape against ExternalSystemDefinition field-by-field; no other operative field is missing.
376 lines
16 KiB
C#
376 lines
16 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
|
|
|
|
/// <summary>
|
|
/// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle
|
|
/// DTOs (<see cref="SiteDto"/>, <see cref="DataConnectionDto"/>,
|
|
/// <see cref="InstanceDto"/> and the extended <see cref="BundleContentDto"/>).
|
|
/// </summary>
|
|
public sealed class BundleDtoSerializationTests
|
|
{
|
|
// Use the production serialization contract (WhenWritingNull + enum-as-string
|
|
// + unknown-member tolerance) so these tests exercise the real wire format.
|
|
private static readonly JsonSerializerOptions Options = BundleJsonOptions.Default;
|
|
|
|
private static BundleContentDto NewFullContent() =>
|
|
new(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
Sites = new[]
|
|
{
|
|
new SiteDto(
|
|
SiteIdentifier: "site-a",
|
|
Name: "Site A",
|
|
Description: "Primary site",
|
|
NodeAAddress: "akka.tcp://site@10.0.0.1:7000",
|
|
NodeBAddress: "akka.tcp://site@10.0.0.2:7000",
|
|
GrpcNodeAAddress: "10.0.0.1:8083",
|
|
GrpcNodeBAddress: "10.0.0.2:8083"),
|
|
},
|
|
DataConnections = new[]
|
|
{
|
|
new DataConnectionDto(
|
|
SiteIdentifier: "site-a",
|
|
Name: "opc-main",
|
|
Protocol: "OpcUa",
|
|
FailoverRetryCount: 5,
|
|
Secrets: new SecretsBlock(new Dictionary<string, string>
|
|
{
|
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://primary:4840\"}",
|
|
["BackupConfiguration"] = "{\"endpoint\":\"opc.tcp://backup:4840\"}",
|
|
})),
|
|
},
|
|
Instances = new[]
|
|
{
|
|
new InstanceDto(
|
|
UniqueName: "Pump-001",
|
|
TemplateName: "PumpTemplate",
|
|
SiteIdentifier: "site-a",
|
|
AreaName: "AreaWest",
|
|
State: InstanceState.Enabled,
|
|
AttributeOverrides: new[]
|
|
{
|
|
new InstanceAttributeOverrideDto(
|
|
AttributeName: "Setpoint",
|
|
OverrideValue: "42.5",
|
|
ElementDataType: DataType.Double),
|
|
},
|
|
AlarmOverrides: new[]
|
|
{
|
|
new InstanceAlarmOverrideDto(
|
|
AlarmCanonicalName: "HighTemp",
|
|
TriggerConfigurationOverride: "{\"limit\":90}",
|
|
PriorityLevelOverride: 800),
|
|
},
|
|
NativeAlarmSourceOverrides: new[]
|
|
{
|
|
new InstanceNativeAlarmSourceOverrideDto(
|
|
SourceCanonicalName: "AnC.Source1",
|
|
ConnectionNameOverride: "opc-main",
|
|
SourceReferenceOverride: "ns=2;s=Alarm1",
|
|
ConditionFilterOverride: "severity>=500"),
|
|
},
|
|
ConnectionBindings: new[]
|
|
{
|
|
new InstanceConnectionBindingDto(
|
|
AttributeName: "Pressure",
|
|
ConnectionName: "opc-main",
|
|
DataSourceReferenceOverride: "ns=2;s=Pressure"),
|
|
}),
|
|
},
|
|
};
|
|
|
|
[Fact]
|
|
public void FullContent_RoundTrips_WithDeepEquality()
|
|
{
|
|
var original = NewFullContent();
|
|
|
|
var json = JsonSerializer.Serialize(original, Options);
|
|
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(json, Options);
|
|
|
|
Assert.NotNull(roundTripped);
|
|
// Record value-equality compares collection *references*, not contents, so
|
|
// assert the structural members explicitly to get real deep equality.
|
|
// Assert.Equal over IEnumerable compares element-by-element (each element
|
|
// is itself a record, hence value-compared).
|
|
Assert.Equal(original.Sites, roundTripped!.Sites);
|
|
Assert.Equal(original.DataConnections.Count, roundTripped.DataConnections.Count);
|
|
Assert.Equal(original.Instances.Count, roundTripped.Instances.Count);
|
|
|
|
var site = Assert.Single(roundTripped.Sites);
|
|
Assert.Equal(original.Sites[0], site);
|
|
|
|
var conn = Assert.Single(roundTripped.DataConnections);
|
|
Assert.Equal("site-a", conn.SiteIdentifier);
|
|
Assert.Equal("opc-main", conn.Name);
|
|
Assert.Equal("OpcUa", conn.Protocol);
|
|
Assert.Equal(5, conn.FailoverRetryCount);
|
|
Assert.NotNull(conn.Secrets);
|
|
Assert.Equal(
|
|
"{\"endpoint\":\"opc.tcp://primary:4840\"}",
|
|
conn.Secrets!.Values["PrimaryConfiguration"]);
|
|
Assert.Equal(
|
|
"{\"endpoint\":\"opc.tcp://backup:4840\"}",
|
|
conn.Secrets.Values["BackupConfiguration"]);
|
|
|
|
var inst = Assert.Single(roundTripped.Instances);
|
|
Assert.Equal("Pump-001", inst.UniqueName);
|
|
Assert.Equal("PumpTemplate", inst.TemplateName);
|
|
Assert.Equal("site-a", inst.SiteIdentifier);
|
|
Assert.Equal("AreaWest", inst.AreaName);
|
|
Assert.Equal(InstanceState.Enabled, inst.State);
|
|
|
|
Assert.Equal(original.Instances[0].AttributeOverrides[0], inst.AttributeOverrides[0]);
|
|
Assert.Equal(original.Instances[0].AlarmOverrides[0], inst.AlarmOverrides[0]);
|
|
Assert.Equal(original.Instances[0].NativeAlarmSourceOverrides[0], inst.NativeAlarmSourceOverrides[0]);
|
|
Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnumsSerializeAsStrings_ForHumanReadableWire()
|
|
{
|
|
var json = JsonSerializer.Serialize(NewFullContent(), Options);
|
|
|
|
// InstanceState + DataType go out as their names, not integers, because
|
|
// the shared options register JsonStringEnumConverter.
|
|
Assert.Contains("\"Enabled\"", json, StringComparison.Ordinal);
|
|
Assert.Contains("\"Double\"", json, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull()
|
|
{
|
|
// A pre-M8 bundle content blob: only the original central-config arrays
|
|
// are present; sites/dataConnections/instances fields are entirely absent
|
|
// from the JSON text.
|
|
const string legacyJson = """
|
|
{
|
|
"TemplateFolders": [],
|
|
"Templates": [],
|
|
"SharedScripts": [],
|
|
"ExternalSystems": [],
|
|
"DatabaseConnections": [],
|
|
"NotificationLists": [],
|
|
"SmtpConfigs": [],
|
|
"ApiMethods": []
|
|
}
|
|
""";
|
|
|
|
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
|
|
|
|
Assert.NotNull(content);
|
|
// The forward-compat invariant: the new arrays come back EMPTY, never null.
|
|
Assert.NotNull(content!.Sites);
|
|
Assert.Empty(content.Sites);
|
|
Assert.NotNull(content.DataConnections);
|
|
Assert.Empty(content.DataConnections);
|
|
Assert.NotNull(content.Instances);
|
|
Assert.Empty(content.Instances);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultConstructed_NewArrays_AreEmpty_NotNull()
|
|
{
|
|
// Existing positional callers that never set the M8 members still get
|
|
// empty (not null) collections, so consumers can enumerate unconditionally.
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
Assert.NotNull(content.Sites);
|
|
Assert.Empty(content.Sites);
|
|
Assert.NotNull(content.DataConnections);
|
|
Assert.Empty(content.DataConnections);
|
|
Assert.NotNull(content.Instances);
|
|
Assert.Empty(content.Instances);
|
|
}
|
|
|
|
// --- arch-review 2026-08-19 P7-1: ExternalSystem TimeoutSeconds wire format ---
|
|
|
|
/// <summary>
|
|
/// Pins the old-bundle semantics: a bundle written before <c>TimeoutSeconds</c>
|
|
/// existed carries no such property in the JSON text at all (not even a null). It
|
|
/// must deserialize to a null DTO value — never to 0, which is a distinct, meaningful
|
|
/// wire value (the entity's own "unset, gateway default applies" sentinel) — so
|
|
/// BundleImporter can tell "not specified by this bundle" apart from "explicitly
|
|
/// unset" and apply the documented fallback (Add: entity default; Overwrite: leave
|
|
/// the target's existing value untouched).
|
|
/// </summary>
|
|
[Fact]
|
|
public void LegacyExternalSystem_MissingTimeoutSeconds_DeserializesToNull()
|
|
{
|
|
const string legacyJson = """
|
|
{
|
|
"TemplateFolders": [],
|
|
"Templates": [],
|
|
"SharedScripts": [],
|
|
"ExternalSystems": [
|
|
{
|
|
"Name": "erp",
|
|
"BaseUrl": "https://erp/api",
|
|
"AuthType": "ApiKey",
|
|
"MaxRetries": 3,
|
|
"RetryDelay": "00:00:05",
|
|
"Methods": [],
|
|
"Secrets": null
|
|
}
|
|
],
|
|
"DatabaseConnections": [],
|
|
"NotificationLists": [],
|
|
"SmtpConfigs": [],
|
|
"ApiMethods": []
|
|
}
|
|
""";
|
|
|
|
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
|
|
|
|
Assert.NotNull(content);
|
|
var sys = Assert.Single(content!.ExternalSystems);
|
|
Assert.Equal("erp", sys.Name);
|
|
Assert.Equal(3, sys.MaxRetries);
|
|
Assert.Null(sys.TimeoutSeconds);
|
|
}
|
|
|
|
// --- S10: SMS recipient PhoneNumber + SmsConfig wire format ---------------
|
|
|
|
[Fact]
|
|
public void LegacyRecipient_MissingPhoneNumber_DeserializesToNull()
|
|
{
|
|
// A pre-SMS bundle: notification-list recipients carry only Name + Email,
|
|
// with no PhoneNumber field present in the JSON text at all.
|
|
const string legacyJson = """
|
|
{
|
|
"TemplateFolders": [],
|
|
"Templates": [],
|
|
"SharedScripts": [],
|
|
"ExternalSystems": [],
|
|
"DatabaseConnections": [],
|
|
"NotificationLists": [
|
|
{
|
|
"Name": "alerts",
|
|
"Type": "Email",
|
|
"Recipients": [ { "Name": "Ops", "EmailAddress": "ops@example.com" } ]
|
|
}
|
|
],
|
|
"SmtpConfigs": [],
|
|
"ApiMethods": []
|
|
}
|
|
""";
|
|
|
|
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
|
|
|
|
Assert.NotNull(content);
|
|
var recipient = Assert.Single(Assert.Single(content!.NotificationLists).Recipients);
|
|
Assert.Equal("Ops", recipient.Name);
|
|
Assert.Equal("ops@example.com", recipient.EmailAddress);
|
|
// The backward-compat invariant: an absent PhoneNumber comes back null.
|
|
Assert.Null(recipient.PhoneNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void LegacyContent_MissingSmsConfigsArray_DeserializesToEmpty_NotNull()
|
|
{
|
|
// A pre-SMS bundle has no smsConfigs field; the importer must see an empty
|
|
// (never null) collection — same forward-compat invariant as the M8 arrays.
|
|
const string legacyJson = """
|
|
{
|
|
"TemplateFolders": [],
|
|
"Templates": [],
|
|
"SharedScripts": [],
|
|
"ExternalSystems": [],
|
|
"DatabaseConnections": [],
|
|
"NotificationLists": [],
|
|
"SmtpConfigs": [],
|
|
"ApiMethods": []
|
|
}
|
|
""";
|
|
|
|
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
|
|
|
|
Assert.NotNull(content);
|
|
Assert.NotNull(content!.SmsConfigs);
|
|
Assert.Empty(content.SmsConfigs);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultConstructed_SmsConfigs_IsEmpty_NotNull()
|
|
{
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
Assert.NotNull(content.SmsConfigs);
|
|
Assert.Empty(content.SmsConfigs);
|
|
}
|
|
|
|
[Fact]
|
|
public void SmsConfig_AuthToken_RidesSecretsBlock_NotCleartextPublicFields()
|
|
{
|
|
// The auth token must travel only inside the SecretsBlock, exactly like the
|
|
// SMTP credentials test pattern — it must NOT leak into any public DTO field.
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
SmsConfigs = new[]
|
|
{
|
|
new SmsConfigDto(
|
|
AccountSid: "AC123",
|
|
FromNumber: "+15550001111",
|
|
MessagingServiceSid: "MG999",
|
|
ApiBaseUrl: "https://api.twilio.com",
|
|
ConnectionTimeoutSeconds: 20,
|
|
MaxRetries: 7,
|
|
RetryDelay: TimeSpan.FromSeconds(45),
|
|
Secrets: new SecretsBlock(new Dictionary<string, string>
|
|
{
|
|
["AuthToken"] = "twilio-super-secret",
|
|
})),
|
|
},
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(content, Options);
|
|
|
|
// The secret appears exactly once — inside the SecretsBlock — and the
|
|
// round-trip restores it.
|
|
Assert.Contains("twilio-super-secret", json, StringComparison.Ordinal);
|
|
Assert.Contains("\"AuthToken\"", json, StringComparison.Ordinal);
|
|
|
|
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(json, Options);
|
|
Assert.NotNull(roundTripped);
|
|
var sms = Assert.Single(roundTripped!.SmsConfigs);
|
|
Assert.Equal("AC123", sms.AccountSid);
|
|
Assert.Equal("+15550001111", sms.FromNumber);
|
|
Assert.NotNull(sms.Secrets);
|
|
Assert.Equal("twilio-super-secret", sms.Secrets!.Values["AuthToken"]);
|
|
}
|
|
}
|