docs: complete XML doc comments via fixdocs (2757 to 131 findings)

Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
This commit is contained in:
Joseph Doherty
2026-06-03 12:34:34 -04:00
parent c6d9b20d9f
commit bd6c0b4d3d
481 changed files with 2550 additions and 1668 deletions
@@ -21,6 +21,7 @@ public sealed class AbCipDriverPageFormSerializationTests
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <summary>Verifies that serializing and deserializing an <see cref="AbCipDriverOptions"/> round-trip preserves all known fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -71,6 +72,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.Tags[0].DataType.ShouldBe(AbCipDataType.Real);
}
/// <summary>Verifies that deserializing JSON with unknown fields drops them without error.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -86,6 +88,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
/// <summary>Verifies that a device row round-trips correctly through its definition.</summary>
[Fact]
public void DeviceRow_round_trips_through_definition()
{
@@ -101,6 +104,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.DeviceName.ShouldBe("PLC-A");
}
/// <summary>Verifies that editing one field of a device row preserves all other fields.</summary>
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
@@ -116,6 +120,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.ConnectionSize.ShouldBe(4002);
}
/// <summary>Verifies that a tag row round-trips correctly through its definition.</summary>
[Fact]
public void TagRow_round_trips_through_definition()
{
@@ -134,6 +139,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.Writable.ShouldBeTrue();
}
/// <summary>Verifies that editing one field of a tag row preserves all other fields.</summary>
[Fact]
public void TagRow_preserves_unedited_fields()
{
@@ -154,6 +160,7 @@ public sealed class AbCipDriverPageFormSerializationTests
back.Members[0].Name.ShouldBe("Sub");
}
/// <summary>Verifies that device row validation rejects a duplicate host address.</summary>
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
@@ -162,6 +169,7 @@ public sealed class AbCipDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that tag row validation rejects a duplicate tag name.</summary>
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
@@ -170,6 +178,7 @@ public sealed class AbCipDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that device and tag lists survive a full options serialize/deserialize round-trip.</summary>
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
@@ -22,6 +22,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <summary>Verifies that round-trip serialization preserves all known fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -71,6 +72,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.Tags[1].DataType.ShouldBe(AbLegacyDataType.Bit);
}
/// <summary>Verifies that deserialization silently drops unknown fields.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -86,6 +88,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
/// <summary>Verifies that a device row round-trips through its definition.</summary>
[Fact]
public void DeviceRow_round_trips_through_definition()
{
@@ -101,6 +104,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.DeviceName.ShouldBe("PLC-A");
}
/// <summary>Verifies that a device row preserves unedited fields when converting back to definition.</summary>
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
@@ -114,6 +118,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.DeviceName.ShouldBe("PLC-A");
}
/// <summary>Verifies that a tag row round-trips through its definition.</summary>
[Fact]
public void TagRow_round_trips_through_definition()
{
@@ -132,6 +137,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.Writable.ShouldBeTrue();
}
/// <summary>Verifies that a tag row preserves unedited fields when converting back to definition.</summary>
[Fact]
public void TagRow_preserves_unedited_fields()
{
@@ -146,6 +152,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.WriteIdempotent.ShouldBeTrue();
}
/// <summary>Verifies that device row validation rejects a duplicate host address.</summary>
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
@@ -154,6 +161,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that tag row validation rejects a duplicate tag name.</summary>
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
@@ -162,6 +170,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that device and tag lists survive a full options serialization round-trip.</summary>
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
@@ -14,6 +14,8 @@ public sealed class BrowseSessionReaperTests
private static BrowseSessionReaper NewReaper(BrowseSessionRegistry registry) =>
new(registry, NullLogger<BrowseSessionReaper>.Instance);
/// <summary>Verifies that ReapOnceAsync evicts a session that has been idle beyond the timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReapOnceAsync_evicts_idle_session()
{
@@ -31,6 +33,8 @@ public sealed class BrowseSessionReaperTests
session.Disposed.ShouldBeTrue();
}
/// <summary>Verifies that ReapOnceAsync preserves a recently used session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReapOnceAsync_preserves_recent_session()
{
@@ -45,6 +49,8 @@ public sealed class BrowseSessionReaperTests
session.Disposed.ShouldBeFalse();
}
/// <summary>Verifies that ReapOnceAsync handles a session already removed from the registry without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReapOnceAsync_handles_already_removed_session()
{
@@ -65,6 +71,8 @@ public sealed class BrowseSessionReaperTests
session.Disposed.ShouldBeFalse();
}
/// <summary>Verifies that ReapOnceAsync continues processing remaining sessions when one session's dispose throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReapOnceAsync_continues_when_one_session_dispose_throws()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// concurrent-registration behaviour.</summary>
public sealed class BrowseSessionRegistryTests
{
/// <summary>Verifies that a registered session can be retrieved by its token.</summary>
[Fact]
public void Register_then_TryGet_returns_session()
{
@@ -22,6 +23,7 @@ public sealed class BrowseSessionRegistryTests
got.ShouldBeSameAs((IBrowseSession)session);
}
/// <summary>Verifies that looking up an unknown token returns false.</summary>
[Fact]
public void TryGet_unknown_returns_false()
{
@@ -31,6 +33,7 @@ public sealed class BrowseSessionRegistryTests
got.ShouldBeNull();
}
/// <summary>Verifies that a removed session can no longer be retrieved.</summary>
[Fact]
public void TryRemove_then_TryGet_returns_false()
{
@@ -43,6 +46,8 @@ public sealed class BrowseSessionRegistryTests
registry.TryGet(session.Token, out _).ShouldBeFalse();
}
/// <summary>Verifies that concurrent registrations from many tasks are all visible in the snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Concurrent_Register_from_many_tasks_all_visible_in_Snapshot()
{
@@ -15,6 +15,8 @@ public sealed class BrowserSessionServiceTests
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance);
/// <summary>Verifies that OpenAsync returns Ok=false with a message when the driver type is unknown.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
{
@@ -29,6 +31,8 @@ public sealed class BrowserSessionServiceTests
result.Message!.ShouldContain("Unknown");
}
/// <summary>Verifies that OpenAsync returns a token and registers the session on the happy path.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task OpenAsync_happy_path_returns_token_and_registers()
{
@@ -50,6 +54,8 @@ public sealed class BrowserSessionServiceTests
registered.ShouldBeSameAs((IBrowseSession)session);
}
/// <summary>Verifies that OpenAsync swallows driver exceptions and returns Ok=false.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task OpenAsync_swallows_driver_throws_returns_Ok_false()
{
@@ -68,6 +74,8 @@ public sealed class BrowserSessionServiceTests
result.Message!.ShouldContain("boom");
}
/// <summary>Verifies that RootAsync throws BrowseSessionNotFoundException for an unknown token.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
{
@@ -78,6 +86,8 @@ public sealed class BrowserSessionServiceTests
() => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
}
/// <summary>Verifies that RootAsync invokes the session Root method and returns its result.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task RootAsync_invokes_session_Root()
{
@@ -96,6 +106,8 @@ public sealed class BrowserSessionServiceTests
actual.ShouldBe(expected);
}
/// <summary>Verifies that RootAsync cancels the call when the per-call timeout elapses.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task RootAsync_enforces_PerCallTimeout()
{
@@ -118,6 +130,8 @@ public sealed class BrowserSessionServiceTests
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35));
}
/// <summary>Verifies that CloseAsync removes the session from the registry and disposes it.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CloseAsync_removes_and_disposes_session()
{
@@ -11,7 +11,7 @@ internal sealed class FakeBrowseSession : IBrowseSession
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Mutable so tests can rewind the timestamp into the reaper's eviction window.</summary>
/// <inheritdoc />
public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow;
/// <summary>True once <see cref="DisposeAsync"/> has run to completion.</summary>
@@ -40,7 +40,8 @@ internal sealed class FakeBrowseSession : IBrowseSession
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
=> AttributesHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
/// <inheritdoc />
/// <summary>Disposes the fake browse session asynchronously, recording completion or throwing if configured.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync()
{
if (ThrowOnDispose) throw new InvalidOperationException("dispose-failed");
@@ -17,6 +17,7 @@ public sealed class DriverStatusSnapshotStoreTests
private static DriverHealthChanged Snap(string instance, string state = "Healthy") =>
new("MAIN", instance, state, null, null, 0, new DateTime(2026, 5, 29, 0, 0, 0, DateTimeKind.Utc));
/// <summary>Verifies that Upsert raises SnapshotChanged with the stored snapshot.</summary>
[Fact]
public void Upsert_raises_SnapshotChanged_with_the_stored_snapshot()
{
@@ -31,6 +32,7 @@ public sealed class DriverStatusSnapshotStoreTests
received[0].ShouldBeSameAs(snap);
}
/// <summary>Verifies that Upsert then TryGet returns the latest snapshot.</summary>
[Fact]
public void Upsert_then_TryGet_returns_the_latest_snapshot()
{
@@ -42,6 +44,7 @@ public sealed class DriverStatusSnapshotStoreTests
latest.State.ShouldBe("Degraded");
}
/// <summary>Verifies that an unsubscribed handler stops receiving events after removal.</summary>
[Fact]
public void Unsubscribed_handler_stops_receiving_after_removal()
{
@@ -21,6 +21,7 @@ public sealed class FocasDriverPageFormSerializationTests
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <summary>Verifies that a round-trip serialization preserves all known FOCAS driver option fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -76,6 +77,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.Tags.ShouldBeEmpty();
}
/// <summary>Verifies that deserialization silently drops unknown fields without throwing.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -89,6 +91,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(18);
}
/// <summary>Verifies that the form model round-trip preserves all editable FOCAS driver option fields.</summary>
[Fact]
public void FormModel_RoundTrip_PreservesEditableFields()
{
@@ -140,6 +143,7 @@ public sealed class FocasDriverPageFormSerializationTests
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
}
/// <summary>Verifies that a FocasDeviceRow round-trips correctly through its definition type.</summary>
[Fact]
public void DeviceRow_round_trips_through_definition()
{
@@ -155,6 +159,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.DeviceName.ShouldBe("CNC1");
}
/// <summary>Verifies that FocasDeviceRow preserves unedited fields when converting to and from a definition.</summary>
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
@@ -168,6 +173,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
}
/// <summary>Verifies that a FocasTagRow round-trips correctly through its definition type.</summary>
[Fact]
public void TagRow_round_trips_through_definition()
{
@@ -186,6 +192,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.Writable.ShouldBeTrue();
}
/// <summary>Verifies that FocasTagRow preserves unedited fields when converting to a definition.</summary>
[Fact]
public void TagRow_preserves_unedited_fields()
{
@@ -200,6 +207,7 @@ public sealed class FocasDriverPageFormSerializationTests
back.WriteIdempotent.ShouldBeTrue();
}
/// <summary>Verifies that device row validation rejects a duplicate host address.</summary>
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
@@ -208,6 +216,7 @@ public sealed class FocasDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that tag row validation rejects a duplicate tag name.</summary>
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
@@ -216,6 +225,7 @@ public sealed class FocasDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that device and tag lists survive a full options serialization round-trip.</summary>
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
@@ -25,6 +25,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
WriteIndented = false,
};
/// <summary>Verifies that a round-trip serialization preserves all known fields of GalaxyDriverOptions.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -76,6 +77,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(45);
}
/// <summary>Verifies that deserialization silently drops unknown fields from the JSON input.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -15,6 +15,7 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
WriteIndented = false,
};
/// <summary>Verifies that a round-trip serialization/deserialization preserves all known fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -42,6 +43,7 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(25);
}
/// <summary>Verifies that null timeout values fall back to the expected defaults after deserialization.</summary>
[Fact]
public void RoundTrip_NullTimeouts_UsesDefaults()
{
@@ -59,6 +61,7 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(30));
}
/// <summary>Verifies that unknown JSON properties are silently ignored during deserialization.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -82,6 +85,7 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
back.PipeName.ShouldBe("otopcua-historian");
}
/// <summary>Verifies that the form model FromRecord/ToRecord round-trip preserves all fields losslessly.</summary>
[Fact]
public void FormModel_RoundTrip_PreservesAllFields()
{
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// </summary>
public sealed class InProcessBroadcasterTests
{
/// <summary>Verifies that Publish raises the Received event for all current subscribers.</summary>
[Fact]
public void Publish_raises_Received_for_all_current_subscribers()
{
@@ -27,6 +28,7 @@ public sealed class InProcessBroadcasterTests
b.ShouldBe(["evt-1"]);
}
/// <summary>Verifies that an unsubscribed handler stops receiving events after removal.</summary>
[Fact]
public void Unsubscribed_handler_stops_receiving()
{
@@ -42,6 +44,7 @@ public sealed class InProcessBroadcasterTests
received.ShouldBe(["first"]);
}
/// <summary>Verifies that Publish with no subscribers does not throw an exception.</summary>
[Fact]
public void Publish_with_no_subscribers_does_not_throw()
{
@@ -21,6 +21,7 @@ public sealed class ModbusDriverPageFormSerializationTests
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <summary>Verifies that round-trip serialization preserves all known Modbus driver fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -97,6 +98,7 @@ public sealed class ModbusDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
/// <summary>Verifies that deserialization silently drops unknown Modbus driver fields.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -112,6 +114,7 @@ public sealed class ModbusDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
/// <summary>Verifies that a tag row round-trips through its definition.</summary>
[Fact]
public void TagRow_round_trips_through_definition()
{
@@ -129,6 +132,7 @@ public sealed class ModbusDriverPageFormSerializationTests
back.Writable.ShouldBeTrue();
}
/// <summary>Verifies that the tag list survives a full options serialization round-trip.</summary>
[Fact]
public void Tag_list_survives_options_serialize_round_trip()
{
@@ -144,6 +148,7 @@ public sealed class ModbusDriverPageFormSerializationTests
back.Tags[0].Name.ShouldBe("A");
}
/// <summary>Verifies that tag row validation rejects a duplicate tag name.</summary>
[Fact]
public void ValidateRow_rejects_duplicate_name()
{
@@ -152,6 +157,7 @@ public sealed class ModbusDriverPageFormSerializationTests
.ShouldNotBeNull();
}
/// <summary>Verifies that converting a tag row to a definition preserves unedited fields.</summary>
[Fact]
public void ToDefinition_preserves_unedited_fields()
{
@@ -17,6 +17,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
WriteIndented = false,
};
/// <summary>Verifies that a round-trip serialization preserves all known driver option fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -68,6 +69,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(20);
}
/// <summary>Verifies that deserialization silently drops unknown fields.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -83,6 +85,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(20);
}
/// <summary>Verifies that the form model round-trip preserves all driver option fields.</summary>
[Fact]
public void FormModel_RoundTrip_PreservesAllFields()
{
@@ -156,6 +159,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
result.ProbeTimeoutSeconds.ShouldBe(25);
}
/// <summary>Verifies that EndpointUrlRow.ToUrl trims leading and trailing whitespace.</summary>
[Fact]
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
{
@@ -165,6 +169,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
}
/// <summary>Verifies that ValidateRow rejects a blank URL.</summary>
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsBlank()
{
@@ -176,6 +181,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
error.ShouldBe("URL is required.");
}
/// <summary>Verifies that ValidateRow rejects a URL with a non-opc.tcp scheme.</summary>
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
{
@@ -187,6 +193,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
error.ShouldBe("Endpoint URL must start with opc.tcp://");
}
/// <summary>Verifies that ValidateRow rejects a duplicate URL.</summary>
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
{
@@ -204,6 +211,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
error.ShouldContain("Duplicate endpoint");
}
/// <summary>Verifies that editing a row in-place does not flag it as a duplicate of itself.</summary>
[Fact]
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
{
@@ -220,6 +228,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
error.ShouldBeNull();
}
/// <summary>Verifies that an endpoint URL list round-trips and preserves the original order.</summary>
[Fact]
public void EndpointUrls_ListRoundTrip_PreservesOrder()
{
@@ -6,6 +6,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class AbLegacyAddressBuilderTests
{
/// <summary>Verifies that Build_Canonical returns the expected canonical address string for each file type.</summary>
/// <param name="fileType">The file type letter (e.g., N, B, F).</param>
/// <param name="fileNumber">The file number.</param>
/// <param name="element">The element index within the file.</param>
/// <param name="expected">The expected canonical address string.</param>
[Theory]
[InlineData("N", 7, 0, "N7:0")]
[InlineData("B", 3, 1, "B3:1")]
@@ -6,6 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class FocasAddressBuilderTests
{
/// <summary>Verifies that Build produces the canonical group:parameterId address string.</summary>
/// <param name="group">The FOCAS parameter group name.</param>
/// <param name="parameterId">The FOCAS parameter ID.</param>
/// <param name="expected">The expected canonical address string.</param>
[Theory]
[InlineData("axis", 5, "axis:5")]
[InlineData("spindle", 0, "spindle:0")]
@@ -6,6 +6,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class HistorianWonderwareAddressBuilderTests
{
/// <summary>Verifies that Build produces the canonical address query string for various tag/mode/interval combinations.</summary>
/// <param name="tag">The tag name input.</param>
/// <param name="mode">The retrieval mode input.</param>
/// <param name="interval">The interval input.</param>
/// <param name="expected">The expected canonical address string.</param>
[Theory]
[InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")]
[InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")]
@@ -6,6 +6,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class ModbusAddressBuilderTests
{
/// <summary>Verifies that Build produces canonical Modbus address strings for all supported register types.</summary>
/// <param name="type">The Modbus register type.</param>
/// <param name="offset">The register offset.</param>
/// <param name="length">The data length.</param>
/// <param name="expected">The expected canonical address string.</param>
[Theory]
[InlineData("Holding", 1, 1, "4x00001-1")]
[InlineData("Coil", 0, 1, "0x00000-1")]
@@ -15,6 +20,7 @@ public sealed class ModbusAddressBuilderTests
public void Build_Canonical(string type, int offset, int length, string expected)
=> ModbusAddressBuilder.Build(type, offset, length).ShouldBe(expected);
/// <summary>Verifies that Build falls back to the Holding register type for unknown type strings.</summary>
[Fact]
public void Build_UnknownType_FallsBackToHolding()
=> ModbusAddressBuilder.Build("Unknown", 1, 1).ShouldBe("4x00001-1");
@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class S7AddressBuilderTests
{
/// <summary>Verifies that Build produces the canonical S7 address string for all supported areas and types.</summary>
/// <param name="area">The S7 memory area (DB / M / I / Q).</param>
/// <param name="dbNumber">The DB number (relevant only for the DB area).</param>
/// <param name="offset">The byte offset within the area.</param>
/// <param name="s7Type">The S7 data type qualifier (X / B / W / D / REAL).</param>
/// <param name="expected">The expected canonical address string.</param>
[Theory]
[InlineData("DB", 10, 20, "REAL", "DB10.DBD20:REAL")]
[InlineData("DB", 1, 0, "X", "DB1.DBX0.0:X")]
@@ -6,10 +6,12 @@ using ZB.MOM.WW.OtOpcUa.Core.Resilience;
public class ResilienceFormModelTests
{
/// <summary>Verifies that a blank form model serializes to null JSON.</summary>
[Fact]
public void Blank_form_serializes_to_null()
=> new ResilienceFormModel().ToJson().ShouldBeNull();
/// <summary>Verifies that a partial policy override round-trips correctly through JSON.</summary>
[Fact]
public void Partial_override_round_trips()
{
@@ -26,6 +28,7 @@ public class ResilienceFormModelTests
back.Policies["Write"].IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies that malformed JSON input yields an empty model with no-error handling.</summary>
[Fact]
public void Malformed_json_yields_empty_model()
{
@@ -34,6 +37,7 @@ public class ResilienceFormModelTests
m.Policies["Read"].IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies that JSON emitted by the form model can be parsed by the runtime resilience options parser.</summary>
[Fact]
public void Emitted_json_is_consumable_by_the_runtime_parser()
{
@@ -15,6 +15,7 @@ public sealed class S7DriverPageFormSerializationTests
WriteIndented = false,
};
/// <summary>Verifies that serializing and deserializing S7 driver options preserves all known fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -54,6 +55,7 @@ public sealed class S7DriverPageFormSerializationTests
back.Tags.ShouldBeEmpty();
}
/// <summary>Verifies that deserializing JSON with unknown fields silently drops the unrecognized members.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -67,6 +69,7 @@ public sealed class S7DriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(12);
}
/// <summary>Verifies that the S7 form model round-trip preserves all editable fields including tags.</summary>
[Fact]
public void FormModel_RoundTrip_PreservesEditableFields()
{
@@ -122,6 +125,7 @@ public sealed class S7DriverPageFormSerializationTests
roundTripped.Tags[1].Writable.ShouldBeFalse();
}
/// <summary>Verifies that an S7 tag row round-trip preserves all editable fields.</summary>
[Fact]
public void S7TagRow_RoundTrip_PreservesEditableFields()
{
@@ -137,6 +141,7 @@ public sealed class S7DriverPageFormSerializationTests
back.StringLength.ShouldBe(80);
}
/// <summary>Verifies that unedited fields are carried through after an S7 tag row edit.</summary>
[Fact]
public void S7TagRow_CarriesThroughUneditedFields()
{
@@ -154,6 +159,7 @@ public sealed class S7DriverPageFormSerializationTests
back.WriteIdempotent.ShouldBeTrue();
}
/// <summary>Verifies that S7 tag row validation rejects duplicate tag names.</summary>
[Fact]
public void S7TagRow_ValidateRow_RejectsDuplicateNames()
{
@@ -180,6 +186,7 @@ public sealed class S7DriverPageFormSerializationTests
S7DriverPage.S7TagRow.ValidateRow(ok, all, editIndex: 1).ShouldBeNull();
}
/// <summary>Verifies that the tag list serialize round-trip preserves all tag definitions.</summary>
[Fact]
public void TagList_SerializeRoundTrip_PreservesTags()
{
@@ -14,6 +14,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
WriteIndented = false,
};
/// <summary>Verifies that serializing and deserializing TwinCAT driver options preserves all known fields.</summary>
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -51,6 +52,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.Tags.ShouldBeEmpty();
}
/// <summary>Verifies that deserializing JSON with unknown fields silently drops the unrecognized members.</summary>
[Fact]
public void Deserialize_DropsUnknownFields()
{
@@ -64,6 +66,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(25);
}
/// <summary>Verifies that the form model round-trip preserves all editable fields.</summary>
[Fact]
public void FormModel_RoundTrip_PreservesEditableFields()
{
@@ -96,6 +99,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
}
/// <summary>Verifies that a device row round-trip preserves all editable fields.</summary>
[Fact]
public void DeviceRow_RoundTrip_PreservesEditableFields()
{
@@ -109,6 +113,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.DeviceName.ShouldBe("PLC1");
}
/// <summary>Verifies that unedited source fields are carried through after a device row edit.</summary>
[Fact]
public void DeviceRow_CarriesThroughUneditedSourceFields()
{
@@ -124,6 +129,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.DeviceName.ShouldBe("Renamed");
}
/// <summary>Verifies that device row validation rejects duplicate host addresses.</summary>
[Fact]
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
{
@@ -140,6 +146,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
error.ShouldContain("Duplicate");
}
/// <summary>Verifies that a tag row round-trip preserves all editable fields.</summary>
[Fact]
public void TagRow_RoundTrip_PreservesEditableFields()
{
@@ -156,6 +163,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.Writable.ShouldBeFalse();
}
/// <summary>Verifies that the unedited WriteIdempotent field is carried through after a tag row edit.</summary>
[Fact]
public void TagRow_CarriesThroughUneditedWriteIdempotent()
{
@@ -172,6 +180,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
back.WriteIdempotent.ShouldBeTrue();
}
/// <summary>Verifies that tag row validation rejects duplicate tag names (case-insensitive).</summary>
[Fact]
public void TagRow_ValidateRow_RejectsDuplicateName()
{
@@ -189,6 +198,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
error.ShouldContain("Duplicate");
}
/// <summary>Verifies that ToOptions serializes device and tag lists correctly.</summary>
[Fact]
public void FormModel_ToOptions_SerializesDeviceAndTagLists()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
public sealed class _PlaceholderTests
{
/// <summary>Verifies the project compiles and the test runner can discover test methods.</summary>
[Fact]
public void ProjectCompilesAndTestRunnerDiscoversIt() => 1.ShouldBe(1);
}
@@ -139,6 +139,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
/// synchronously, never throws, and routes the event onto the actor's own mailbox
/// (<c>Self.Tell</c>) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell
/// tests above. Reaches the concrete instance via a TestActorRef.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox()
{
@@ -197,6 +198,8 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
/// <summary>Verifies the Outcome derivation table: config verbs → Success, the two
/// authorization-rejection events → Denied.</summary>
/// <param name="action">The audit action string to derive the outcome from.</param>
/// <param name="expected">The expected audit outcome for the given action.</param>
[Theory]
[InlineData("DraftCreated", AuditOutcome.Success)]
[InlineData("DraftEdited", AuditOutcome.Success)]
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class ConfigComposerTests : ControlPlaneActorTestBase
{
/// <summary>Verifies that an empty database produces a stable, reproducible hash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Empty_database_produces_stable_hash()
{
@@ -26,6 +27,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
}
/// <summary>Verifies that insertion order does not affect the configuration hash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Same_rows_in_different_insert_orders_produce_same_hash()
{
@@ -54,6 +56,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
}
/// <summary>Verifies that different database configurations produce different hashes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Different_data_produces_different_hash()
{
@@ -76,6 +79,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
}
/// <summary>Verifies that the revision hash is a 64-character lowercase hexadecimal string.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Hash_is_64_lowercase_hex_chars()
{
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
public sealed class ClusterFormationTests
{
/// <summary>Verifies that two nodes form a 2-member cluster.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Two_nodes_form_a_2_member_cluster()
{
@@ -29,6 +30,7 @@ public sealed class ClusterFormationTests
}
/// <summary>Verifies that both nodes see each other as role members.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Both_nodes_see_each_other_as_role_members()
{
@@ -19,6 +19,7 @@ public sealed class DeployHappyPathTests
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that StartDeployment seals after both nodes apply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartDeployment_seals_after_both_nodes_apply()
{
@@ -55,6 +56,7 @@ public sealed class DeployHappyPathTests
}
/// <summary>Verifies that replaying dispatch to same revision is idempotent and a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Replaying_dispatch_to_same_revision_is_idempotent_no_op()
{
@@ -17,6 +17,7 @@ public static class DockerFixtureAvailability
/// <param name="host">The host to probe.</param>
/// <param name="port">The TCP port to connect to.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
/// <returns><see langword="true"/> if the TCP connection succeeded within the timeout; otherwise <see langword="false"/>.</returns>
public static bool IsReachable(string host, int port, int timeoutMs = 500)
{
try
@@ -43,6 +44,7 @@ public static class DockerFixtureAvailability
/// </summary>
/// <param name="endpoint">Endpoint in <c>host:port</c> format.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
/// <returns><see langword="true"/> if the TCP connection succeeded within the timeout; otherwise <see langword="false"/>.</returns>
public static bool IsReachable(string endpoint, int timeoutMs = 500)
{
try
@@ -32,6 +32,7 @@ public sealed class DriverProbeRegistrationTests
"Historian.Wonderware",
];
/// <summary>Verifies that AddOtOpcUaDriverProbes registers a probe for every AdminUI driver type.</summary>
[Fact]
public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type()
{
@@ -49,6 +50,7 @@ public sealed class DriverProbeRegistrationTests
byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'.");
}
/// <summary>Verifies that AddOtOpcUaDriverProbes is idempotent when called multiple times.</summary>
[Fact]
public void AddOtOpcUaDriverProbes_is_idempotent()
{
@@ -37,6 +37,7 @@ public sealed class DriverReconnectE2eTests
/// to a deployed driver, so no <c>DriverInstanceActor</c> will act on the DPS
/// broadcast — the test is validating the command ingestion and reply path only.</para>
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Reconnect_RoundTrip_ReturnsOk()
{
@@ -63,6 +64,7 @@ public sealed class DriverReconnectE2eTests
/// is also accepted (idempotent at the actor layer — the actor simply re-broadcasts
/// to DPS and writes another <c>ConfigEdit</c> row).
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Reconnect_IsIdempotent_SecondCallAlsoReturnsOk()
{
@@ -39,6 +39,7 @@ public sealed class DriverStatusHubE2eTests
/// to both the <see cref="IDriverStatusSnapshotStore"/> (via <c>Upsert</c>) and the
/// mock <see cref="IHubContext{DriverStatusHub}"/> (via <c>SendAsync</c>).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusHub_BridgeActor_ForwardsHealthChanged_ToStoreAndHub()
{
@@ -106,6 +107,7 @@ public sealed class DriverStatusHubE2eTests
/// for the same instance ID results in the store holding only the most recent state
/// (last-write-wins) and both hub push calls being made.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusHub_BridgeActor_LastSnapshotWins_InStore()
{
@@ -45,6 +45,7 @@ public sealed class DriverTestConnectE2eTests
/// the <see cref="TestDriverConnectResult"/> reports <c>Ok = true</c> with a
/// sub-5 s latency. Skipped when the Docker fixture host is unreachable.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TestConnect_Modbus_AgainstFixture_ReportsOk()
{
@@ -74,6 +75,7 @@ public sealed class DriverTestConnectE2eTests
/// containing a connection-refused indicator. Skipped when the host is unreachable
/// (even a refused connection requires the IP to be routable).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TestConnect_Modbus_AgainstWrongPort_ReportsFailure()
{
@@ -118,6 +120,7 @@ public sealed class DriverTestConnectE2eTests
/// <see cref="TestConnect_Modbus_AgainstFixture_ReportsOk"/> (which skips in dev).
/// This test does NOT require any Docker fixture and always runs.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TestConnect_Modbus_AgainstBlackHole_ReportsTimeout()
{
@@ -19,6 +19,7 @@ public sealed class FailoverDuringDeployTests
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that stopping node B shrinks the cluster to one up member.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Stopping_node_b_shrinks_cluster_to_one_up_member()
{
@@ -34,6 +35,7 @@ public sealed class FailoverDuringDeployTests
}
/// <summary>Verifies that a restarted node B rejoins the cluster on the same port.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Restarted_node_b_rejoins_cluster_on_same_port()
{
@@ -51,6 +53,7 @@ public sealed class FailoverDuringDeployTests
}
/// <summary>Verifies that a deployment started with node B down seals with one-node state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deployment_started_with_node_b_down_seals_with_one_node_state()
{
@@ -17,6 +17,7 @@ public sealed class FleetDiagnosticsRoundTripTests
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that get diagnostics returns a snapshot with the target node ID.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDiagnostics_returns_snapshot_with_target_NodeId()
{
@@ -41,6 +42,7 @@ public sealed class FleetDiagnosticsRoundTripTests
}
/// <summary>Verifies that get diagnostics after deploy reports the current revision.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDiagnostics_after_deploy_reports_current_revision()
{
@@ -19,6 +19,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
{
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
{
@@ -37,6 +38,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
{
@@ -54,6 +56,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
{
@@ -68,6 +71,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
}
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
@@ -82,6 +86,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
}
/// <summary>Verifies that LDAP exceptions are converted to backend error denial results.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
{
@@ -97,6 +102,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
}
/// <summary>Verifies that authentication falls back to username when LDAP omits display name.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
{
@@ -131,10 +137,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
/// <param name="handler">The handler to invoke with the username to produce a result.</param>
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
/// <summary>Authenticates a user asynchronously via the handler function.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password (ignored by the fake).</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
@@ -145,6 +148,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
/// <param name="groups">The LDAP groups to map.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that resolves to the group role mapping result.</returns>
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
}
@@ -88,6 +88,8 @@ public sealed class ProdOverlayValidationTests
return configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>() ?? new LdapOptions();
}
/// <summary>Verifies that each production overlay declares the ldaps transport.</summary>
/// <param name="fileName">The overlay file name to validate.</param>
[Theory]
[InlineData("appsettings.admin.json")]
[InlineData("appsettings.driver.json")]
@@ -100,6 +102,8 @@ public sealed class ProdOverlayValidationTests
options.Transport.ShouldBe(LdapTransport.Ldaps);
}
/// <summary>Verifies that each production overlay passes startup validation.</summary>
/// <param name="fileName">The overlay file name to validate.</param>
[Theory]
[InlineData("appsettings.admin.json")]
[InlineData("appsettings.driver.json")]
@@ -114,6 +118,7 @@ public sealed class ProdOverlayValidationTests
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>Verifies that the Development overlay passes startup validation via the DevStub exemption.</summary>
[Fact]
public void Development_overlay_passes_startup_validation_via_devstub_exemption()
{
@@ -77,6 +77,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// <summary>Boots both nodes and waits up to <paramref name="formationTimeout"/> for cluster convergence.</summary>
/// <param name="formationTimeout">Maximum time to wait for cluster formation; defaults to 20 seconds if not provided.</param>
/// <returns>A task that resolves to the started two-node cluster harness.</returns>
public static async Task<TwoNodeClusterHarness> StartAsync(TimeSpan? formationTimeout = null)
{
var harness = new TwoNodeClusterHarness();
@@ -108,6 +109,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// a couple of seconds. Use this for failover scenarios; call <see cref="RestartNodeBAsync"/>
/// to bring it back on the same Akka port.
/// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns>
public async Task StopNodeBAsync()
{
if (NodeB is null) return;
@@ -120,6 +122,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// to re-converge to 2 Up members. Use after <see cref="StopNodeBAsync"/> to test rejoin.
/// </summary>
/// <param name="formationTimeout">The maximum time to wait for cluster formation; defaults to 20 seconds.</param>
/// <returns>A task that represents the asynchronous restart operation.</returns>
public async Task RestartNodeBAsync(TimeSpan? formationTimeout = null)
{
NodeB = await BuildNodeAsync(this, NodeRole.Joiner);
@@ -136,6 +139,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// </summary>
/// <param name="expectedUpMembers">The expected number of Up members in the cluster.</param>
/// <param name="timeout">The maximum time to wait for the expected cluster size.</param>
/// <returns>A task that represents the asynchronous wait operation.</returns>
public async Task WaitForClusterSizeAsync(int expectedUpMembers, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -280,6 +284,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
}
/// <summary>Asynchronously disposes both nodes and cleans up the SQL database if used.</summary>
/// <returns>A value task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync()
{
if (NodeB is not null) await NodeB.DisposeAsync();
@@ -303,11 +308,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
private sealed class StubLdapAuthService : ILdapAuthService
{
/// <summary>Asynchronously authenticates a user with the stub LDAP service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate against.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the LDAP authentication result.</returns>
/// <inheritdoc />
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(new LdapAuthResult(
Success: password == "valid-password",
@@ -24,6 +24,7 @@ public sealed class DualEndpointTests
private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";
/// <summary>Verifies that a client can read the ServerArray containing both redundant node URIs.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
{
@@ -92,6 +92,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
}
/// <summary>Verifies NullOpcUaUserAuthenticator always returns denial result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NullOpcUaUserAuthenticator_always_denies()
{
@@ -91,6 +91,7 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
/// <summary>
/// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_populates_ServerConfiguration_with_all_enabled_profiles()
{
@@ -129,6 +130,7 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
/// <summary>
/// Verifies that StartAsync with only SignAndEncrypt omits the None endpoint.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_with_only_signandencrypt_omits_None_endpoint()
{
@@ -20,6 +20,7 @@ public sealed class OpcUaApplicationHostServerArrayTests
/// <summary>
/// Verifies that ServerArray contains local URI and configured peer URIs after start.
/// </summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
{
@@ -19,6 +19,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>Verifies StartAsync creates a self-signed certificate in the PKI own store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_creates_application_certificate_in_pki_own()
{
@@ -41,6 +42,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
}
/// <summary>Verifies StartAsync reuses an existing certificate on the second boot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_reuses_existing_certificate_on_second_boot()
{
@@ -67,6 +67,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
}
/// <summary>Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
{
@@ -135,31 +136,16 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// <summary>Gets the list of EnsureFolder calls recorded by this sink.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
/// <summary>Records a value write (stub implementation for testing).</summary>
/// <param name="nodeId">The node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Records an alarm state write (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
/// <summary>Records a folder creation request.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
/// <param name="variableNodeId">The node ID of the variable.</param>
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
/// <inheritdoc />
public void RebuildAddressSpace() { }
}
}
@@ -246,33 +246,18 @@ public sealed class Phase7ApplierTests
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
/// <summary>Records a value write (no-op in this recording sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Records an alarm state write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
/// <summary>Records a rebuild address space call.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
@@ -283,34 +268,18 @@ public sealed class Phase7ApplierTests
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>No-op variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <summary>No-op rebuild address space call.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() { }
}
}
@@ -20,6 +20,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
$"otopcua-sink-{Guid.NewGuid():N}");
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
{
@@ -36,6 +37,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
/// <summary>Verifies that WriteAlarmState creates a dedicated node distinct from value writes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
{
@@ -51,6 +53,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RebuildAddressSpace_clears_all_registered_variables()
{
@@ -73,6 +76,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
/// <summary>Verifies that NullOpcUaAddressSpaceSink does not crash on any call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
{
@@ -20,6 +20,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
{
@@ -49,6 +50,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
}
/// <summary>Verifies that publishing service level values is idempotent when called multiple times.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Publish_is_idempotent_when_called_multiple_times()
{
@@ -175,10 +175,7 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
/// <param name="supportedType">The driver type this factory supports.</param>
public CountingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>Attempts to create a driver if the type is supported.</summary>
/// <param name="driverType">The driver type to create.</param>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success()
{
@@ -48,6 +49,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that writing to a non-IWritable driver returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_against_non_IWritable_driver_returns_failure()
{
@@ -66,6 +68,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that writing to an IWritable driver returns success when status is Good.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_against_IWritable_returns_success_when_status_is_Good()
{
@@ -85,6 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that write propagates status code on Bad result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_propagates_status_code_on_Bad_result()
{
@@ -104,6 +108,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that subscribing to an ISubscribable driver forwards OnDataChange to parent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent()
{
@@ -129,6 +134,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
{
@@ -153,6 +159,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that subscribing to a non-ISubscribable driver replies with failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Subscribe_against_non_ISubscribable_replies_with_failure()
{
@@ -170,6 +177,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that DisconnectObserved detaches subscription handler so late events are dropped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped()
{
@@ -201,14 +209,12 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
/// <summary>Gets the number of times reinitialization was called.</summary>
public int ReinitializeCount;
/// <summary>Gets the driver instance ID.</summary>
/// <inheritdoc />
public string DriverInstanceId => "stub-driver-1";
/// <summary>Gets the driver type.</summary>
/// <inheritdoc />
public string DriverType => "Stub";
/// <summary>Initializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
@@ -216,24 +222,20 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReinitializeCount);
return Task.CompletedTask;
}
/// <summary>Shuts down the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -244,9 +246,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
/// <summary>Gets the list of write requests received.</summary>
public List<WriteRequest> Writes { get; } = new();
/// <summary>Writes the specified requests.</summary>
/// <param name="writes">The write requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -266,17 +266,12 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
/// <summary>Gets the number of subscribers to OnDataChange.</summary>
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
=> Task.FromResult<ISubscriptionHandle>(_handle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
/// <param name="handle">The subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
@@ -292,7 +287,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => "stub-sub";
}
}
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
public sealed class HealthProbeActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task DbHealthProbeActor_returns_reachable_against_in_memory_db()
{
@@ -92,6 +93,7 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
}
/// <summary>Verifies that the historian adapter actor returns sink status via GetStatus.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task HistorianAdapterActor_returns_sink_status_via_GetStatus()
{
@@ -115,16 +117,14 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
/// <summary>Gets the list of enqueued alarm historian events.</summary>
public ConcurrentBag<AlarmHistorianEvent> Enqueued { get; } = [];
/// <summary>Enqueues an alarm historian event.</summary>
/// <param name="evt">The event to enqueue.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <inheritdoc />
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
Enqueued.Add(evt);
return Task.CompletedTask;
}
/// <summary>Gets the current status of the historian sink.</summary>
/// <inheritdoc />
public HistorianSinkStatus GetStatus() => new(
QueueDepth: Enqueued.Count,
DeadLetterDepth: 0,
@@ -128,6 +128,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <summary>Gets count of measurements with the specified tag key-value pair.</summary>
/// <param name="key">Tag key.</param>
/// <param name="value">Tag value.</param>
/// <returns>The count of matching measurements.</returns>
public int WithTag(string key, string value)
{
lock (_gate)
@@ -177,10 +178,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
private sealed class ConstEval(object? value) : IVirtualTagEvaluator
{
/// <summary>Evaluates the virtual tag with a constant value.</summary>
/// <param name="virtualTagId">Virtual tag ID.</param>
/// <param name="expression">Expression to evaluate.</param>
/// <param name="dependencies">Dependency values.</param>
/// <inheritdoc />
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(value);
}
@@ -189,30 +187,15 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
{
/// <summary>Gets the write count.</summary>
public int Writes { get; private set; }
/// <summary>Records a value write.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
/// <summary>Records an alarm state write.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
/// <summary>Ensures folder exists (stub implementation).</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>Ensures variable exists (stub implementation).</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
/// <inheritdoc />
public void RebuildAddressSpace() { /* recorded via span */ }
}
}
@@ -160,36 +160,21 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public List<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> Alarms =>
AlarmQueue.ToList();
/// <summary>Records a value update.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The attribute value.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the update.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
ValueQueue.Enqueue((nodeId, value, quality, ts));
/// <summary>Records an alarm state update.</summary>
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="ts">The timestamp of the update.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
/// <summary>Ensures a folder exists (no-op in test).</summary>
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>Ensures a variable exists (no-op in test).</summary>
/// <param name="variableNodeId">The OPC UA variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <summary>Records a rebuild call.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
@@ -199,8 +184,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
private readonly ConcurrentQueue<byte> _q = new();
/// <summary>Gets the recorded service levels.</summary>
public byte[] Levels => _q.ToArray();
/// <summary>Records a service level publish.</summary>
/// <param name="serviceLevel">The service level value to publish.</param>
/// <inheritdoc />
public void Publish(byte serviceLevel) => _q.Enqueue(serviceLevel);
}
}
@@ -25,6 +25,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
private static CancellationToken Ct => CancellationToken.None;
/// <summary>Verifies that the primary cluster leader sets Server ServiceLevel to 240.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Primary_leader_drives_Server_ServiceLevel_to_240()
{
@@ -65,6 +66,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
}
/// <summary>Verifies that the secondary node sets Server ServiceLevel to 100.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secondary_drives_Server_ServiceLevel_to_100()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
{
/// <summary>Verifies that alarm state transitions write to the state store with the correct lastAckUser value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Transition_writes_to_state_store_with_lastAckUser()
{
@@ -39,6 +40,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}
/// <summary>Verifies that actor restart restores persisted state so pending acknowledgment is not dropped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreStart_restores_persisted_state_so_restart_does_not_drop_pending_ack()
{
@@ -64,6 +66,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}
/// <summary>Verifies that alarm boots to inactive state when no persisted state exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreStart_with_no_persisted_state_boots_inactive()
{
@@ -80,6 +83,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}
/// <summary>Verifies that EF-based alarm actor state store correctly persists and restores state through the config database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb()
{
@@ -118,6 +122,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}
/// <summary>Verifies that loading an alarm state for a missing ID returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null()
{
@@ -136,17 +141,11 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
/// <summary>Gets all saved alarm state snapshots in order.</summary>
public List<AlarmActorStateSnapshot> Snapshots => _saves.ToList();
/// <summary>Loads the alarm state snapshot for the specified alarm ID.</summary>
/// <param name="alarmId">The alarm ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The alarm state snapshot if found, null otherwise.</returns>
/// <inheritdoc />
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
=> Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null);
/// <summary>Saves the alarm state snapshot.</summary>
/// <param name="snapshot">The alarm state snapshot to save.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A completed task.</returns>
/// <inheritdoc />
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
{
_byId[snapshot.AlarmId] = snapshot;
@@ -18,6 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
public sealed class ServiceCollectionExtensionsTests
{
/// <summary>Verifies that WithOtOpcUaRuntimeActors spawns driver host and DB health probe actors.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe()
{
@@ -72,6 +73,7 @@ public sealed class ServiceCollectionExtensionsTests
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new in-memory database context.</summary>
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/> instance backed by an in-memory database.</returns>
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
@@ -84,22 +86,19 @@ public sealed class ServiceCollectionExtensionsTests
/// <summary>Fake cluster role information for testing.</summary>
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
{
/// <summary>Gets the local node ID.</summary>
/// <inheritdoc />
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
/// <summary>Gets the local roles.</summary>
/// <inheritdoc />
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
/// <summary>Determines whether the local node has the specified role.</summary>
/// <param name="role">The role to check.</param>
/// <inheritdoc />
public bool HasRole(string role) => LocalRoles.Contains(role);
/// <summary>Gets the members with the specified role.</summary>
/// <param name="role">The role to query.</param>
/// <inheritdoc />
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
/// <summary>Gets the leader node for the specified role.</summary>
/// <param name="role">The role to query.</param>
/// <inheritdoc />
public NodeId? RoleLeader(string role) => null;
/// <summary>Raised when the role leader changes.</summary>
/// <inheritdoc />
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
{
add { _ = value; }
@@ -155,6 +155,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
/// <param name="id">The identifier of the virtual tag.</param>
/// <param name="expression">The expression to evaluate.</param>
/// <param name="deps">A dictionary of dependency values keyed by reference.</param>
/// <returns>A <see cref="ZB.MOM.WW.OtOpcUa.Commons.Engines.VirtualTagEvalResult"/> containing the integer sum of all dependency values.</returns>
public ZB.MOM.WW.OtOpcUa.Commons.Engines.VirtualTagEvalResult Evaluate(
string id, string expression, IReadOnlyDictionary<string, object?> deps)
{
@@ -86,11 +86,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
/// <summary>Test evaluator that sums integer dependency values.</summary>
private sealed class SumEvaluator : IVirtualTagEvaluator
{
/// <summary>Evaluates the expression by summing integer dependencies.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>The sum of integer values in the dependencies.</returns>
/// <inheritdoc />
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
{
var sum = deps.Values.OfType<int>().Sum();
@@ -76,6 +76,7 @@ public sealed class AuditActorTests
private sealed class StubAccessor(string? value) : IAuditActorAccessor
{
/// <inheritdoc />
public string? CurrentActor { get; } = value;
}
}
@@ -110,6 +110,7 @@ public sealed class HttpAuditActorAccessorTests
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
{
/// <summary>Gets or sets the HTTP context for the stub.</summary>
public HttpContext? HttpContext { get; set; } = context;
}
}
@@ -41,6 +41,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Initializes the test host and server.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
var dbName = $"auth-int-tests-{Guid.NewGuid():N}";
@@ -106,6 +107,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Disposes the test host and server.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _host.StopAsync(TestContext.Current.CancellationToken);
@@ -122,6 +124,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
};
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
{
@@ -134,6 +137,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that login with invalid credentials returns 401.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_with_invalid_credentials_returns_401()
{
@@ -145,6 +149,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that login when LDAP throws returns 503.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_when_ldap_throws_returns_503()
{
@@ -156,6 +161,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that ping anonymous returns 401.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Ping_anonymous_returns_401()
{
@@ -166,6 +172,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that ping after cookie login returns 200.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Ping_after_cookie_login_returns_200()
{
@@ -181,6 +188,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that token after cookie login returns jwt.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Token_after_cookie_login_returns_jwt()
{
@@ -202,6 +210,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_merges_db_role_grant_into_claims()
{
@@ -238,6 +247,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// still SUCCEEDS but the user is granted ZERO role claims. They are authenticated (can prove
/// identity) yet authorized for nothing role-gated until the mapper recovers — the safe
/// fail-closed behaviour, not a fail-open with a stale role set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_when_role_mapper_throws_signs_in_with_no_role_claims()
{
@@ -307,6 +317,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted
/// (the pre-Task-1.5 strings that would indicate the migration was incomplete).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal()
{
@@ -367,6 +378,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// <see cref="JwtTokenService.RoleClaimType"/> docs for the rationale and the caveat that
/// applies if a JwtBearer scheme is ever added).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Token_payload_uses_canonical_zb_claim_keys()
{
@@ -420,6 +432,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Tests that logout clears the cookie.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Logout_clears_the_cookie()
{
@@ -439,6 +452,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Root_anonymous_browser_GET_redirects_to_login()
{
@@ -457,6 +471,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
/// heuristic). The framework still writes a <c>Location</c> header alongside the 401;
/// AJAX clients ignore it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Root_anonymous_xhr_GET_returns_401()
{
@@ -479,11 +494,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private sealed class StubLdapAuthService : ILdapAuthService
{
/// <summary>Authenticates a user asynchronously using the stub service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to verify.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The authentication result.</returns>
/// <inheritdoc />
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (username == "ldap-down")
@@ -517,10 +528,12 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
/// </summary>
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
{
/// <summary>Gets the seeded group-to-role mapping rows available for lookup.</summary>
public List<LdapGroupRoleMapping> Rows { get; } = [];
/// <summary>Gets or sets a value indicating whether the service should simulate a fault.</summary>
public bool Throws { get; set; }
/// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
@@ -531,15 +544,15 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
return Task.FromResult(matched);
}
/// <summary>Not exercised by these tests.</summary>
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
/// <inheritdoc />
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
/// <inheritdoc />
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
@@ -27,6 +27,9 @@ public sealed class CanonicalAdminRolesTests
{
// --- (a) the mapper mints the CANONICAL role claim for each native group ----------------
/// <summary>Verifies that the mapper yields the canonical role claim for each native LDAP group.</summary>
/// <param name="canonicalRole">The canonical role name to test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("Viewer")] // was ConfigViewer
[InlineData("Designer")] // was ConfigEditor
@@ -42,6 +45,10 @@ public sealed class CanonicalAdminRolesTests
result.Roles.ShouldContain(canonicalRole);
}
/// <summary>Verifies that a system-wide DB row role renders as the canonical role string.</summary>
/// <param name="role">The admin role enum value to test.</param>
/// <param name="expected">The expected canonical role string.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(AdminRole.Viewer, "Viewer")]
[InlineData(AdminRole.Designer, "Designer")]
@@ -61,6 +68,8 @@ public sealed class CanonicalAdminRolesTests
// --- (b)/(c) the REAL registered authorization policies enforce on the canonical values ---
/// <summary>Verifies that the Deployments role check authorizes Designer and Administrator roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deployments_role_check_authorizes_Designer_and_Administrator()
{
@@ -76,6 +85,8 @@ public sealed class CanonicalAdminRolesTests
(await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse();
}
/// <summary>Verifies that the FleetAdmin policy authorizes only the Administrator role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FleetAdmin_policy_authorizes_only_Administrator()
{
@@ -88,6 +99,8 @@ public sealed class CanonicalAdminRolesTests
(await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
}
/// <summary>Verifies that the DriverOperator policy authorizes Operator and Administrator roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DriverOperator_policy_authorizes_Operator_and_Administrator()
{
@@ -139,16 +152,31 @@ public sealed class CanonicalAdminRolesTests
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
{
/// <summary>Returns all seeded rows that belong to any of the specified LDAP groups.</summary>
/// <param name="ldapGroups">The LDAP groups to look up.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to the matching role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Returns all seeded role mapping rows.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to all role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Not supported in this stub.</summary>
/// <param name="row">The row to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
=> throw new NotSupportedException();
/// <summary>Not supported in this stub.</summary>
/// <param name="id">The identifier of the row to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
@@ -28,6 +28,8 @@ public sealed class OtOpcUaGroupRoleMapperTests
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
}
/// <summary>Verifies that the mapper maps a configured group and drops unmapped groups.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Maps_config_group_and_drops_unmapped_group()
{
@@ -39,6 +41,8 @@ public sealed class OtOpcUaGroupRoleMapperTests
result.Scope.ShouldBeNull();
}
/// <summary>Verifies that a system-wide DB row adds a role on top of the config baseline.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task System_wide_db_row_adds_role_on_top_of_config_baseline()
{
@@ -53,6 +57,8 @@ public sealed class OtOpcUaGroupRoleMapperTests
result.Scope.ShouldBeNull();
}
/// <summary>Verifies that a cluster-scoped DB row is ignored by the mapper.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Cluster_scoped_db_row_is_ignored()
{
@@ -72,6 +78,8 @@ public sealed class OtOpcUaGroupRoleMapperTests
result.Roles.ShouldBeEmpty();
}
/// <summary>Verifies that the mapper output matches the expected RoleMapper.Map + Merge result for representative inputs.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs()
{
@@ -102,16 +110,31 @@ public sealed class OtOpcUaGroupRoleMapperTests
/// <summary>In-memory stand-in for the EF-backed DB service; returns the configured rows verbatim.</summary>
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
{
/// <summary>Returns all seeded rows that belong to any of the specified LDAP groups.</summary>
/// <param name="ldapGroups">The LDAP groups to look up.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to the matching role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Returns all seeded role mapping rows.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to all role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Not supported in this stub.</summary>
/// <param name="row">The row to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
=> throw new NotSupportedException();
/// <summary>Not supported in this stub.</summary>
/// <param name="id">The identifier of the row to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
@@ -22,6 +22,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
/// <summary>DevStubMode on → stub Administrator success WITHOUT hitting the library.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DevStubMode_grants_Administrator_without_calling_the_library()
{
@@ -38,6 +39,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
}
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
{
@@ -53,6 +55,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
/// downstream mapper (the library returns groups, not roles).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
{
@@ -73,6 +76,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
}
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Real_path_failure_folds_into_error()
{
@@ -90,6 +94,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
{
@@ -107,6 +112,9 @@ public sealed class OtOpcUaLdapAuthServiceTests
}
/// <summary>Empty username/password are rejected up front without a library call.</summary>
/// <param name="user">The username to test.</param>
/// <param name="pw">The password to test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("", "pw")]
[InlineData("user", "")]
@@ -124,8 +132,14 @@ public sealed class OtOpcUaLdapAuthServiceTests
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
{
/// <summary>Gets a value indicating whether the library service was called.</summary>
public bool Called { get; private set; }
/// <summary>Authenticates the user, records that the call was made, and returns the canned result.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the canned <see cref="LibLdapAuthResult"/>.</returns>
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
Called = true;
@@ -62,6 +62,7 @@ public sealed class RoleMapperTests
roles.ShouldBe(new[] { "Administrator" });
}
/// <summary>Verifies that Merge unions baseline roles with system-wide DB roles.</summary>
[Fact]
public void Merge_unions_baseline_and_systemwide_db_roles()
{
@@ -76,6 +77,7 @@ public sealed class RoleMapperTests
result.ShouldNotContain("Designer"); // cluster-scoped row ignored (global-only)
}
/// <summary>Verifies that Merge with no DB rows returns the baseline roles unchanged.</summary>
[Fact]
public void Merge_with_no_db_rows_returns_baseline()
=> RoleMapper.Merge(["Administrator"], []).ShouldBe(["Administrator"]);