docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
+8
-1
@@ -7,11 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Encryption;
|
||||
|
||||
public sealed class BundleSecretEncryptorTests
|
||||
{
|
||||
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum);
|
||||
// The documented PBKDF2 floor is 100_000 (OWASP minimum);
|
||||
// the production value is 600_000. Using the floor keeps the test fast while
|
||||
// remaining a valid EncryptionMetadata.Iterations.
|
||||
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
|
||||
|
||||
/// <summary>Verifies that encrypting and then decrypting arbitrary bytes returns the original bytes.</summary>
|
||||
[Fact]
|
||||
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
Assert.Equal(plaintext, recovered);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that decrypting with the wrong passphrase throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary>
|
||||
[Fact]
|
||||
public void Decrypt_with_wrong_passphrase_throws_CryptographicException()
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "wrong-pass"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that decrypting tampered ciphertext throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary>
|
||||
[Fact]
|
||||
public void Decrypt_with_tampered_ciphertext_throws_CryptographicException()
|
||||
{
|
||||
@@ -47,6 +50,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "pass"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encrypting the same input twice produces different ciphertext because of the random IV.</summary>
|
||||
[Fact]
|
||||
public void Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv()
|
||||
{
|
||||
@@ -61,6 +65,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
Assert.NotEqual(meta1.SaltB64, meta2.SaltB64);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that decrypting with mismatched associated data throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary>
|
||||
[Fact]
|
||||
public void Decrypt_with_mismatched_AAD_throws_CryptographicException()
|
||||
{
|
||||
@@ -78,6 +83,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
() => sut.Decrypt(ciphertext, metadata, "pass", aadOnDecrypt));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that decrypting with matching associated data recovers the plaintext.</summary>
|
||||
[Fact]
|
||||
public void Decrypt_with_matching_AAD_recovers_plaintext()
|
||||
{
|
||||
@@ -92,6 +98,7 @@ public sealed class BundleSecretEncryptorTests
|
||||
Assert.Equal(plaintext, recovered);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encryption emits metadata matching the inputs needed for decryption.</summary>
|
||||
[Fact]
|
||||
public void Encrypt_emits_metadata_matching_decryption_inputs()
|
||||
{
|
||||
|
||||
@@ -132,6 +132,8 @@ public sealed class DependencyResolverTests
|
||||
.Returns(instance.ConnectionBindings.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the base template for a composed template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_base_template_for_composed_template()
|
||||
{
|
||||
@@ -152,6 +154,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Contains(result.Templates, t => t.Id == 11);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the shared script referenced by a template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_shared_script_referenced_by_template()
|
||||
{
|
||||
@@ -170,6 +174,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal("UtilHelper", result.SharedScripts[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the external system referenced by a template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_external_system_referenced_by_template()
|
||||
{
|
||||
@@ -188,6 +194,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal("ErpSystem", result.ExternalSystems[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the shared script dependency of an API method.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_api_method_shared_script_dependency()
|
||||
{
|
||||
@@ -206,6 +214,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal("Validator", result.SharedScripts[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the selected SMS configurations.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_selected_sms_configs()
|
||||
{
|
||||
@@ -226,6 +236,8 @@ public sealed class DependencyResolverTests
|
||||
e => e.Type == "SmsConfiguration" && e.Name == "AC_test_sid");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving handles a diamond dependency without duplication.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_handles_diamond_dependency_without_duplication()
|
||||
{
|
||||
@@ -253,6 +265,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Single(result.Templates, t => t.Id == 4);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving includes the template folder for each selected template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_includes_template_folder_for_each_selected_template()
|
||||
{
|
||||
@@ -276,6 +290,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal("Grand", result.TemplateFolders[2].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving returns a topological order with base templates before derived ones.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_returns_topological_order_base_before_derived()
|
||||
{
|
||||
@@ -302,9 +318,11 @@ public sealed class DependencyResolverTests
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M8 (B1): site/instance-scoped expansion.
|
||||
// Site/instance-scoped expansion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that resolving a site pulls in its connections and instances.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_site_pulls_its_connections_and_instances()
|
||||
{
|
||||
@@ -331,6 +349,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Single(result.Templates, t => t.Id == 9);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving an instance with dependencies pulls in its site connections and template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_instance_with_deps_pulls_site_connections_and_template()
|
||||
{
|
||||
@@ -370,6 +390,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Single(resolvedInstance.NativeAlarmSourceOverrides);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving an instance without dependencies does not pull in its site or template.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_instance_without_deps_does_not_pull_site_or_template()
|
||||
{
|
||||
@@ -395,6 +417,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Empty(result.Templates);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving deduplicates an instance when both the site and the instance are selected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_dedups_instance_when_site_and_instance_both_selected()
|
||||
{
|
||||
@@ -422,6 +446,8 @@ public sealed class DependencyResolverTests
|
||||
await _templates.Received(1).GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving orders sites, connections and instances deterministically.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_orders_sites_connections_and_instances_deterministically()
|
||||
{
|
||||
@@ -462,6 +488,8 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal(new[] { "Alpha", "Zulu" }, result.Instances.Select(i => i.UniqueName).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving emits manifest entries with dependency edges for site scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_emits_manifest_entries_with_dependency_edges_for_site_scope()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ArtifactDiff"/>: the structured per-line Myers code diff
|
||||
/// embedded in <c>FieldDiffJson</c> (T20) and the M8 Site / DataConnection /
|
||||
/// embedded in <c>FieldDiffJson</c> (T20) and the Site / DataConnection /
|
||||
/// Instance compares. The diff records are private, so assertions parse the
|
||||
/// serialized <see cref="ImportPreviewItem.FieldDiffJson"/>.
|
||||
/// </summary>
|
||||
@@ -61,6 +61,7 @@ public sealed class ArtifactDiffTests
|
||||
|
||||
// ============ T20: structured per-line code diff ============
|
||||
|
||||
/// <summary>Verifies that a modified shared script emits a structured line diff.</summary>
|
||||
[Fact]
|
||||
public void SharedScript_ModifiedCode_EmitsStructuredLineDiff()
|
||||
{
|
||||
@@ -94,6 +95,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.DoesNotContain(hunks, h => h.Text.Contains("lines>"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a shared script with identical code reports no change.</summary>
|
||||
[Fact]
|
||||
public void SharedScript_IdenticalCode_NoChange()
|
||||
{
|
||||
@@ -156,7 +158,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal(ConflictKind.Identical, item.Kind);
|
||||
}
|
||||
|
||||
// ============ arch-review 2026-08-19 P7-1: TimeoutSeconds in the diff (row-27 shape) ============
|
||||
// ============ TimeoutSeconds in the diff ============
|
||||
|
||||
/// <summary>
|
||||
/// The bundle has always CARRIED TimeoutSeconds and BundleImporter has always APPLIED it
|
||||
@@ -220,6 +222,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal(ConflictKind.Identical, item.Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a modified API method script emits a structured line diff.</summary>
|
||||
[Fact]
|
||||
public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff()
|
||||
{
|
||||
@@ -244,6 +247,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Contains(("add", "log(\"b\");"), hunks);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that modified template script code emits a structured line diff.</summary>
|
||||
[Fact]
|
||||
public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff()
|
||||
{
|
||||
@@ -265,8 +269,9 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Contains(("add", "new2"), hunks);
|
||||
}
|
||||
|
||||
// ============ #05-T23: placeholder-identity resolution via name maps ============
|
||||
// ============ placeholder-identity resolution via name maps ============
|
||||
|
||||
/// <summary>Verifies that templates with matching folder, base and composition names compare as identical.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical()
|
||||
{
|
||||
@@ -295,6 +300,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal(ConflictKind.Identical, item.Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a divergent base template name makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_DivergentBaseTemplateName_IsModified()
|
||||
{
|
||||
@@ -311,8 +317,9 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
}
|
||||
|
||||
// ============ #05-T6: single-source child equality — diff sees the full field set ============
|
||||
// ============ single-source child equality — diff sees the full field set ============
|
||||
|
||||
/// <summary>Verifies that a change to an attribute's element data type makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_AttributeElementDataTypeChange_IsModified()
|
||||
{
|
||||
@@ -334,6 +341,7 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a change to the locked-in-derived flag makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_LockedInDerivedChange_IsModified()
|
||||
{
|
||||
@@ -355,6 +363,7 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a change to a script's execution timeout makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()
|
||||
{
|
||||
@@ -372,6 +381,7 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a change to an alarm's on-trigger script makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()
|
||||
{
|
||||
@@ -396,6 +406,7 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a change to an alarm's MES-reportable flag makes the template compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareTemplate_AlarmIsMesReportableChange_IsModified()
|
||||
{
|
||||
@@ -418,6 +429,7 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Alarms.A"); // IsMesReportable diverged.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a change to an instance alarm override's MES-reportable flag makes the instance compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified()
|
||||
{
|
||||
@@ -443,8 +455,9 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
// ============ M8: CompareSite ============
|
||||
// ============ CompareSite ============
|
||||
|
||||
/// <summary>Verifies that comparing a site against no existing site yields a new result.</summary>
|
||||
[Fact]
|
||||
public void CompareSite_ExistingNull_New()
|
||||
{
|
||||
@@ -457,6 +470,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Null(item.FieldDiffJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site with a differing field compares as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareSite_FieldDiffers_Modified()
|
||||
{
|
||||
@@ -471,6 +485,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("new", change.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site with no changes compares as identical.</summary>
|
||||
[Fact]
|
||||
public void CompareSite_NoChanges_Identical()
|
||||
{
|
||||
@@ -490,8 +505,9 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Null(item.FieldDiffJson);
|
||||
}
|
||||
|
||||
// ============ M8: CompareDataConnection ============
|
||||
// ============ CompareDataConnection ============
|
||||
|
||||
/// <summary>Verifies that comparing a data connection against no existing connection yields a new result.</summary>
|
||||
[Fact]
|
||||
public void CompareDataConnection_ExistingNull_New()
|
||||
{
|
||||
@@ -503,6 +519,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("opc1", item.Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a data connection with a different protocol compares as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareDataConnection_ProtocolDiffers_Modified()
|
||||
{
|
||||
@@ -517,6 +534,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("MxAccess", change.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a changed secret value with preserved presence is reported without echoing the secret.</summary>
|
||||
[Fact]
|
||||
public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho()
|
||||
{
|
||||
@@ -541,6 +559,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.DoesNotContain("4840", item.FieldDiffJson ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a flip in secret presence is shown only as a present marker, without the secret value.</summary>
|
||||
[Fact]
|
||||
public void CompareDataConnection_SecretPresenceFlips_ShowsPresentMarkerOnly()
|
||||
{
|
||||
@@ -577,6 +596,7 @@ public sealed class ArtifactDiffTests
|
||||
? new SecretsBlock(new Dictionary<string, string> { ["AuthToken"] = "wire-token" })
|
||||
: null);
|
||||
|
||||
/// <summary>Verifies that comparing an SMS configuration against no existing configuration yields a new result.</summary>
|
||||
[Fact]
|
||||
public void CompareSmsConfiguration_ExistingNull_New()
|
||||
{
|
||||
@@ -587,6 +607,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("AC1", item.Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS configuration whose fields and secret presence all match compares as identical.</summary>
|
||||
[Fact]
|
||||
public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical()
|
||||
{
|
||||
@@ -596,6 +617,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Null(item.FieldDiffJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS configuration with a different from-number compares as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareSmsConfiguration_FromNumberDiffers_Modified()
|
||||
{
|
||||
@@ -607,6 +629,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("+15559999999", change.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a changed auth token value with preserved presence is reported without echoing the secret.</summary>
|
||||
[Fact]
|
||||
public void CompareSmsConfiguration_AuthTokenValueChangesButPresencePreserved_NoSecretEcho()
|
||||
{
|
||||
@@ -620,6 +643,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.DoesNotContain("wire-token", json);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a flip in auth token presence is shown only as a present marker, without the secret value.</summary>
|
||||
[Fact]
|
||||
public void CompareSmsConfiguration_AuthTokenPresenceFlips_ShowsPresentMarkerOnly()
|
||||
{
|
||||
@@ -634,8 +658,9 @@ public sealed class ArtifactDiffTests
|
||||
Assert.DoesNotContain("stored-token", item.FieldDiffJson!);
|
||||
}
|
||||
|
||||
// ============ M8: CompareInstance ============
|
||||
// ============ CompareInstance ============
|
||||
|
||||
/// <summary>Verifies that comparing an instance against no existing instance yields a new result.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_ExistingNull_New()
|
||||
{
|
||||
@@ -647,6 +672,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("inst-1", item.Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance with a differing state compares as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_StateDiffers_Modified()
|
||||
{
|
||||
@@ -661,6 +687,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("Enabled", change.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance with no changes compares as identical.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_NoChanges_Identical()
|
||||
{
|
||||
@@ -673,6 +700,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Null(item.FieldDiffJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that differing template and site names make the instance compare as modified.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_TemplateAndSiteNamesDiffer_Modified()
|
||||
{
|
||||
@@ -689,6 +717,7 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added, removed and modified child overrides are reported for an instance.</summary>
|
||||
[Fact]
|
||||
public void CompareInstance_ChildOverridesAddedRemovedModified()
|
||||
{
|
||||
|
||||
@@ -69,7 +69,10 @@ public sealed class BundleImporterLoadTests
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
/// <summary>Initializes the provider at the given start time.</summary>
|
||||
/// <param name="start">The initial UTC time.</param>
|
||||
public TestTimeProvider(DateTimeOffset start) { _now = start; }
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
@@ -159,6 +162,8 @@ public sealed class BundleImporterLoadTests
|
||||
return serializer.Pack(content, manifest, passphrase, encryptor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading an unencrypted bundle returns a session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_returns_session_for_unencrypted_bundle()
|
||||
{
|
||||
@@ -176,6 +181,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading an encrypted bundle with the correct passphrase returns a session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_returns_session_for_encrypted_bundle_with_correct_passphrase()
|
||||
{
|
||||
@@ -197,6 +204,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Equal("Pump", roundTripped.Templates[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading throws when the passphrase is wrong.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_throws_when_passphrase_wrong()
|
||||
{
|
||||
@@ -210,6 +219,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(stream, passphrase: "wrong"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the bundle is locked after three wrong passphrases, even across callers.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers()
|
||||
{
|
||||
@@ -245,6 +256,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading rejects a bundle with too many entries.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_too_many_entries()
|
||||
{
|
||||
@@ -261,6 +274,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading rejects a bundle with an oversized entry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_oversized_entry()
|
||||
{
|
||||
@@ -277,6 +292,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading rejects a bundle whose entry exceeds the compression ratio.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio()
|
||||
{
|
||||
@@ -352,13 +369,15 @@ public sealed class BundleImporterLoadTests
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading rejects a bundle with a tampered manifest field even with the correct passphrase.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase()
|
||||
{
|
||||
// T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment,
|
||||
// ExportedBy, …) have been edited must fail decryption with a tag mismatch.
|
||||
// Without AAD an attacker could rewrite the SourceEnvironment label and slip
|
||||
// past the Step-4 typo-resistant confirmation gate. We tamper the field by
|
||||
// past the typo-resistant confirmation gate. We tamper the field by
|
||||
// re-packing the manifest (with everything else, including the original
|
||||
// ciphertext, unchanged) into a fresh ZIP and verify decrypt fails.
|
||||
var rig = BuildRig();
|
||||
@@ -405,6 +424,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading resets the unlock counter on the correct passphrase.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase()
|
||||
{
|
||||
@@ -429,6 +450,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading throws a not-supported error when the bundle format version is unsupported.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported()
|
||||
{
|
||||
@@ -458,6 +481,8 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(bundleStream, passphrase: null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading throws an invalid-data error when the content hash does not match.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_throws_InvalidDataException_when_content_hash_mismatch()
|
||||
{
|
||||
@@ -488,6 +513,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Contains("hash", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading throws an invalid-operation error when the bundle exceeds the size cap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_throws_InvalidOperationException_when_bundle_exceeds_size_cap()
|
||||
{
|
||||
@@ -508,6 +535,8 @@ public sealed class BundleImporterLoadTests
|
||||
// invoked, so these all passed-by-doing-nothing before the wiring.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that loading rejects unlock attempts past the per-IP hourly cap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap()
|
||||
{
|
||||
@@ -542,6 +571,8 @@ public sealed class BundleImporterLoadTests
|
||||
new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throttled unlock is not counted as a passphrase failure and burns no strike.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike()
|
||||
{
|
||||
@@ -571,6 +602,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the unlock throttle is isolated per client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Unlock_throttle_is_isolated_per_client()
|
||||
{
|
||||
@@ -598,6 +631,8 @@ public sealed class BundleImporterLoadTests
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unattributed callers share one throttle bucket rather than bypassing it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it()
|
||||
{
|
||||
@@ -623,6 +658,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unencrypted bundles do not consume the unlock budget.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget()
|
||||
{
|
||||
@@ -641,6 +678,8 @@ public sealed class BundleImporterLoadTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an already-locked bundle does not spend the client's hourly budget.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget()
|
||||
{
|
||||
@@ -683,6 +722,8 @@ public sealed class BundleImporterLoadTests
|
||||
// Register row 43 — abandoned-session release.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that abandoning a session releases its slot and zeroes its decrypted content.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content()
|
||||
{
|
||||
@@ -708,6 +749,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.All(decrypted, b => Assert.Equal(0, b));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that abandoning a session is idempotent and tolerates unknown ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Abandon_is_idempotent_and_tolerates_unknown_ids()
|
||||
{
|
||||
@@ -724,6 +767,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.Null(rig.SessionStore.Get(session.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that abandoning a session does not release another caller's session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Abandon_does_not_release_another_callers_session()
|
||||
{
|
||||
@@ -743,6 +788,8 @@ public sealed class BundleImporterLoadTests
|
||||
Assert.NotEmpty(survivor!.DecryptedContent);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that abandoning a session frees a concurrent import session slot.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Abandon_frees_a_concurrent_import_session_slot()
|
||||
{
|
||||
|
||||
+36
-1
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: <c>MaxConcurrentImportSessions</c> had no observability — nothing
|
||||
/// counted open sessions and nothing logged a rejection at the cap, so the register-row-43
|
||||
/// counted open sessions and nothing logged a rejection at the cap, so the earlier
|
||||
/// session leak could only be inferred from rotating test failures. These tests pin the
|
||||
/// two signals that close that gap: the
|
||||
/// <c>scadabridge.central.transport.import_sessions.open</c> gauge and the Warning-level
|
||||
@@ -25,6 +25,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
// Warning log on cap rejection
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that opening a session at the cap logs a warning with the session count and the cap.</summary>
|
||||
[Fact]
|
||||
public void Open_AtCap_LogsWarningWithCountAndCap()
|
||||
{
|
||||
@@ -50,6 +51,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Contains("MaxConcurrentImportSessions", warning.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session under the cap logs nothing.</summary>
|
||||
[Fact]
|
||||
public void Open_UnderCap_LogsNothing()
|
||||
{
|
||||
@@ -64,6 +66,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Empty(logger.Entries);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening at a cap filled by expired sessions reclaims them and logs nothing.</summary>
|
||||
[Fact]
|
||||
public void Open_AtCapOfExpiredSessions_ReclaimsAndLogsNothing()
|
||||
{
|
||||
@@ -88,6 +91,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
// Rejection counter (register row 66)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that opening a session at the cap increments the rejection counter.</summary>
|
||||
[Fact]
|
||||
public void Open_AtCap_IncrementsTheRejectionCounter()
|
||||
{
|
||||
@@ -109,6 +113,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(1, delta);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated cap hits accumulate on the rejection counter.</summary>
|
||||
[Fact]
|
||||
public void Open_RepeatedCapHits_AccumulateOnTheRejectionCounter()
|
||||
{
|
||||
@@ -131,6 +136,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(3, delta);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session under the cap does not increment the rejection counter.</summary>
|
||||
[Fact]
|
||||
public void Open_UnderCap_DoesNotIncrementTheRejectionCounter()
|
||||
{
|
||||
@@ -145,6 +151,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(0, delta);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reclaiming expired sessions at the cap does not increment the rejection counter.</summary>
|
||||
[Fact]
|
||||
public void Open_AtCapOfExpiredSessions_ReclaimsWithoutIncrementingTheRejectionCounter()
|
||||
{
|
||||
@@ -166,6 +173,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(0, delta);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reopening an existing session id at the cap does not increment the rejection counter.</summary>
|
||||
[Fact]
|
||||
public void Open_ReopeningAnExistingSessionIdAtCap_DoesNotIncrementTheRejectionCounter()
|
||||
{
|
||||
@@ -190,6 +198,7 @@ public sealed class BundleSessionObservabilityTests
|
||||
// OpenSessionCount / gauge
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the open session count excludes sessions that expired but have not been swept.</summary>
|
||||
[Fact]
|
||||
public void OpenSessionCount_ExcludesExpiredButUnsweptSessions()
|
||||
{
|
||||
@@ -211,6 +220,8 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(1, sut.OpenSessionCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the eviction service registers the gauge provider and clears it on stop.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EvictionService_RegistersTheGaugeProvider_AndClearsItOnStop()
|
||||
{
|
||||
@@ -250,6 +261,8 @@ public sealed class BundleSessionObservabilityTests
|
||||
Assert.Equal(0, afterClear);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping an eviction service does not clobber a newer instance's gauge provider.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EvictionService_StopDoesNotClobberANewerInstancesProvider()
|
||||
{
|
||||
@@ -418,8 +431,13 @@ public sealed class BundleSessionObservabilityTests
|
||||
private sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
/// <summary>Initializes the clock at the given start time.</summary>
|
||||
/// <param name="start">The initial time.</param>
|
||||
public TestClock(DateTimeOffset start) { _now = start; }
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
/// <summary>Moves the clock forward.</summary>
|
||||
/// <param name="by">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
|
||||
@@ -434,22 +452,39 @@ public sealed class BundleSessionObservabilityTests
|
||||
{
|
||||
public static readonly InertUnlockWindow Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryRegisterAttemptAsync(
|
||||
string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PruneAsync(TimeSpan window, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Captures formatted log entries so the cap-rejection Warning can be asserted.</summary>
|
||||
private sealed class CapturingLogger : ILogger<BundleSessionStore>
|
||||
{
|
||||
/// <summary>Gets the captured log entries in emission order.</summary>
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
/// <summary>Starts no scope; the fake does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state, ignored.</param>
|
||||
/// <returns>No scope token; the fake does not track scopes.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being queried.</param>
|
||||
/// <returns>Always <see langword="true"/>.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The severity of the entry.</param>
|
||||
/// <param name="eventId">The event id of the entry.</param>
|
||||
/// <param name="state">The state object being logged.</param>
|
||||
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message text.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
|
||||
@@ -29,10 +29,11 @@ public sealed class BundleSessionStoreTests
|
||||
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
Contents: Array.Empty<ManifestContentEntry>());
|
||||
|
||||
/// <summary>Verifies that opening beyond the maximum concurrent import sessions throws, and removing one admits a new session.</summary>
|
||||
[Fact]
|
||||
public void Open_beyond_MaxConcurrentImportSessions_throws_and_removing_one_admits_a_new_session()
|
||||
{
|
||||
// #05-T24: the store bounds concurrently-open sessions (each pins a
|
||||
// The store bounds concurrently-open sessions (each pins a
|
||||
// decrypted bundle in memory). The (cap+1)th Open is rejected; freeing a
|
||||
// slot re-admits a new session.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
@@ -59,6 +60,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.NotNull(sut.Get(overflow.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session and then getting it returns the session.</summary>
|
||||
[Fact]
|
||||
public void Open_then_Get_returns_session()
|
||||
{
|
||||
@@ -73,6 +75,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Same(session, fetched);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting a session after its time-to-live returns null and evicts it.</summary>
|
||||
[Fact]
|
||||
public void Get_after_TTL_returns_null_and_evicts()
|
||||
{
|
||||
@@ -90,6 +93,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Null(sut.Get(session.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a session evicts it.</summary>
|
||||
[Fact]
|
||||
public void Remove_evicts_session()
|
||||
{
|
||||
@@ -103,6 +107,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Null(sut.Get(session.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that eviction removes every session past its time-to-live.</summary>
|
||||
[Fact]
|
||||
public void EvictExpired_removes_all_past_ttl()
|
||||
{
|
||||
@@ -125,6 +130,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Null(sut.Get(dropB.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the legacy failed-unlock-attempts field still round-trips through the shared reference.</summary>
|
||||
[Fact]
|
||||
public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference()
|
||||
{
|
||||
@@ -155,6 +161,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.True(refetch!.Locked);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the unlock failure count is tracked per bundle and resets.</summary>
|
||||
[Fact]
|
||||
public void UnlockFailureCount_TracksPerBundleAndResets()
|
||||
{
|
||||
@@ -179,6 +186,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unlock failures expire on the time-to-live and get returns zero.</summary>
|
||||
[Fact]
|
||||
public void UnlockFailures_ExpireOnTtlAndGetReturnsZero()
|
||||
{
|
||||
@@ -197,6 +205,7 @@ public sealed class BundleSessionStoreTests
|
||||
Assert.Equal(0, sut.GetUnlockFailureCount(contentHash));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that evicting expired entries clears stale unlock failure entries.</summary>
|
||||
[Fact]
|
||||
public void UnlockFailures_EvictExpired_ClearsStaleEntries()
|
||||
{
|
||||
@@ -220,8 +229,13 @@ public sealed class BundleSessionStoreTests
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
/// <summary>Initializes the provider with the given start time.</summary>
|
||||
/// <param name="start">The initial UTC time.</param>
|
||||
public TestTimeProvider(DateTimeOffset start) { _now = start; }
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
/// <summary>Advances the provider's current time.</summary>
|
||||
/// <param name="by">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
|
||||
/// <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
|
||||
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
|
||||
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
|
||||
/// IP-style strings to mirror the documented intent.
|
||||
@@ -13,11 +13,18 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
private sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
/// <summary>Initializes a new instance of the <c>TestClock</c> class.</summary>
|
||||
/// <param name="start">The start.</param>
|
||||
public TestClock(DateTimeOffset start) { _now = start; }
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
/// <summary>Advances the test clock by the given amount.</summary>
|
||||
/// <param name="delta">The delta.</param>
|
||||
public void Advance(TimeSpan delta) { _now += delta; }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering an attempt under the limit returns true.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
|
||||
{
|
||||
@@ -36,6 +43,8 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering an attempt once the limit is reached is rejected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
|
||||
{
|
||||
@@ -60,6 +69,8 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that recorded attempts expire after the window elapses.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_EntriesExpireAfterWindow()
|
||||
{
|
||||
@@ -83,6 +94,8 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a partial expiry releases only the oldest slot.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
|
||||
{
|
||||
@@ -118,6 +131,8 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that attempts are counted independently per key.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_PerKeyIsolation()
|
||||
{
|
||||
@@ -137,6 +152,9 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering an attempt with a blank key throws.</summary>
|
||||
/// <param name="key">The key.</param>
|
||||
/// <returns>The result.</returns>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -148,6 +166,9 @@ public sealed class BundleUnlockRateLimiterTests
|
||||
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering an attempt with a non-positive limit throws.</summary>
|
||||
/// <param name="limit">The limit.</param>
|
||||
/// <returns>The result.</returns>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
|
||||
public sealed class LineDifferTests
|
||||
{
|
||||
/// <summary>Verifies that identical text produces no additions or removals and is not truncated.</summary>
|
||||
[Fact]
|
||||
public void IdenticalText_NoAddsOrRemoves_NotTruncated()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public sealed class LineDifferTests
|
||||
Assert.All(result.Lines, l => Assert.Equal(LineDiffOp.Context, l.Op));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that diffing an empty old text against three new lines yields three additions numbered 1 to 3.</summary>
|
||||
[Fact]
|
||||
public void OldEmpty_NewThreeLines_ThreeAdds_NewLineNos1To3()
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public sealed class LineDifferTests
|
||||
Assert.All(adds, a => Assert.Null(a.OldLineNo));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that diffing two old lines against an empty new text yields two removals numbered 1 to 2.</summary>
|
||||
[Fact]
|
||||
public void NewEmpty_OldTwoLines_TwoRemoves_OldLineNos1To2()
|
||||
{
|
||||
@@ -50,6 +53,7 @@ public sealed class LineDifferTests
|
||||
Assert.All(removes, r => Assert.Null(r.NewLineNo));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that changing one middle line yields one removal and one addition with the surrounding context preserved.</summary>
|
||||
[Fact]
|
||||
public void SingleMiddleLineChange_OneRemoveOneAdd_ContextPreserved()
|
||||
{
|
||||
@@ -83,6 +87,7 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(3, ctxC.NewLineNo);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that differing CRLF and LF line endings are normalised and produce no changes.</summary>
|
||||
[Fact]
|
||||
public void CrlfNormalization_NoChanges()
|
||||
{
|
||||
@@ -93,6 +98,7 @@ public sealed class LineDifferTests
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that lone carriage returns are normalised and produce no changes.</summary>
|
||||
[Fact]
|
||||
public void LoneCarriageReturnNormalization_NoChanges()
|
||||
{
|
||||
@@ -103,6 +109,7 @@ public sealed class LineDifferTests
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null old text and a single new line yield one addition.</summary>
|
||||
[Fact]
|
||||
public void NullOld_NewSingleLine_OneAdd()
|
||||
{
|
||||
@@ -115,6 +122,7 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(1, add.NewLineNo);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null new text and a single old line yield one removal.</summary>
|
||||
[Fact]
|
||||
public void NullNew_OldSingleLine_OneRemove()
|
||||
{
|
||||
@@ -127,6 +135,7 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(1, remove.OldLineNo);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that two null texts produce no changes.</summary>
|
||||
[Fact]
|
||||
public void BothNull_NoChanges()
|
||||
{
|
||||
@@ -137,6 +146,7 @@ public sealed class LineDifferTests
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a thousand new lines against an empty old text are truncated to the maximum line count.</summary>
|
||||
[Fact]
|
||||
public void SizeCap_OldEmpty_ThousandNewLines_TruncatedToMax()
|
||||
{
|
||||
@@ -150,6 +160,7 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(0, result.RemovedCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an interleaved change preserves the longest-common-subsequence context.</summary>
|
||||
[Fact]
|
||||
public void InterleavedCase_LcsContextPreserved()
|
||||
{
|
||||
@@ -182,6 +193,7 @@ public sealed class LineDifferTests
|
||||
Assert.True(idxCtx1 < idxRem2 && idxRem2 < idxCtx3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trailing newline produces a trailing empty line.</summary>
|
||||
[Fact]
|
||||
public void TrailingNewline_ProducesTrailingEmptyLine()
|
||||
{
|
||||
@@ -192,6 +204,7 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(0, result.RemovedCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that oversized inputs with no common lines degrade to a summary without running the Myers algorithm.</summary>
|
||||
[Fact]
|
||||
public void OversizedInputs_ZeroCommonLines_DegradesToSummary_WithoutRunningMyers()
|
||||
{
|
||||
|
||||
+13
-5
@@ -5,7 +5,7 @@ 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
|
||||
/// Round-trip + forward-compat coverage for the site/instance-scoped bundle
|
||||
/// DTOs (<see cref="SiteDto"/>, <see cref="DataConnectionDto"/>,
|
||||
/// <see cref="InstanceDto"/> and the extended <see cref="BundleContentDto"/>).
|
||||
/// </summary>
|
||||
@@ -90,6 +90,7 @@ public sealed class BundleDtoSerializationTests
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Verifies that full content round-trips with deep equality.</summary>
|
||||
[Fact]
|
||||
public void FullContent_RoundTrips_WithDeepEquality()
|
||||
{
|
||||
@@ -136,6 +137,7 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enums serialize as strings for a human-readable wire format.</summary>
|
||||
[Fact]
|
||||
public void EnumsSerializeAsStrings_ForHumanReadableWire()
|
||||
{
|
||||
@@ -147,10 +149,11 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Contains("\"Double\"", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that legacy content missing the new arrays deserializes to empty collections rather than null.</summary>
|
||||
[Fact]
|
||||
public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull()
|
||||
{
|
||||
// A pre-M8 bundle content blob: only the original central-config arrays
|
||||
// A legacy 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 = """
|
||||
@@ -178,10 +181,11 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Empty(content.Instances);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a default-constructed content has empty, non-null new arrays.</summary>
|
||||
[Fact]
|
||||
public void DefaultConstructed_NewArrays_AreEmpty_NotNull()
|
||||
{
|
||||
// Existing positional callers that never set the M8 members still get
|
||||
// Existing positional callers that never set the site/instance members still get
|
||||
// empty (not null) collections, so consumers can enumerate unconditionally.
|
||||
var content = new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
@@ -201,7 +205,7 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Empty(content.Instances);
|
||||
}
|
||||
|
||||
// --- arch-review 2026-08-19 P7-1: ExternalSystem TimeoutSeconds wire format ---
|
||||
// --- ExternalSystem TimeoutSeconds wire format ---
|
||||
|
||||
/// <summary>
|
||||
/// Pins the old-bundle semantics: a bundle written before <c>TimeoutSeconds</c>
|
||||
@@ -249,6 +253,7 @@ public sealed class BundleDtoSerializationTests
|
||||
|
||||
// --- S10: SMS recipient PhoneNumber + SmsConfig wire format ---------------
|
||||
|
||||
/// <summary>Verifies that a legacy recipient missing a phone number deserializes to null.</summary>
|
||||
[Fact]
|
||||
public void LegacyRecipient_MissingPhoneNumber_DeserializesToNull()
|
||||
{
|
||||
@@ -283,11 +288,12 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Null(recipient.PhoneNumber);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that legacy content missing the SMS configs array deserializes to an empty collection rather than null.</summary>
|
||||
[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.
|
||||
// (never null) collection — same forward-compat invariant as the site/instance arrays.
|
||||
const string legacyJson = """
|
||||
{
|
||||
"TemplateFolders": [],
|
||||
@@ -308,6 +314,7 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Empty(content.SmsConfigs);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a default-constructed content has an empty, non-null SMS configs collection.</summary>
|
||||
[Fact]
|
||||
public void DefaultConstructed_SmsConfigs_IsEmpty_NotNull()
|
||||
{
|
||||
@@ -325,6 +332,7 @@ public sealed class BundleDtoSerializationTests
|
||||
Assert.Empty(content.SmsConfigs);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the SMS config auth token rides the secrets block rather than cleartext public fields.</summary>
|
||||
[Fact]
|
||||
public void SmsConfig_AuthToken_RidesSecretsBlock_NotCleartextPublicFields()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
|
||||
|
||||
public sealed class BundleSerializerTests
|
||||
{
|
||||
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum).
|
||||
// The documented PBKDF2 floor is 100_000 (OWASP minimum).
|
||||
// Using the floor keeps the suite fast while passing EncryptionMetadata
|
||||
// validation.
|
||||
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
|
||||
@@ -33,6 +33,7 @@ public sealed class BundleSerializerTests
|
||||
contents: Array.Empty<ManifestContentEntry>(),
|
||||
contentBytes: contentBytes);
|
||||
|
||||
/// <summary>Verifies that packing without a passphrase emits a manifest and plain JSON content.</summary>
|
||||
[Fact]
|
||||
public void Pack_emits_manifest_and_content_json_when_no_passphrase()
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public sealed class BundleSerializerTests
|
||||
Assert.Null(archive.GetEntry("content.enc"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that packing with a passphrase emits a manifest and encrypted content.</summary>
|
||||
[Fact]
|
||||
public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied()
|
||||
{
|
||||
@@ -67,6 +69,7 @@ public sealed class BundleSerializerTests
|
||||
Assert.NotNull(archive.GetEntry("content.enc"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a round trip through a temporary stream recovers identical content.</summary>
|
||||
[Fact]
|
||||
public void Roundtrip_through_temp_stream_recovers_identical_content()
|
||||
{
|
||||
@@ -91,6 +94,7 @@ public sealed class BundleSerializerTests
|
||||
Assert.Equal("return 42;", shared.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading the manifest throws when the archive has no manifest file.</summary>
|
||||
[Fact]
|
||||
public void ReadManifest_throws_when_zip_missing_manifest_json()
|
||||
{
|
||||
@@ -107,6 +111,7 @@ public sealed class BundleSerializerTests
|
||||
Assert.Throws<InvalidDataException>(() => sut.ReadManifest(ms));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unpacking throws a CryptographicException when the passphrase is wrong.</summary>
|
||||
[Fact]
|
||||
public void UnpackContent_throws_CryptographicException_on_wrong_passphrase()
|
||||
{
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class EntitySerializerTests
|
||||
SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
|
||||
ApiMethods: Array.Empty<ApiMethod>());
|
||||
|
||||
/// <summary>Verifies that converting an external system to a DTO carves its credentials into the secrets block.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_carves_external_system_credentials_into_secrets_block()
|
||||
{
|
||||
@@ -47,6 +49,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("ApiKey", dtoSys.AuthType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an SMTP config to a DTO carves its password into the secrets block.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_carves_smtp_password_into_secrets_block()
|
||||
{
|
||||
@@ -67,6 +71,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal(587, dtoSmtp.Port);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a template round trip preserves its attributes, alarms, scripts and compositions.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
|
||||
{
|
||||
@@ -99,7 +105,7 @@ public sealed class EntitySerializerTests
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"threshold\":100}",
|
||||
IsLocked = false,
|
||||
// FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is
|
||||
// Alarm fires "OnUpdate" script when triggered. The FK is
|
||||
// an in-aggregate script id; the DTO carries the name and the
|
||||
// importer resolves the FK on the way back in.
|
||||
OnTriggerScriptId = 1,
|
||||
@@ -122,7 +128,7 @@ public sealed class EntitySerializerTests
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
// FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id).
|
||||
// Verify the DTO carries OnTriggerScriptName by NAME (not id).
|
||||
// The bundle is portable across environments so a script-id FK can't
|
||||
// survive a round-trip; resolution back to a script id is the importer's
|
||||
// job once the parent template's scripts have been re-persisted.
|
||||
@@ -159,6 +165,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("MotorA", rtComp.InstanceName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a template round trip preserves the locked-in-derived flag on attributes, alarms and scripts.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script()
|
||||
{
|
||||
@@ -202,6 +210,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a template round trip preserves its native alarm sources.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_NativeAlarmSources()
|
||||
{
|
||||
@@ -244,6 +254,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.True(rtSrc.LockedInDerived);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that old-form bundle JSON without the locked-in-derived field deserializes it as false.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
||||
{
|
||||
@@ -294,6 +306,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a round trip preserves the MES-reportable flag on alarms and instance overrides.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_preserves_IsMesReportable_on_alarm_and_instance_override()
|
||||
{
|
||||
@@ -332,6 +346,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.False(Assert.Single(Assert.Single(restored.Instances).AlarmOverrides).IsMesReportableOverride);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that old-form bundle JSON without the MES-reportable field deserializes it as false on alarms and null on overrides.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void OldForm_bundle_json_without_IsMesReportable_deserializes_to_false_and_null()
|
||||
{
|
||||
@@ -394,6 +410,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(over.IsMesReportableOverride);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a template folder round trip preserves the folder hierarchy.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_template_folder_preserves_hierarchy()
|
||||
{
|
||||
@@ -419,6 +437,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an external system round trip preserves its retry configuration.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_external_system_preserves_retry_config()
|
||||
{
|
||||
@@ -441,7 +461,7 @@ public sealed class EntitySerializerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// arch-review 2026-08-19 P7-1 (row-27 shape): <c>TimeoutSeconds</c> is operative at
|
||||
/// <c>TimeoutSeconds</c> is operative at
|
||||
/// runtime (the per-system HTTP timeout) but was silently dropped by the export/import
|
||||
/// DTO — same defect class as the earlier <c>MaxRetries</c>/<c>RetryDelay</c> holes this
|
||||
/// test's sibling covers.
|
||||
@@ -494,6 +514,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal(0, sys.TimeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting a DTO with a null secrets block yields an entity with default empty secrets.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
|
||||
{
|
||||
@@ -516,6 +538,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(sys.AuthConfiguration);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list attribute round trip preserves its element data type.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_List_attribute_preserves_ElementDataType()
|
||||
{
|
||||
@@ -585,6 +609,8 @@ public sealed class EntitySerializerTests
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that import normalizes an old-form Int32 list value to native JSON.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Import_normalizes_old_form_Int32_list_value_to_native_json()
|
||||
{
|
||||
@@ -600,6 +626,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("[10,20]", attr.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that import leaves a string list value quoted.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Import_leaves_string_list_value_quoted()
|
||||
{
|
||||
@@ -614,6 +642,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("[\"a\",\"b\"]", attr.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that import leaves a malformed list value unchanged without throwing.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Import_leaves_malformed_list_value_unchanged_without_throwing()
|
||||
{
|
||||
@@ -628,6 +658,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("[\"a\"", attr.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that import leaves a scalar attribute value unchanged.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Import_leaves_scalar_attribute_value_unchanged()
|
||||
{
|
||||
@@ -667,6 +699,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("42.0", attr.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar attribute round trip keeps a null element data type as null.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
|
||||
{
|
||||
@@ -699,7 +733,7 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(rtAttr.ElementDataType);
|
||||
}
|
||||
|
||||
// --- M8 (B2): site / data-connection / instance mapping ------------------
|
||||
// --- Site / data-connection / instance mapping ------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a populated aggregate: one template (so the instance's TemplateName
|
||||
@@ -768,6 +802,8 @@ public sealed class EntitySerializerTests
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting a site, connection and instance to DTOs maps secrets and name links.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_maps_site_connection_instance_with_secrets_and_name_links()
|
||||
{
|
||||
@@ -831,6 +867,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("ns=2;s=Pump1.P", dtoBinding.DataSourceReferenceOverride);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting a connection to a DTO omits the secret key when the backup configuration is empty.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_omits_secret_key_when_backup_configuration_is_empty()
|
||||
{
|
||||
@@ -857,6 +895,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.False(dtoConn.Secrets.Values.ContainsKey("BackupConfiguration"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site, connection and instance round trip restores configuration from the secrets and binding names.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_site_connection_instance_restores_config_from_secrets_and_binding_name()
|
||||
{
|
||||
@@ -911,6 +951,8 @@ public sealed class EntitySerializerTests
|
||||
|
||||
// --- S10: SMS recipient PhoneNumber + SmsConfiguration round-trip ---------
|
||||
|
||||
/// <summary>Verifies that converting an SMS recipient to a DTO carries its phone number.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_carries_sms_recipient_phone_number()
|
||||
{
|
||||
@@ -930,6 +972,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(dtoRecipient.EmailAddress);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS recipient round trip preserves the phone number.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_sms_recipient_preserves_phone_number()
|
||||
{
|
||||
@@ -948,6 +992,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(rtRecipient.EmailAddress);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email recipient round trip preserves the email address and leaves the phone number null.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_email_recipient_preserves_email_and_leaves_phone_null()
|
||||
{
|
||||
@@ -969,6 +1015,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(rtRecipient.PhoneNumber);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a recipient round trip preserves both the email address and the phone number.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_recipient_with_both_contacts_preserves_both()
|
||||
{
|
||||
@@ -989,6 +1037,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("+15559876543", rtRecipient.PhoneNumber);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting a recipient DTO without a phone number yields a null phone number.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void FromDto_recipient_without_phone_number_yields_null_phone()
|
||||
{
|
||||
@@ -1017,6 +1067,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Null(recipient.PhoneNumber);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an SMS config to a DTO carves its auth token into the secrets block.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_carves_sms_auth_token_into_secrets_block()
|
||||
{
|
||||
@@ -1045,6 +1097,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.DoesNotContain("twilio-super-secret", dtoSms.FromNumber);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS config round trip restores the auth token from the secrets block.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_sms_config_restores_auth_token_from_secrets_block()
|
||||
{
|
||||
@@ -1075,6 +1129,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("twilio-super-secret", rtSms.AuthToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an SMS config to a DTO omits the secret key when the auth token is null.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ToDto_omits_sms_secret_key_when_auth_token_is_null()
|
||||
{
|
||||
@@ -1095,6 +1151,8 @@ public sealed class EntitySerializerTests
|
||||
|
||||
// --- EWS: Transport + the pre-existing OAuth2 authority/scope drop ---------
|
||||
|
||||
/// <summary>Verifies that an SMTP config round trip preserves the transport and OAuth2 fields.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields()
|
||||
{
|
||||
@@ -1143,6 +1201,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an SMTP config DTO without transport or OAuth2 fields yields nulls.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls()
|
||||
{
|
||||
@@ -1182,6 +1242,8 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("smtp.example.com", smtp.Host);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP config DTO omits null transport and OAuth2 fields from its JSON.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json()
|
||||
{
|
||||
|
||||
@@ -10,11 +10,12 @@ public sealed class ManifestBuilderTests
|
||||
private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0);
|
||||
private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>();
|
||||
|
||||
/// <summary>Verifies that building a manifest populates the summary from the contents.</summary>
|
||||
[Fact]
|
||||
public void Build_populates_summary_from_contents()
|
||||
{
|
||||
var sut = new ManifestBuilder();
|
||||
// M8 (B3): summary now carries trailing Sites/DataConnections/Instances counts.
|
||||
// Summary now carries trailing Sites/DataConnections/Instances counts.
|
||||
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, Sites: 3, DataConnections: 4, Instances: 5);
|
||||
var contents = new[]
|
||||
{
|
||||
@@ -26,7 +27,7 @@ public sealed class ManifestBuilderTests
|
||||
var manifest = sut.Build("env", "user", "1.0.0", encryption: null, summary, contents, contentBytes: new byte[] { 1, 2, 3 });
|
||||
|
||||
Assert.Equal(summary, manifest.Summary);
|
||||
// New M8 summary counts flow through the builder verbatim.
|
||||
// New summary counts flow through the builder verbatim.
|
||||
Assert.Equal(3, manifest.Summary.Sites);
|
||||
Assert.Equal(4, manifest.Summary.DataConnections);
|
||||
Assert.Equal(5, manifest.Summary.Instances);
|
||||
@@ -35,11 +36,12 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal("user", manifest.ExportedBy);
|
||||
Assert.Equal("1.0.0", manifest.ScadaBridgeVersion);
|
||||
Assert.Equal(1, manifest.BundleFormatVersion);
|
||||
// M8 (B3): schema minor bumped 1.0 → 1.1; format version stays 1.
|
||||
// Schema minor bumped 1.0 → 1.1; format version stays 1.
|
||||
Assert.Equal("1.1", manifest.SchemaVersion);
|
||||
Assert.Null(manifest.Encryption);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building a manifest computes a content hash with the sha256 prefix.</summary>
|
||||
[Fact]
|
||||
public void Build_computes_content_hash_with_sha256_prefix()
|
||||
{
|
||||
@@ -52,6 +54,7 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal("sha256:" + expectedHashHex, manifest.ContentHash);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation rejects an unsupported bundle format version.</summary>
|
||||
[Fact]
|
||||
public void Validate_rejects_unsupported_bundleFormatVersion()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal(ManifestValidationResult.UnsupportedFormatVersion, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation rejects a manifest whose content hash does not match.</summary>
|
||||
[Fact]
|
||||
public void Validate_rejects_when_contentHash_mismatch()
|
||||
{
|
||||
@@ -95,6 +99,7 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal(ManifestValidationResult.ContentHashMismatch, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation accepts a well-formed version 1 manifest.</summary>
|
||||
[Fact]
|
||||
public void Validate_accepts_well_formed_v1_manifest()
|
||||
{
|
||||
@@ -107,10 +112,11 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal(ManifestValidationResult.Ok, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation accepts an older schema version 1.0 manifest.</summary>
|
||||
[Fact]
|
||||
public void Validate_accepts_older_schemaVersion_1_0_manifest()
|
||||
{
|
||||
// An older "1.0" bundle (pre-M8) must still import: schema minor differences
|
||||
// An older "1.0" bundle must still import: schema minor differences
|
||||
// are additive and the validator gates only on BundleFormatVersion.
|
||||
var bytes = Encoding.UTF8.GetBytes("legacy-bundle");
|
||||
var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
@@ -131,6 +137,7 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal(ManifestValidationResult.Ok, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation refuses bundle format version 2.</summary>
|
||||
[Fact]
|
||||
public void Validate_refuses_bundleFormatVersion_2()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ public class TransportOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(TransportOptions options) =>
|
||||
new TransportOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
/// <summary>Verifies that the default options are valid.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero bundle session TTL is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroBundleSessionTtlMinutes_IsRejected()
|
||||
{
|
||||
@@ -30,6 +32,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("BundleSessionTtlMinutes", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum bundle size is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxBundleSizeMb_IsRejected()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum of concurrent import sessions is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxConcurrentImportSessions_IsRejected()
|
||||
{
|
||||
@@ -46,6 +50,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative maximum of concurrent import sessions is rejected.</summary>
|
||||
[Fact]
|
||||
public void NegativeMaxConcurrentImportSessions_IsRejected()
|
||||
{
|
||||
@@ -54,6 +59,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero PBKDF2 iterations is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroPbkdf2Iterations_IsRejected()
|
||||
{
|
||||
@@ -62,6 +68,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a PBKDF2 iteration count below the security floor is rejected.</summary>
|
||||
[Fact]
|
||||
public void Pbkdf2Iterations_BelowSecurityFloor_IsRejected()
|
||||
{
|
||||
@@ -72,6 +79,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a PBKDF2 iteration count at the security floor is valid.</summary>
|
||||
[Fact]
|
||||
public void Pbkdf2Iterations_AtSecurityFloor_IsValid()
|
||||
{
|
||||
@@ -79,6 +87,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero unlock attempts per session is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxUnlockAttemptsPerSession_IsRejected()
|
||||
{
|
||||
@@ -87,6 +96,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxUnlockAttemptsPerSession", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum decompressed bundle entry size is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryDecompressedMb_IsRejected()
|
||||
{
|
||||
@@ -95,6 +105,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxBundleEntryDecompressedMb", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum bundle entry count is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryCount_IsRejected()
|
||||
{
|
||||
@@ -103,6 +114,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxBundleEntryCount", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum bundle entry compression ratio is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryCompressionRatio_IsRejected()
|
||||
{
|
||||
@@ -111,6 +123,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxBundleEntryCompressionRatio", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero unlock attempts per IP per hour is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxUnlockAttemptsPerIpPerHour_IsRejected()
|
||||
{
|
||||
@@ -119,6 +132,7 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxUnlockAttemptsPerIpPerHour", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero major schema version is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroSchemaVersionMajor_IsRejected()
|
||||
{
|
||||
@@ -127,6 +141,8 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("SchemaVersionMajor", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a blank source environment is rejected.</summary>
|
||||
/// <param name="value">The blank source environment value.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
|
||||
Reference in New Issue
Block a user