docs(src): add missing XML docs and strip tracking-ID comments
Sweep of 203 source files resolving CommentChecker findings: add <summary>/<param>/<returns>/<inheritdoc> where missing, and remove resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN, Task N) from code comments. Comment/doc-only — no logic changes. Server+Tests build clean under TreatWarningsAsErrors.
This commit is contained in:
@@ -32,6 +32,8 @@ public sealed class AlarmFailoverEndToEndTests
|
||||
{
|
||||
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Verifies the alarm feed reflects each stage of a full alarmmgr-to-subtag failover and failback lifecycle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ProviderFailoverAndFailback_FullLifecycle_ReflectedInFeed()
|
||||
{
|
||||
@@ -227,6 +229,8 @@ public sealed class AlarmFailoverEndToEndTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a degraded transition cached before a new subscriber connects is replayed with its degraded flag and source provider intact.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DegradedTransition_CachedThenReplayed_CarriesDegradedAndSourceProviderToNewSubscriber()
|
||||
{
|
||||
@@ -400,10 +404,13 @@ public sealed class AlarmFailoverEndToEndTests
|
||||
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
|
||||
|
||||
/// <summary>Pushes a worker event onto the monitor's event stream.</summary>
|
||||
/// <param name="mxEvent">The worker event to push.</param>
|
||||
public void EmitEvent(MxEvent mxEvent) =>
|
||||
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
|
||||
|
||||
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task that completes once the SubscribeAlarms command has been issued.</returns>
|
||||
public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -39,6 +39,12 @@ public sealed class AlarmWatchListResolverTests
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Galaxy Repository rows and config includes are unioned, configured
|
||||
/// excludes are removed, and duplicate references (case-insensitive) collapse
|
||||
/// to a single entry.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates()
|
||||
{
|
||||
@@ -63,6 +69,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies subtag item addresses are composed using the configured subtag names.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames()
|
||||
{
|
||||
@@ -89,6 +97,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that configuring empty Priority and AckComment subtag names leaves those target fields empty.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty()
|
||||
{
|
||||
@@ -110,6 +120,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal(string.Empty, target.AckCommentSubtag);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the canonical full reference uses the row's discovered Galaxy area, not the configured Discovery.Area.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea()
|
||||
{
|
||||
@@ -135,6 +147,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the canonical full reference omits the area segment when neither a discovered nor a configured area is present.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea()
|
||||
{
|
||||
@@ -153,6 +167,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a config include entry with no discovered area falls back to the configured Discovery.Area.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback()
|
||||
{
|
||||
@@ -170,6 +186,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a config include entry falls back to DefaultArea when Discovery.Area is empty.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty()
|
||||
{
|
||||
@@ -187,6 +205,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disabling Galaxy Repository discovery skips the repository call and resolves the watch list from configured includes only.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly()
|
||||
{
|
||||
@@ -206,6 +226,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository exception during discovery is logged and swallowed, returning the config-only watch list instead of throwing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
|
||||
{
|
||||
@@ -221,6 +243,8 @@ public sealed class AlarmWatchListResolverTests
|
||||
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
|
||||
{
|
||||
@@ -240,6 +264,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false.
|
||||
/// A config-only include must survive even when the same path appears in ExcludeAttributes.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled()
|
||||
{
|
||||
@@ -262,6 +287,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
|
||||
/// UseGalaxyRepository is true.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled()
|
||||
{
|
||||
@@ -286,6 +312,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
|
||||
/// not accidentally exclude any real reference.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped()
|
||||
{
|
||||
@@ -307,11 +334,12 @@ public sealed class AlarmWatchListResolverTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-051: a cancellation triggered while Galaxy Repository discovery is
|
||||
/// A cancellation triggered while Galaxy Repository discovery is
|
||||
/// awaiting must propagate as <see cref="OperationCanceledException"/>, not be
|
||||
/// swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/>
|
||||
/// contract.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled()
|
||||
{
|
||||
@@ -329,11 +357,12 @@ public sealed class AlarmWatchListResolverTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-052 item 2 / Server-053: an entry that appears in both
|
||||
/// An entry that appears in both
|
||||
/// <c>IncludeAttributes</c> and <c>ExcludeAttributes</c> is removed — excludes
|
||||
/// win over explicit includes (the documented "excludes also suppress matching
|
||||
/// explicit includes" behaviour).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude()
|
||||
{
|
||||
@@ -362,22 +391,32 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
|
||||
public int GetAlarmAttributesCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always reports a successful connection.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="true"/>.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Reports no known deploy time.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="null"/>.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<DateTime?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyHierarchyRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyAttributeRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Returns the fixed alarm rowset supplied at construction, tracking how many times it was called.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying the configured alarm attribute rows.</returns>
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetAlarmAttributesCount++;
|
||||
@@ -388,22 +427,32 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
|
||||
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always reports a successful connection; only alarm-attribute discovery fails for this fake.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="true"/>.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Reports no known deploy time.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="null"/>.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<DateTime?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyHierarchyRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyAttributeRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Simulates the live SQL path failing by faulting with the configured exception.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A faulted task carrying the configured exception.</returns>
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
|
||||
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
|
||||
}
|
||||
@@ -415,22 +464,32 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// </summary>
|
||||
private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always reports a successful connection; only alarm-attribute discovery cancels for this fake.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="true"/>.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Reports no known deploy time.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying <see langword="null"/>.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<DateTime?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyHierarchyRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task carrying an empty list.</returns>
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new List<GalaxyAttributeRow>());
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Cancels the supplied source and throws, mirroring the live SQL path being cancelled mid-await.</summary>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>Never returns normally; always throws <see cref="OperationCanceledException"/>.</returns>
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
source.Cancel();
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
{
|
||||
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>A worker-reported provider-mode change to subtag broadcasts a degraded status message and increments the provider-switch metric.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ProviderModeChange_BroadcastsDegradedStatus_AndIncrementsSwitchMetric()
|
||||
{
|
||||
@@ -121,12 +123,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-053: a redundant <c>OnAlarmProviderModeChanged</c> event whose target
|
||||
/// A redundant <c>OnAlarmProviderModeChanged</c> event whose target
|
||||
/// mode equals the current mode still records a provider switch. The worker is the
|
||||
/// authority on when a mode change occurred; the gateway does not second-guess it,
|
||||
/// so each event the worker emits increments <c>provider_switches</c> (no from==to
|
||||
/// suppression). This test pins that semantics so it cannot drift silently.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ProviderModeChange_RepeatedSameMode_RecordsASwitchForEachEvent()
|
||||
{
|
||||
@@ -219,7 +222,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-032: pins the monitor's <c>toMode → AlarmProviderSwitchReason</c>
|
||||
/// Pins the monitor's <c>toMode → AlarmProviderSwitchReason</c>
|
||||
/// derivation (<c>GatewayAlarmMonitor.ApplyProviderModeChangeAsync</c>): an
|
||||
/// alarmmgr→subtag change must emit <c>reason=failover</c> and a subtag→alarmmgr
|
||||
/// change must emit <c>reason=failback</c>. Captures the <c>reason</c> tag off the
|
||||
@@ -227,6 +230,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
/// the Failover/Failback arms or collapsed them to Unknown would be caught here,
|
||||
/// whereas the count-only tests above would still pass.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ProviderModeChange_FailoverThenFailback_RecordsCorrectReasonTags()
|
||||
{
|
||||
@@ -338,6 +342,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A subscriber that joins the alarm feed receives the current provider status as its first message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NewSubscriber_ReceivesProviderStatusAsFirstMessage()
|
||||
{
|
||||
@@ -374,6 +380,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>With the fallback mode forced to subtag, the initial provider status baselines to degraded subtag without recording a provider switch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForceSubtagConfig_BaselinesProviderStatusToSubtagDegraded_WithoutSwitch()
|
||||
{
|
||||
@@ -448,6 +456,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>With the fallback mode forced to alarm manager, the initial provider status baselines to non-degraded alarmmgr without recording a provider switch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForceAlarmManagerConfig_BaselinesProviderStatusToAlarmmgr_WithoutSwitch()
|
||||
{
|
||||
@@ -519,6 +529,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>The monitor's SubscribeAlarms command carries the forced provider mode, failover settings, and watch list resolved from configuration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeAlarms_SendsForcedModeAndWatchList_FromConfiguration()
|
||||
{
|
||||
@@ -565,6 +577,10 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>The configured fallback mode string maps to the expected <see cref="AlarmProviderMode"/> sent in the SubscribeAlarms command.</summary>
|
||||
/// <param name="mode">The configured fallback mode string.</param>
|
||||
/// <param name="expected">The expected forced <see cref="AlarmProviderMode"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("ForceAlarmManager", AlarmProviderMode.Alarmmgr)]
|
||||
[InlineData("forcealarmmanager", AlarmProviderMode.Alarmmgr)]
|
||||
@@ -701,10 +717,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
|
||||
|
||||
/// <summary>Pushes a worker event onto the monitor's event stream.</summary>
|
||||
/// <param name="mxEvent">The worker event to push.</param>
|
||||
public void EmitEvent(MxEvent mxEvent) =>
|
||||
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
|
||||
|
||||
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -123,12 +123,14 @@ public sealed class GatewayOptionsTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <see cref="DashboardOptions.DisableLogin"/> defaults to <see langword="false"/>.</summary>
|
||||
[Fact]
|
||||
public void DashboardOptions_DisableLogin_DefaultsToFalse()
|
||||
{
|
||||
Assert.False(new DashboardOptions().DisableLogin);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <see cref="DashboardOptions.AutoLoginUser"/> defaults to <see langword="null"/>.</summary>
|
||||
[Fact]
|
||||
public void DashboardOptions_AutoLoginUser_DefaultsToNull()
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies default TLS options pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultTlsOptions()
|
||||
{
|
||||
@@ -53,6 +54,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero <see cref="TlsOptions.ValidityYears"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
|
||||
{
|
||||
@@ -62,6 +64,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="TlsOptions.ValidityYears"/> above the allowed maximum fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenTlsValidityYearsTooLarge()
|
||||
{
|
||||
@@ -71,6 +74,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank entry in <see cref="TlsOptions.AdditionalDnsNames"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenAdditionalDnsNameBlank()
|
||||
{
|
||||
@@ -80,6 +84,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSelfSignedCertPathBlank()
|
||||
{
|
||||
@@ -89,6 +94,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
||||
}
|
||||
|
||||
/// <summary>Verifies an LDAP port of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLdapPortIsZero()
|
||||
{
|
||||
@@ -100,6 +106,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 0)"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies an LDAP port above the allowed maximum fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLdapPortExceedsMaximum()
|
||||
{
|
||||
@@ -111,6 +118,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 70000)"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies enabled LDAP with a valid port passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
|
||||
{
|
||||
@@ -144,6 +152,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
|
||||
{
|
||||
@@ -159,6 +168,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the default "Auto" fallback mode passes validation when alarms are enabled.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
|
||||
{
|
||||
@@ -170,6 +180,8 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies each recognised (case-insensitive) fallback mode passes validation.</summary>
|
||||
/// <param name="mode">Fallback mode value under test.</param>
|
||||
[Theory]
|
||||
[InlineData("Auto")]
|
||||
[InlineData("ForceAlarmManager")]
|
||||
@@ -184,6 +196,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an unrecognised fallback mode fails validation when alarms are enabled.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
|
||||
{
|
||||
@@ -195,6 +208,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies "ForceSubtag" fails validation without a Galaxy repository and without include attributes.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
|
||||
{
|
||||
@@ -217,6 +231,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies "ForceSubtag" passes validation without a Galaxy repository when include attributes are supplied.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
|
||||
{
|
||||
@@ -236,6 +251,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies "ForceSubtag" passes validation when a Galaxy repository is used, even without include attributes.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
|
||||
{
|
||||
@@ -251,6 +267,9 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.ConsecutiveFailureThreshold"/> fails validation.</summary>
|
||||
/// <param name="value">Threshold value under test.</param>
|
||||
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||
[InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||
@@ -264,6 +283,9 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackProbeIntervalSeconds"/> fails validation.</summary>
|
||||
/// <param name="value">Interval value under test.</param>
|
||||
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||
[InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||
@@ -277,6 +299,9 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackStableProbes"/> fails validation.</summary>
|
||||
/// <param name="value">Probe count value under test.</param>
|
||||
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||
[InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||
@@ -308,6 +333,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies <see cref="SessionOptions.AllowMultipleEventSubscribers"/> set to <see langword="true"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue()
|
||||
{
|
||||
@@ -319,6 +345,8 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> fails validation.</summary>
|
||||
/// <param name="value">Subscriber cap value under test.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
@@ -334,6 +362,8 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Sessions:MaxEventSubscribersPerSession"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> passes validation.</summary>
|
||||
/// <param name="value">Subscriber cap value under test.</param>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(8)]
|
||||
@@ -347,6 +377,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies default <see cref="SessionOptions"/> pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultSessionOptions()
|
||||
{
|
||||
@@ -361,6 +392,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
// WorkerReadyWaitTimeoutMs validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies a negative <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative()
|
||||
{
|
||||
@@ -374,6 +406,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero()
|
||||
{
|
||||
@@ -384,6 +417,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a positive <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive()
|
||||
{
|
||||
@@ -394,6 +428,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a negative <see cref="SessionOptions.DetachGraceSeconds"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenDetachGraceSecondsIsNegative()
|
||||
{
|
||||
@@ -407,6 +442,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Sessions:DetachGraceSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero <see cref="SessionOptions.DetachGraceSeconds"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenDetachGraceSecondsIsZero()
|
||||
{
|
||||
@@ -435,6 +471,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies a negative <see cref="EventOptions.ReplayBufferCapacity"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenReplayBufferCapacityIsNegative()
|
||||
{
|
||||
@@ -448,6 +485,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Events:ReplayBufferCapacity"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero <see cref="EventOptions.ReplayBufferCapacity"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenReplayBufferCapacityIsZero()
|
||||
{
|
||||
@@ -458,6 +496,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a negative <see cref="EventOptions.ReplayRetentionSeconds"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenReplayRetentionSecondsIsNegative()
|
||||
{
|
||||
@@ -471,6 +510,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Events:ReplayRetentionSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero <see cref="EventOptions.ReplayRetentionSeconds"/> passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
public sealed class TlsOptionsBindingTests
|
||||
{
|
||||
/// <summary>Verifies that <see cref="TlsOptions"/> applies its documented defaults when no configuration section is bound.</summary>
|
||||
[Fact]
|
||||
public void Defaults_AreApplied_WhenSectionAbsent()
|
||||
{
|
||||
@@ -16,6 +17,7 @@ public sealed class TlsOptionsBindingTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <see cref="TlsOptions"/> values bind correctly from the <c>MxGateway:Tls</c> configuration section.</summary>
|
||||
[Fact]
|
||||
public void Binds_FromMxGatewayTlsSection()
|
||||
{
|
||||
|
||||
@@ -347,8 +347,8 @@ public sealed class ProtobufContractRoundTripTests
|
||||
/// by-name-specific payload case and instead reuses the
|
||||
/// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>)
|
||||
/// case. A future change that adds a separate by-name reply case — or
|
||||
/// drops the reuse — breaks this test. See Contracts-002 and
|
||||
/// docs/AlarmClientDiscovery.md section 4.
|
||||
/// drops the reuse — breaks this test. See docs/AlarmClientDiscovery.md
|
||||
/// section 4.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase()
|
||||
@@ -1015,8 +1015,6 @@ public sealed class ProtobufContractRoundTripTests
|
||||
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
|
||||
/// pinning the credential-bearing entry shape
|
||||
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
|
||||
/// See Contracts-011 for the credential-sensitivity comment on
|
||||
/// <c>WriteSecuredBulkEntry.value</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
|
||||
@@ -1597,7 +1595,7 @@ public sealed class ProtobufContractRoundTripTests
|
||||
/// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/>
|
||||
/// so a future renumber or type-narrowing is caught at the contract level.
|
||||
/// Also verifies that an all-defaults <see cref="MxSparseArray"/> (no elements)
|
||||
/// is not a proto-level error. See Contracts-023.
|
||||
/// is not a proto-level error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxValue_RoundTripsSparseArrayValueAndPinsFieldNumbers()
|
||||
|
||||
@@ -142,10 +142,14 @@ public sealed class DashboardBrowseServiceTests
|
||||
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the stub never refreshes.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the stub is always considered loaded.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class AuthStoreHealthCheckTests
|
||||
return new AuthSqliteConnectionFactory(sqlitePath);
|
||||
}
|
||||
|
||||
/// <summary>The health check reports healthy when the auth store database is reachable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Healthy_WhenStoreReachable()
|
||||
{
|
||||
@@ -25,6 +27,8 @@ public sealed class AuthStoreHealthCheckTests
|
||||
finally { if (File.Exists(path)) File.Delete(path); }
|
||||
}
|
||||
|
||||
/// <summary>The health check reports unhealthy when the database path cannot be opened.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Unhealthy_WhenPathUnusable()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
|
||||
|
||||
public sealed class GatewayLogRedactorSeamTests
|
||||
{
|
||||
/// <summary>Redacting a log property containing a bearer API key masks the secret while preserving the key id prefix.</summary>
|
||||
[Fact]
|
||||
public void Redact_MasksApiKeyInClientIdentity()
|
||||
{
|
||||
|
||||
@@ -9,16 +9,6 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||
/// <summary>
|
||||
/// Adversarial-input coverage for the Galaxy Repository browse filter layer.
|
||||
/// <para>
|
||||
/// Re-triage note (finding Tests-002): the Galaxy Repository's SQL surface
|
||||
/// (<c>HierarchySql</c>, <c>AttributesSql</c>, <c>SELECT 1</c>,
|
||||
/// <c>SELECT time_of_last_deploy FROM galaxy</c>) is entirely constant — no
|
||||
/// <see cref="DiscoverHierarchyRequest"/> field is ever concatenated into a SQL
|
||||
/// string. All filters (<c>TagNameGlob</c>, <c>RootTagName</c>, category ids,
|
||||
/// template-chain filters, contained-path roots) are applied in memory by
|
||||
/// <see cref="GalaxyHierarchyProjector"/> against the cached snapshot, so there is
|
||||
/// no SQL-injection surface and no <c>LIKE</c>-escaping helper to test.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The genuine, testable concern is that adversarial filter strings — SQL
|
||||
/// metacharacters (<c>'</c>, <c>;</c>) and <c>LIKE</c>-wildcards (<c>%</c>,
|
||||
/// <c>_</c>) — are treated as opaque literals by the in-memory filter layer:
|
||||
@@ -41,6 +31,7 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
];
|
||||
|
||||
/// <summary>Returns adversarial input cases for theory tests.</summary>
|
||||
/// <returns>Theory data of adversarial input strings.</returns>
|
||||
public static TheoryData<string> AdversarialInputCases()
|
||||
{
|
||||
TheoryData<string> data = [];
|
||||
@@ -88,7 +79,7 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for finding Server-008: <see cref="GalaxyGlobMatcher"/> caches
|
||||
/// <see cref="GalaxyGlobMatcher"/> caches
|
||||
/// the compiled regex per glob pattern. Repeated calls with the same pattern, and
|
||||
/// interleaved calls with different patterns, must keep returning the correct
|
||||
/// literal-vs-wildcard result rather than a stale cached match.
|
||||
@@ -229,6 +220,7 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
/// zero matches rather than returning the whole hierarchy or faulting.
|
||||
/// </summary>
|
||||
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
|
||||
@@ -249,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
/// a query fragment or matching unrelated objects.
|
||||
/// </summary>
|
||||
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
|
||||
@@ -327,13 +320,17 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Gets the fixed cache entry returned to callers.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the stub never refreshes.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the stub is always considered loaded.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -29,6 +29,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
|
||||
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
|
||||
{
|
||||
@@ -45,6 +46,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
|
||||
{
|
||||
@@ -79,6 +81,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
|
||||
{
|
||||
@@ -96,6 +99,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
||||
{
|
||||
@@ -121,6 +125,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
|
||||
{
|
||||
@@ -153,6 +158,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
|
||||
{
|
||||
@@ -181,6 +187,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
|
||||
/// written — audit completeness for refused deletes.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
|
||||
{
|
||||
@@ -206,6 +213,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
|
||||
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
|
||||
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -225,10 +233,11 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-004 regression: the dashboard create path must reject a request carrying a
|
||||
/// The dashboard create path must reject a request carrying a
|
||||
/// non-canonical scope string rather than persisting a key whose scope the authorization
|
||||
/// resolver never matches.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_UnknownScope_DoesNotCreate()
|
||||
{
|
||||
@@ -255,6 +264,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
|
||||
/// the operator username as Actor and the managed keyId as Target.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
|
||||
{
|
||||
@@ -386,7 +396,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records the audit event in memory instead of writing it to a real sink.</summary>
|
||||
/// <param name="auditEvent">The audit event to record.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(auditEvent);
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardAuthenticatorTests
|
||||
{
|
||||
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
|
||||
/// <param name="username">The blank/whitespace/null username under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -40,6 +42,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
}
|
||||
|
||||
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
|
||||
{
|
||||
@@ -60,6 +63,8 @@ public sealed class DashboardAuthenticatorTests
|
||||
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
|
||||
/// maps to the generic dashboard failure without leaking the raw password.
|
||||
/// </summary>
|
||||
/// <param name="failure">The LDAP failure bucket under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(LdapAuthFailure.Disabled)]
|
||||
[InlineData(LdapAuthFailure.BadCredentials)]
|
||||
@@ -87,6 +92,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
|
||||
/// dashboard authentication scheme.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
|
||||
{
|
||||
@@ -127,11 +133,12 @@ public sealed class DashboardAuthenticatorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with
|
||||
/// The principal emits canonical ZbClaimTypes.Username ("zb:username") with
|
||||
/// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
|
||||
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
|
||||
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
|
||||
{
|
||||
@@ -183,6 +190,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
/// When the user authenticates but none of their groups map to a dashboard role,
|
||||
/// the login is denied (the long-standing "no roles matched → denied" rule).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
|
||||
{
|
||||
@@ -216,6 +224,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
/// for the realistic (already-short) group shape.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
|
||||
{
|
||||
@@ -243,6 +252,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
}
|
||||
|
||||
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
|
||||
{
|
||||
@@ -296,10 +306,17 @@ public sealed class DashboardAuthenticatorTests
|
||||
/// </summary>
|
||||
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
|
||||
{
|
||||
/// <summary>Gets a value indicating whether <see cref="AuthenticateAsync"/> was invoked.</summary>
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets the username passed to the most recent <see cref="AuthenticateAsync"/> call.</summary>
|
||||
public string? LastUsername { get; private set; }
|
||||
|
||||
/// <summary>Records the call and returns the configured fixed result.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password to authenticate.</param>
|
||||
/// <param name="ct">Token to observe for cancellation.</param>
|
||||
/// <returns>The fixed <see cref="LdapAuthResult"/> configured for this fake.</returns>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
WasCalled = true;
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardAuthorizationHandlerTests
|
||||
{
|
||||
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
/// Verifies that the anonymous-localhost bypass is denied when <c>AllowAnonymousLocalhost</c>
|
||||
/// is off, even on a loopback connection — the misconfiguration must not expose the dashboard.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed()
|
||||
{
|
||||
@@ -56,6 +59,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
/// Verifies that the anonymous-localhost bypass stays scoped to loopback: an anonymous
|
||||
/// request from a non-loopback address is denied even when <c>AllowAnonymousLocalhost</c> is on.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed()
|
||||
{
|
||||
@@ -69,6 +73,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an authenticated user without any dashboard role fails the viewer requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
|
||||
{
|
||||
@@ -82,6 +87,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
|
||||
{
|
||||
@@ -95,6 +101,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy()
|
||||
{
|
||||
@@ -108,6 +115,7 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
|
||||
{
|
||||
|
||||
+5
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardAutoLoginAuthenticationHandlerTests
|
||||
{
|
||||
/// <summary>The default auto-login user mints an authenticated principal with both dashboard roles.</summary>
|
||||
[Fact]
|
||||
public void CreatePrincipal_MintsAuthenticatedMultiRoleUser()
|
||||
{
|
||||
@@ -16,6 +17,8 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
||||
}
|
||||
|
||||
/// <summary>A null, empty, or whitespace-only user falls back to the configured default user.</summary>
|
||||
/// <param name="user">The blank/whitespace/null user under test.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -27,6 +30,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
|
||||
Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name);
|
||||
}
|
||||
|
||||
/// <summary>Leading/trailing whitespace around the user name is trimmed before minting the principal.</summary>
|
||||
[Fact]
|
||||
public void CreatePrincipal_TrimsUser()
|
||||
{
|
||||
@@ -35,6 +39,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
|
||||
Assert.Equal("multi-role", principal.Identity!.Name);
|
||||
}
|
||||
|
||||
/// <summary>A custom user name preserves its identity name and still receives both dashboard roles.</summary>
|
||||
[Fact]
|
||||
public void CreatePrincipal_CustomUser_PreservesNameAndRoles()
|
||||
{
|
||||
|
||||
+4
-6
@@ -171,13 +171,11 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
Assert.True(model.IsDegraded);
|
||||
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
||||
Assert.Equal("x", model.Reason);
|
||||
// Tests-033: pin the amber label text, not just the CSS class — a label swap
|
||||
// would otherwise pass this test.
|
||||
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: an explicitly-degraded status whose mode is still Alarmmgr (the
|
||||
/// An explicitly-degraded status whose mode is still Alarmmgr (the
|
||||
/// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must still
|
||||
/// map to the degraded amber badge.
|
||||
/// </summary>
|
||||
@@ -199,7 +197,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: the <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
|
||||
/// The <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
|
||||
/// timestamp converted to a <see cref="DateTimeOffset" />.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -220,7 +218,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
|
||||
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
|
||||
/// dashboard SignalR snapshot path actually calls — projects a provider-status
|
||||
/// feed message into the badge model.
|
||||
/// </summary>
|
||||
@@ -246,7 +244,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
|
||||
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
|
||||
/// <see cref="ArgumentException" /> when the feed message does not carry a
|
||||
/// provider-status payload.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardCookieOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_ConfiguresSecureDashboardCookie()
|
||||
{
|
||||
@@ -36,6 +37,7 @@ public sealed class DashboardCookieOptionsTests
|
||||
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
|
||||
/// the dashboard can be reached over plain HTTP in dev.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public sealed class DashboardCookieOptionsTests
|
||||
/// cookie name, so a gateway instance sharing a hostname with another can be given a
|
||||
/// distinct name (browser cookies are scoped by host+path, not port).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WithCookieNameOverride_UsesConfiguredName()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
public sealed class DashboardDisableLoginTests
|
||||
{
|
||||
/// <summary>Verifies that when dashboard login is not disabled, the dashboard authentication scheme resolves to the cookie authentication handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisableLoginOff_CookieSchemeUsesCookieHandler()
|
||||
{
|
||||
@@ -25,6 +27,8 @@ public sealed class DashboardDisableLoginTests
|
||||
Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when <c>MxGateway:Dashboard:DisableLogin</c> is enabled, the dashboard authentication scheme resolves to the auto-login handler instead of the cookie handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler()
|
||||
{
|
||||
@@ -40,6 +44,8 @@ public sealed class DashboardDisableLoginTests
|
||||
Assert.Equal(typeof(DashboardAutoLoginAuthenticationHandler), scheme!.HandlerType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the auto-login principal created when login is disabled satisfies the Admin, Viewer, and hub-clients authorization policies.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisableLoginOn_AutoLoginPrincipalSatisfiesAdminAndViewerPolicies()
|
||||
{
|
||||
|
||||
+2
@@ -130,6 +130,8 @@ public sealed class DashboardGalaxySummaryProjectorTests
|
||||
/// Verifies that the cache status enum is faithfully mapped to the dashboard
|
||||
/// status enum across every defined value.
|
||||
/// </summary>
|
||||
/// <param name="cacheStatus">The Galaxy cache status to project.</param>
|
||||
/// <param name="expected">The expected dashboard status for the given cache status.</param>
|
||||
[Theory]
|
||||
[InlineData(GalaxyCacheStatus.Healthy, DashboardGalaxyStatus.Healthy)]
|
||||
[InlineData(GalaxyCacheStatus.Stale, DashboardGalaxyStatus.Stale)]
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
|
||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
||||
/// <param name="expectedRole">The expected role or null if no match.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||
@@ -61,6 +62,7 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that admin and viewer roles are both emitted when both groups are present.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
|
||||
{
|
||||
@@ -75,6 +77,7 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
|
||||
{
|
||||
@@ -86,12 +89,13 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.7 (canonical roles): an LDAP user in the admin group must resolve
|
||||
/// Canonical roles: an LDAP user in the admin group must resolve
|
||||
/// to the canonical role value <c>"Administrator"</c> (not the legacy
|
||||
/// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with
|
||||
/// string LITERALS — independent of <see cref="DashboardRoles"/> — so a
|
||||
/// regression on the constant's value is caught here.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue()
|
||||
{
|
||||
@@ -105,7 +109,7 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.7: the canonical admin value (<c>"Administrator"</c>) passes the
|
||||
/// The canonical admin value (<c>"Administrator"</c>) passes the
|
||||
/// admin-only gate while a <c>"Viewer"</c> fails it — asserted with literals,
|
||||
/// proving enforcement is bound to the new value and the legacy <c>"Admin"</c>
|
||||
/// string is no longer what authorizes admin actions.
|
||||
@@ -124,6 +128,7 @@ public sealed class DashboardGroupRoleMapperTests
|
||||
/// Verifies the extracted shared helper is the single source of truth: it
|
||||
/// produces the same roles the mapper does for the same inputs.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SharedHelper_MatchesMapperOutput()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardHubsRegistrationTests
|
||||
{
|
||||
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public sealed class DashboardHubsRegistrationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
|
||||
{
|
||||
|
||||
+12
-7
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardSessionAdminServiceTests
|
||||
{
|
||||
/// <summary>Verifies that a viewer cannot close a session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_ViewerCannotManage()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an admin can close a session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_AdminClosesSession()
|
||||
{
|
||||
@@ -43,6 +45,7 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
|
||||
{
|
||||
@@ -62,6 +65,7 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_ViewerCannotManage()
|
||||
{
|
||||
@@ -78,6 +82,7 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an admin can kill a worker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_AdminKillsWorker()
|
||||
{
|
||||
@@ -93,14 +98,11 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
Assert.Equal(1, sessionManager.KillCount);
|
||||
Assert.Equal("session-1", sessionManager.LastKilledSessionId);
|
||||
|
||||
// Tests-028: pin the literal reason string so a future caller-side change is a deliberate
|
||||
// test update rather than a silent drift. DashboardSessionAdminService passes a hard-coded
|
||||
// "dashboard-admin-kill" so the worker-exit metric (mxgateway.workers.killed) carries a
|
||||
// stable, machine-greppable reason tag.
|
||||
Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
|
||||
{
|
||||
@@ -117,10 +119,11 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-029: <c>CloseSessionAsync</c> has the same blank-session-id guard as
|
||||
/// <c>CloseSessionAsync</c> has the same blank-session-id guard as
|
||||
/// <c>KillWorkerAsync</c> but previously had no parallel test. Coverage was asymmetric.
|
||||
/// A guard-removal regression on the close path would slip through.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_BlankSessionId_ReturnsFailure()
|
||||
{
|
||||
@@ -148,12 +151,13 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-050: an unexpected (non-<see cref="SessionManagerException"/>)
|
||||
/// Regression: an unexpected (non-<see cref="SessionManagerException"/>)
|
||||
/// exception from <c>CloseSessionAsync</c> — e.g. an <see cref="InvalidOperationException"/>
|
||||
/// or <see cref="IOException"/> surfaced from <c>RemoveSessionAsync</c>/<c>DisposeAsync</c> —
|
||||
/// must be converted to a friendly <see cref="DashboardSessionAdminResult.Fail(string)"/>
|
||||
/// rather than propagating raw into Blazor's error boundary.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail()
|
||||
{
|
||||
@@ -173,8 +177,9 @@ public sealed class DashboardSessionAdminServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-050: same friendly-fail contract for the Kill path.
|
||||
/// Regression: same friendly-fail contract for the Kill path.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail()
|
||||
{
|
||||
|
||||
+19
-21
@@ -11,20 +11,14 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Server-042 regression: a transient failure inside
|
||||
/// A transient failure inside
|
||||
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> must not
|
||||
/// end the BackgroundService; the publisher must wait the configured
|
||||
/// reconnect delay and then re-open the subscription. Before the fix,
|
||||
/// the publisher exited on the first non-cancellation exception and
|
||||
/// the dashboard's snapshot stream went silent until process restart.
|
||||
///
|
||||
/// <para>Tests-031: the reconnect-gap measurement is bounded between the
|
||||
/// moment the first subscribe actually <c>throw</c>s and the moment the
|
||||
/// second subscribe begins. Measuring from <c>startedAt</c> (pre-<c>StartAsync</c>)
|
||||
/// baselined scheduling overhead into the budget and made the lower bound
|
||||
/// flaky on slow CI; recording <c>firstThrowAt</c> inside the fake removes
|
||||
/// that baseline so only the <c>Task.Delay(reconnectDelay)</c> contributes.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay()
|
||||
{
|
||||
@@ -60,12 +54,6 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
$"Expected at least 2 subscribe calls, got {snapshotService.SubscribeCount}.");
|
||||
Assert.True(hubContext.SendCount >= 1);
|
||||
|
||||
// Tests-031: the gap is measured from the moment the first subscribe
|
||||
// actually threw (inside the fake) to the moment the second subscribe
|
||||
// began (also inside the fake). This isolates the publisher's
|
||||
// Task.Delay(reconnectDelay) — no StartAsync / scheduling overhead in
|
||||
// the baseline. The 10ms slack absorbs Task.Delay's coarse Windows
|
||||
// timer quantum (~15ms) when the underlying scheduler wakes early.
|
||||
TimeSpan gap = secondSubscribeAt - firstThrowAt;
|
||||
Assert.True(gap >= reconnectDelay - TimeSpan.FromMilliseconds(10),
|
||||
$"Expected reconnect gap >= {reconnectDelay.TotalMilliseconds}ms; got {gap.TotalMilliseconds}ms.");
|
||||
@@ -75,6 +63,7 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// Sanity: a normal completion of WatchSnapshotsAsync (no exception)
|
||||
/// also reconnects after the delay — exits only on host shutdown.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenSnapshotServiceCompletes_ReconnectsAfterDelay()
|
||||
{
|
||||
@@ -114,7 +103,7 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
public int SubscribeCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tests-031: the wall-clock instant the first <c>WatchSnapshotsAsync</c> throws.
|
||||
/// The wall-clock instant the first <c>WatchSnapshotsAsync</c> throws.
|
||||
/// The reconnect-gap assertion is measured against this timestamp (NOT the
|
||||
/// pre-<c>StartAsync</c> wall clock) so scheduling overhead is not baselined
|
||||
/// into the lower bound.
|
||||
@@ -124,14 +113,13 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// <summary>Gets the wall-clock instant of the second subscription attempt.</summary>
|
||||
public DateTimeOffset? SecondSubscribeAt { get; private set; }
|
||||
|
||||
/// <summary>Gets the current snapshot.</summary>
|
||||
/// <inheritdoc />
|
||||
public DashboardSnapshot GetSnapshot()
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
/// <summary>Watches for snapshot changes and yields them asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -167,14 +155,13 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// <summary>Gets the number of subscription attempts.</summary>
|
||||
public int SubscribeCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the current snapshot.</summary>
|
||||
/// <inheritdoc />
|
||||
public DashboardSnapshot GetSnapshot()
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
/// <summary>Watches for snapshot changes and completes immediately.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature
|
||||
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -211,35 +198,43 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
|
||||
/// <summary>Gets a client proxy excluding specified connections.</summary>
|
||||
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for a specific connection.</summary>
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy Client(string connectionId) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for specified connections.</summary>
|
||||
/// <param name="connectionIds">The connection identifiers.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for a group.</summary>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy Group(string groupName) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for a group excluding specified connections.</summary>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for specified groups.</summary>
|
||||
/// <param name="groupNames">The group names.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for a specific user.</summary>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy User(string userId) => AllProxy;
|
||||
|
||||
/// <summary>Gets a client proxy for specified users.</summary>
|
||||
/// <param name="userIds">The user identifiers.</param>
|
||||
/// <returns>The recording client proxy shared by this fake.</returns>
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
|
||||
}
|
||||
|
||||
@@ -254,6 +249,7 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// <param name="method">The SignalR method name.</param>
|
||||
/// <param name="args">The method arguments.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _sendCount);
|
||||
@@ -267,6 +263,7 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
@@ -274,6 +271,7 @@ public sealed class DashboardSnapshotPublisherTests
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-059: the shared library replaces the cache entry via
|
||||
/// The shared library replaces the cache entry via
|
||||
/// <c>previous with { ... }</c> at the <b>same</b> Sequence on a steady-state tick and on a
|
||||
/// refresh failure (Status → Unavailable, LastError set). The dashboard summary must reflect
|
||||
/// those per-tick-volatile fields, not a summary frozen at the last heavy-refresh sequence —
|
||||
@@ -356,7 +356,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
/// <summary>
|
||||
/// Verifies that a changed cache Sequence invalidates the memoized template breakdown and the
|
||||
/// next snapshot reflects the new object set. Guards against an inverted sequence check that
|
||||
/// would freeze the Galaxy section after its first load (Tests-041).
|
||||
/// would freeze the Galaxy section after its first load.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_WhenSequenceChanges_RecomputesTemplateBreakdown()
|
||||
@@ -405,6 +405,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
|
||||
{
|
||||
@@ -441,6 +442,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
|
||||
{
|
||||
@@ -484,6 +486,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||
{
|
||||
@@ -575,31 +578,47 @@ public sealed class DashboardSnapshotServiceTests
|
||||
|
||||
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the fake never persists created keys.</summary>
|
||||
/// <param name="record">API key record to create.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Lists API keys; the base fake always returns an empty list.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An empty list of API key summaries.</returns>
|
||||
public virtual Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>([]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the fake never revokes keys.</summary>
|
||||
/// <param name="keyId">Key identifier to revoke.</param>
|
||||
/// <param name="whenUtc">Revocation timestamp.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns><see langword="false"/>, always.</returns>
|
||||
public Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the fake never rotates key secrets.</summary>
|
||||
/// <param name="keyId">Key identifier to rotate.</param>
|
||||
/// <param name="newSecretHash">New secret hash.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns><see langword="false"/>, always.</returns>
|
||||
public Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Does nothing; the fake never deletes keys.</summary>
|
||||
/// <param name="keyId">Key identifier to delete.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns><see langword="false"/>, always.</returns>
|
||||
public Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
@@ -664,24 +683,16 @@ public sealed class DashboardSnapshotServiceTests
|
||||
int? processId,
|
||||
WorkerClientState state) : IWorkerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the session identifier.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = sessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process identifier.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = processId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current worker client state.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; private set; } = state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last heartbeat.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
@@ -699,24 +710,14 @@ public sealed class DashboardSnapshotServiceTests
|
||||
/// </summary>
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the worker client asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a worker command asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to invoke.</param>
|
||||
/// <param name="timeout">Command timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Command reply.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
@@ -725,11 +726,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
return Task.FromResult(new WorkerCommandReply());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events from the worker asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of worker events.</returns>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -737,12 +734,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuts down the worker client asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Shutdown timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -752,10 +744,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates the worker client.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for termination.</param>
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason)
|
||||
{
|
||||
KillCount++;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class HubTokenServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-039: a token whose data-protected payload has both
|
||||
/// A token whose data-protected payload has both
|
||||
/// <c>Name</c> and <c>NameIdentifier</c> null (the principal that
|
||||
/// minted the token had no identity claims) must be rejected by
|
||||
/// <see cref="HubTokenService.Validate"/>. The role claims alone are
|
||||
@@ -38,7 +38,7 @@ public sealed class HubTokenServiceTests
|
||||
/// <summary>
|
||||
/// Sanity check: a token minted from a principal with a Name claim
|
||||
/// validates and returns a principal carrying that identity. Pins
|
||||
/// that the Server-039 fix does not over-reject valid tokens.
|
||||
/// that the null-identity rejection above does not over-reject valid tokens.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
public sealed class GatewayApplicationTests
|
||||
{
|
||||
/// <summary>Verifies that Build maps the canonical three health tiers.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_MapsCanonicalHealthEndpoints()
|
||||
{
|
||||
@@ -43,12 +44,13 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Build registers the gateway's browse-scope provider so it wins over the
|
||||
/// shared library's no-op default (Server-060). The per-API-key browse-subtree scoping
|
||||
/// shared library's no-op default. The per-API-key browse-subtree scoping
|
||||
/// depends on <c>AddSingleton<IGalaxyBrowseScopeProvider, GatewayBrowseScopeProvider></c>
|
||||
/// running before <c>AddZbGalaxyRepository</c>, whose <c>TryAddSingleton</c> default
|
||||
/// (NullGalaxyBrowseScopeProvider → full hierarchy) must lose. If this registration order ever
|
||||
/// regressed, every metadata-scoped key would silently see the entire Galaxy hierarchy.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_RegistersGatewayBrowseScopeProviderOverLibraryDefault()
|
||||
{
|
||||
@@ -61,6 +63,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_RegistersGatewayMetrics()
|
||||
{
|
||||
@@ -72,6 +75,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_MapsMetricsEndpoint()
|
||||
{
|
||||
@@ -94,6 +98,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||
{
|
||||
@@ -123,6 +128,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
|
||||
{
|
||||
@@ -152,6 +158,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
||||
{
|
||||
@@ -180,6 +187,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard routes are registered at root when enabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
|
||||
{
|
||||
@@ -205,6 +213,7 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard routes are not mapped when disabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
||||
{
|
||||
@@ -221,6 +230,7 @@ public sealed class GatewayApplicationTests
|
||||
/// <param name="key">Configuration key to override.</param>
|
||||
/// <param name="value">Invalid configuration value.</param>
|
||||
/// <param name="expectedFailure">Expected validation error message.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"MxGateway:Worker:ExecutablePath",
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
/// <summary>
|
||||
/// Verifies gateway session lifecycle with a scripted fake worker: open, command, event, close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath()
|
||||
{
|
||||
@@ -99,6 +100,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
/// through the full gRPC→WorkerClient→pipe roundtrip when the fake worker responds
|
||||
/// with canned replies via RespondToControlCommandAsync.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewayService_WithFakeWorker_ControlCommandsRoundtripThroughGateway()
|
||||
{
|
||||
@@ -296,6 +298,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
/// <summary>
|
||||
/// Disposes all active sessions and metrics.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (GatewaySession session in _registry.Snapshot())
|
||||
@@ -351,12 +354,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
/// </summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Launches a new worker process and returns a handle to manage it.
|
||||
/// </summary>
|
||||
/// <param name="request">Worker process launch request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker process handle.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -482,6 +480,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
|
||||
/// <summary>Waits until the scripted worker has responded to one control command.</summary>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WaitForNextControlCommandAsync(TimeSpan timeout)
|
||||
{
|
||||
using CancellationTokenSource cts = new(timeout);
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// Two concurrent StreamEvents RPCs on one session both receive every worker event
|
||||
/// the fake worker emits, in order.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_TwoSubscribers_BothReceiveAllEvents()
|
||||
{
|
||||
@@ -130,6 +131,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// When one of two subscribers cancels its stream, the other subscriber continues
|
||||
/// to receive subsequent events and the session remains usable.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_OneSubscriberCancels_OtherContinuesReceivingEvents()
|
||||
{
|
||||
@@ -241,6 +243,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// rejected with gRPC status <see cref="StatusCode.ResourceExhausted"/> while the first
|
||||
/// two subscribers keep streaming.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_ThirdSubscriberWhenCapIsTwo_ReceivesResourceExhausted()
|
||||
{
|
||||
@@ -411,6 +414,9 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
private readonly GatewayMetrics _metrics = new();
|
||||
private readonly SessionRegistry _registry = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="MultiSubscriberGatewayServiceFixture"/> class.</summary>
|
||||
/// <param name="launcher">Fake worker process launcher backing the session manager.</param>
|
||||
/// <param name="maxEventSubscribersPerSession">Maximum concurrent event subscribers allowed per session.</param>
|
||||
public MultiSubscriberGatewayServiceFixture(
|
||||
IWorkerProcessLauncher launcher,
|
||||
int maxEventSubscribersPerSession = 8)
|
||||
@@ -446,6 +452,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
new FakeGatewayAlarmService());
|
||||
}
|
||||
|
||||
/// <summary>Gets the gateway service under test.</summary>
|
||||
public MxAccessGatewayService Service { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -454,6 +461,10 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// bounded by <paramref name="timeout"/>. Fails the test if the count is not reached
|
||||
/// within the deadline.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Identifier of the session to poll.</param>
|
||||
/// <param name="n">Target subscriber count to wait for.</param>
|
||||
/// <param name="timeout">Maximum time to wait before failing the test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <remarks>
|
||||
/// This is the deterministic gate that replaces <c>Task.Delay</c> before Advise
|
||||
/// calls: it proves the production code has registered each subscriber before we
|
||||
@@ -485,6 +496,8 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes every session in the registry and releases the fixture's metrics.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (GatewaySession session in _registry.Snapshot())
|
||||
@@ -533,8 +546,10 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
|
||||
private readonly FakeWorkerProcess _process = new(ProcessId);
|
||||
|
||||
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -625,6 +640,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
private readonly SemaphoreSlim _emitGate = new(0, 64);
|
||||
private volatile bool _stopEmitting;
|
||||
|
||||
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Releases the gate so the worker emits one event.</summary>
|
||||
@@ -640,6 +656,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
_emitGate.Release(); // unblock a pending gate wait if any
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class GatewayTlsBootstrapTests
|
||||
/// trusted dev cert, Kestrel would otherwise serve that dev cert (<c>CN=localhost</c>), so the
|
||||
/// subject assertion is what makes this test fail without the wiring on either kind of host.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Host_ServesGeneratedCertificate_WhenHttpsEndpointHasNoCertificate()
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class EventStreamServiceTests
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Verifies that events from the worker stream maintain their original sequence order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
|
||||
{
|
||||
@@ -38,6 +39,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a second event subscriber is rejected when one is already active.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
|
||||
{
|
||||
@@ -67,6 +69,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that canceling an event stream detaches the subscriber cleanly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
|
||||
{
|
||||
@@ -89,6 +92,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing an event stream with buffered events resets the queue depth metric.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenDisposedWithBufferedEvents_ResetsStreamQueueDepth()
|
||||
{
|
||||
@@ -116,6 +120,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that queue depth metrics correctly track concurrent event streams across multiple sessions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth()
|
||||
{
|
||||
@@ -157,7 +162,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-targeted in Task 5: a per-subscriber channel overflow in the session's
|
||||
/// A per-subscriber channel overflow in the session's
|
||||
/// <see cref="SessionEventDistributor"/> faults the whole session under the legacy
|
||||
/// single-subscriber FailFast policy (the default, single-subscriber mode) and records
|
||||
/// the overflow + fault metrics. The distributor completes this subscriber's channel
|
||||
@@ -165,6 +170,7 @@ public sealed class EventStreamServiceTests
|
||||
/// <see cref="SessionManagerErrorCode.EventQueueOverflow"/> the pre-epic per-RPC
|
||||
/// overflow produced.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
|
||||
{
|
||||
@@ -189,7 +195,7 @@ public sealed class EventStreamServiceTests
|
||||
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
// The pump fans 50 events into a capacity-1 subscriber channel faster than this
|
||||
// The pump fans 50 events into a subscriber channel with capacity 1 faster than this
|
||||
// single reader drains, so one of the reads observes the terminal overflow fault.
|
||||
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||
async () =>
|
||||
@@ -211,12 +217,13 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-targeted in Task 5: under the DisconnectSubscriber policy a per-subscriber
|
||||
/// channel overflow disconnects only that subscriber's stream (terminal
|
||||
/// Under the DisconnectSubscriber policy a per-subscriber channel overflow
|
||||
/// disconnects only that subscriber's stream (terminal
|
||||
/// <see cref="SessionManagerErrorCode.EventQueueOverflow"/>) and records the overflow
|
||||
/// metric, but leaves the session <see cref="SessionState.Ready"/> and records no
|
||||
/// fault. The session, pump, and any other subscribers are unaffected.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady()
|
||||
{
|
||||
@@ -259,6 +266,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the event stream does not synthesize OperationComplete events from write completions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
|
||||
{
|
||||
@@ -276,6 +284,7 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a terminal fault from the worker event stream propagates and faults the session.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
|
||||
{
|
||||
@@ -301,9 +310,10 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: resuming with AfterWorkerSequence inside the retained window replays exactly
|
||||
/// Resuming with AfterWorkerSequence inside the retained window replays exactly
|
||||
/// the newer retained events (in order, no dup) then live, with NO ReplayGap sentinel.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_ResumeWithinRetainedWindow_ReplaysNewerThenLive_NoSentinel()
|
||||
{
|
||||
@@ -340,9 +350,10 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: resuming with AfterWorkerSequence older than the oldest retained yields the
|
||||
/// Resuming with AfterWorkerSequence older than the oldest retained yields the
|
||||
/// ReplayGap sentinel FIRST (correct requested/oldest), then the retained tail, then live.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_ResumeOlderThanOldestRetained_EmitsSentinelFirst_ThenTailThenLive()
|
||||
{
|
||||
@@ -385,9 +396,10 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: the replay→live boundary is contiguous — no duplicate and no skip — even
|
||||
/// The replay→live boundary is contiguous — no duplicate and no skip — even
|
||||
/// when events span the handoff.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_ResumeHandoff_IsContiguous_NoDuplicateNoSkip()
|
||||
{
|
||||
@@ -424,9 +436,10 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: the per-item filter applies to REPLAYED events identically to live — a
|
||||
/// The per-item filter applies to REPLAYED events identically to live — a
|
||||
/// replayed event at/below the requested watermark is never delivered.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_ResumeReplay_AppliesPerItemFilter_DropsAtOrBelowWatermark()
|
||||
{
|
||||
@@ -466,9 +479,10 @@ public sealed class EventStreamServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: AfterWorkerSequence == 0 is a fresh stream (not a resume) — no replay, no
|
||||
/// AfterWorkerSequence == 0 is a fresh stream (not a resume) — no replay, no
|
||||
/// sentinel, just live events as before.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_FreshStreamAfterSequenceZero_NoReplayNoSentinel()
|
||||
{
|
||||
@@ -816,7 +830,8 @@ public sealed class EventStreamServiceTests
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op disposal; the fake holds no unmanaged resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
||||
/// service → <c>BrowseChildren</c> returns empty children.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_BrowseSubtreesConstraintThroughHostWiring_FiltersChildren()
|
||||
{
|
||||
@@ -130,13 +131,17 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
/// <summary>Immediately-ready stub: Current returns the seeded entry, loads are instant.</summary>
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Gets the seeded cache entry passed to the stub's constructor.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op: the stub's entry is always considered already loaded.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -146,23 +151,33 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
/// </summary>
|
||||
private sealed class StubGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Never returns; always throws.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Never returns; always throws.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Never returns; always throws.</returns>
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Never returns; always throws.</returns>
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Always throws: <c>BrowseChildren</c> never calls the repository directly.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Never returns; always throws.</returns>
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException("Not called during BrowseChildren.");
|
||||
}
|
||||
|
||||
+42
-54
@@ -13,7 +13,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Server-021. <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
|
||||
/// <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
|
||||
/// the <c>BulkConstraintPlan</c> / <c>ReadBulkConstraintPlan</c> /
|
||||
/// <c>WriteBulkConstraintPlan</c> / <c>SubscribeBulkConstraintPlan</c> reply-merge
|
||||
/// logic was previously exercised only with an allow-all enforcer, so denial
|
||||
@@ -34,6 +34,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// worker once with only the allowed tags, then splice the denied entries
|
||||
/// back into the reply at their original indices.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder()
|
||||
{
|
||||
@@ -95,6 +96,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <see cref="BulkConstraintPlan.HasAllowedItems"/> false, return the
|
||||
/// denied-only reply, and never call the session manager.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply()
|
||||
{
|
||||
@@ -118,6 +120,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <c>FilterHandleBulkAsync</c> against <c>CheckReadHandleAsync</c>. Partial
|
||||
/// denial must still produce a merged-by-index <c>BulkSubscribeReply</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply()
|
||||
{
|
||||
@@ -166,6 +169,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// unchanged — the constraint plan is null and no merge occurs. Regression
|
||||
/// guard against accidentally engaging the merge path for the common case.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged()
|
||||
{
|
||||
@@ -208,6 +212,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// the SubscribeBulk family because the reply slot is
|
||||
/// <c>BulkReadReply</c>, not <c>BulkSubscribeReply</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults()
|
||||
{
|
||||
@@ -255,6 +260,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <see cref="MxAccessGatewayService"/>'s <c>ReadBulkConstraintPlan</c>
|
||||
/// <c>CreateDeniedReply</c> path.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
@@ -279,6 +285,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// forwarded command and splice a denied <c>BulkWriteResult</c> back in at
|
||||
/// the original index.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply()
|
||||
{
|
||||
@@ -329,6 +336,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// switch arm than plain <c>WriteBulk</c>. The merge logic is shared, so a
|
||||
/// full denial here is enough to prove the secured-bulk routing.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
@@ -347,13 +355,14 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-020: <c>Write2Bulk</c> takes the third <c>GetPayload</c>/<c>SetPayload</c>
|
||||
/// <c>Write2Bulk</c> takes the third <c>GetPayload</c>/<c>SetPayload</c>
|
||||
/// switch arm in <c>WriteBulkConstraintPlan</c>. The merge logic is shared with
|
||||
/// <c>WriteBulk</c>, but a full denial through the <c>CreateDeniedReply</c> path
|
||||
/// proves the <c>Write2Bulk</c> arm of the per-kind <c>SetPayload</c> switch fires
|
||||
/// (and not, say, <c>WriteBulk</c> by mistake) — guarding against a refactor that
|
||||
/// drops or misroutes the <c>Write2Bulk</c> case.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_Write2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
@@ -377,11 +386,12 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-020: <c>WriteSecured2Bulk</c> takes the fourth <c>GetPayload</c>/<c>SetPayload</c>
|
||||
/// <c>WriteSecured2Bulk</c> takes the fourth <c>GetPayload</c>/<c>SetPayload</c>
|
||||
/// switch arm in <c>WriteBulkConstraintPlan</c>. Same reasoning as
|
||||
/// <c>Write2Bulk</c> — assert the <c>WriteSecured2Bulk</c> reply slot is populated
|
||||
/// to prove that arm of the switch fires.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteSecured2Bulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
@@ -404,10 +414,10 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
Assert.Empty(reply.WriteSecuredBulk?.Results ?? new Google.Protobuf.Collections.RepeatedField<BulkWriteResult>());
|
||||
}
|
||||
|
||||
// === Worker reply-count divergence (Tests-024) ===
|
||||
// === Worker reply-count divergence ===
|
||||
|
||||
/// <summary>
|
||||
/// Tests-024: <c>WriteBulkConstraintPlan.MergeDeniedInto</c> dequeues from
|
||||
/// <c>WriteBulkConstraintPlan.MergeDeniedInto</c> dequeues from
|
||||
/// <c>allowedResults</c> per non-denied slot via <c>Queue.TryDequeue</c>,
|
||||
/// which silently returns <c>false</c> when the queue is empty. Pin the
|
||||
/// observable behaviour when the worker returns FEWER allowed results than
|
||||
@@ -417,6 +427,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// This fixture makes that "silent truncate" behaviour explicit so a future
|
||||
/// change either fills the gap with a synthetic failure or fails this test.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteBulk_WhenWorkerReturnsFewerResultsThanAllowed_MergedReplyIsTruncated()
|
||||
{
|
||||
@@ -464,12 +475,13 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-024: when the worker returns MORE allowed results than the
|
||||
/// When the worker returns MORE allowed results than the
|
||||
/// gateway forwarded, the extras must be silently ignored — the merged
|
||||
/// reply length stays at <c>OriginalCount</c>. This pins the
|
||||
/// <c>for index < OriginalCount</c> loop bound so a regression that
|
||||
/// accidentally surfaces extras as trailing results is caught.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteBulk_WhenWorkerReturnsExtraResults_IgnoresExtras()
|
||||
{
|
||||
@@ -527,6 +539,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceWriteHandleAsync</c>
|
||||
/// and never reach the session manager.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker()
|
||||
{
|
||||
@@ -554,6 +567,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// enforce helper -> <c>RecordDenialAsync</c>, so the recorded denial carries the exact
|
||||
/// id the client sent (including non-GUID trace ids used by Rust/Python/Java clients).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_Write_WithDeniedHandle_ThreadsClientCorrelationIdIntoRecordedDenial()
|
||||
{
|
||||
@@ -581,6 +595,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <c>ApplyConstraintsAsync</c> (Write/Write2/WriteSecured/WriteSecured2) is
|
||||
/// reachable for at least one of the secured kinds.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied()
|
||||
{
|
||||
@@ -604,6 +619,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceReadTagAsync</c>
|
||||
/// and never reach the session manager.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker()
|
||||
{
|
||||
@@ -881,11 +897,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
/// <param name="session">The session to seed.</param>
|
||||
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
|
||||
|
||||
/// <summary>Opens a test session asynchronously.</summary>
|
||||
/// <param name="request">The session open request.</param>
|
||||
/// <param name="clientIdentity">The client identity, if any.</param>
|
||||
/// <param name="ownerKeyId">The API key identifier of the caller, if any.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
@@ -893,9 +905,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(seededSessions.Values.First());
|
||||
|
||||
/// <summary>Tries to get a test session by identifier.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="session">The session, if found.</param>
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, out GatewaySession session)
|
||||
{
|
||||
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
|
||||
@@ -914,10 +924,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Invokes a worker command and returns the reply asynchronously.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="command">The worker command.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
@@ -928,9 +935,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
return Task.FromResult(InvokeReply);
|
||||
}
|
||||
|
||||
/// <summary>Reads events from the session asynchronously.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
string sessionId,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -943,33 +948,25 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes a test session asynchronously.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <summary>Kills a worker process asynchronously.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="reason">The reason for killing the worker.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <summary>Closes expired session leases asynchronously.</summary>
|
||||
/// <param name="now">The current time to check against.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
/// <summary>Shuts down the test session manager asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static GatewaySession CreateFallbackSession(string sessionId)
|
||||
@@ -994,9 +991,7 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
|
||||
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
|
||||
{
|
||||
/// <summary>Streams events for the test session asynchronously.</summary>
|
||||
/// <param name="request">The stream events request.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -1012,33 +1007,28 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the test session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
|
||||
|
||||
/// <summary>Gets the test worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the test worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat time.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Starts the test worker client asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Invokes a command on the test worker asynchronously.</summary>
|
||||
/// <param name="command">The worker command.</param>
|
||||
/// <param name="timeout">Maximum time to wait for completion.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <summary>Reads events from the test worker asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1046,18 +1036,16 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>Shuts down the test worker client asynchronously.</summary>
|
||||
/// <param name="timeout">Maximum time to wait for completion.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Kills the test worker process.</summary>
|
||||
/// <param name="reason">The reason for killing the worker.</param>
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the test worker client. No-op — there is no unmanaged state to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
|
||||
public sealed class MxAccessGatewayServiceTests
|
||||
{
|
||||
/// <summary>Verifies that OpenSession returns correct session details for a valid request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
|
||||
{
|
||||
@@ -56,6 +57,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
/// <c>TryGetSession</c> return false, so this test fails if the service drops
|
||||
/// its missing-session check rather than passing for the wrong reason.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
|
||||
{
|
||||
@@ -78,6 +80,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
/// manager when <see cref="FakeSessionManager.ResolveOnlySeededSessions"/> is on,
|
||||
/// confirming the missing-session test above is gated on a real lookup.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WhenSessionSeeded_ResolvesAndInvokes()
|
||||
{
|
||||
@@ -94,6 +97,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
||||
{
|
||||
@@ -117,6 +121,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Invoke returns HResult status and method payload from worker reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
|
||||
{
|
||||
@@ -172,6 +177,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that StreamEvents writes only events after the specified worker sequence.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
|
||||
{
|
||||
@@ -197,15 +203,8 @@ public sealed class MxAccessGatewayServiceTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>StreamEvents</c> records the send-duration histogram per event.
|
||||
///
|
||||
/// <para>Tests-027 (concurrency flake): the listener must filter by the specific
|
||||
/// <see cref="System.Diagnostics.Metrics.Meter"/> instance owned by this test, not by the process-shared
|
||||
/// <see cref="GatewayMetrics.MeterName"/>. Otherwise a parallel test that constructs its own
|
||||
/// <see cref="GatewayMetrics"/> and records <c>mxgateway.events.stream_send.duration</c> would
|
||||
/// cross-contaminate <c>families</c> and break the equality assertion below. See the companion
|
||||
/// <see cref="StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName"/>
|
||||
/// regression for the cross-talk reproduction.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
|
||||
{
|
||||
@@ -252,13 +251,14 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-027 regression: a <see cref="MeterListener"/> that filters by the specific
|
||||
/// A <see cref="MeterListener"/> that filters by the specific
|
||||
/// <see cref="System.Diagnostics.Metrics.Meter"/> instance (via <see cref="object.ReferenceEquals"/>)
|
||||
/// must NOT observe measurements recorded on a different <see cref="GatewayMetrics"/> that shares
|
||||
/// the same <see cref="GatewayMetrics.MeterName"/>. This is the cross-talk vector that previously
|
||||
/// caused <c>StreamEvents_WhenEventIsWritten_RecordsSendDuration</c> to fail intermittently when
|
||||
/// run in parallel with another test recording the same histogram.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName()
|
||||
{
|
||||
@@ -315,6 +315,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CloseSession throws InvalidArgument when session ID is blank.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
|
||||
{
|
||||
@@ -335,6 +336,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
// alarm feed. CreateService injects FakeGatewayAlarmService.
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm rejects an empty alarm_full_reference.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
|
||||
{
|
||||
@@ -349,6 +351,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService()
|
||||
{
|
||||
@@ -369,6 +372,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamAlarms_ForwardsTheCentralAlarmFeed()
|
||||
{
|
||||
@@ -386,6 +390,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSession_AdvertisesAlarmRpcCapabilities()
|
||||
{
|
||||
@@ -715,7 +720,8 @@ public sealed class MxAccessGatewayServiceTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Completes immediately without disposing any resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -43,6 +43,7 @@ public sealed class ArrayAddressNormalizerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies null, empty, and whitespace addresses are returned unchanged.</summary>
|
||||
/// <param name="address">The blank address under test.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -96,10 +97,14 @@ public sealed class ArrayAddressNormalizerTests
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op stub refresh; the fixed <see cref="Current"/> entry never changes.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op stub wait; the fixed <see cref="Current"/> entry is already loaded.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array <c>AddItem</c> address is normalized to its writable array form on the wire,
|
||||
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -71,6 +72,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
}
|
||||
|
||||
/// <summary>A bare scalar <c>AddItem</c> address is forwarded unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem_ScalarAddress_ForwardedUnchanged()
|
||||
{
|
||||
@@ -99,6 +101,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A sparse-array <see cref="WriteCommand"/> value is expanded to a full, default-filled
|
||||
/// <see cref="MxArray"/> before reaching the worker; no sparse value is ever forwarded.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -145,6 +148,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array <c>AddItem2</c> address is normalized to its writable array form on the wire,
|
||||
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem2_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -199,6 +203,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; no sparse value is ever
|
||||
/// forwarded inside a bulk write.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -253,6 +258,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
|
||||
/// address in the <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -329,6 +335,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -376,6 +383,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -424,6 +432,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -471,6 +480,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -524,6 +534,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -578,6 +589,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -630,6 +642,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
|
||||
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -745,10 +758,14 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always ready.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always considered loaded.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -760,16 +777,16 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// <summary>Gets or sets the reply returned by the next invocation.</summary>
|
||||
public WorkerCommandReply NextReply { get; set; } = new();
|
||||
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-array-write-wiring";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -801,7 +818,8 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the test worker client. No-op — there is no unmanaged state to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -14,7 +14,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 regression tests for the internal dashboard mirror. The dashboard is a
|
||||
/// Regression tests for the internal dashboard mirror. The dashboard is a
|
||||
/// first-class subscriber on the session's <see cref="SessionEventDistributor"/>, so it
|
||||
/// receives session events whether or not a gRPC client is streaming — fixing the
|
||||
/// "dark feed" where the dashboard only saw events while a gRPC client was actively
|
||||
@@ -28,9 +28,8 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The KEY bug-fix test: the dashboard broadcaster receives session events even when
|
||||
/// NO gRPC <c>StreamEvents</c> subscriber is attached. The session is driven to Ready
|
||||
/// with a fake worker emitting events; only the internal dashboard subscriber exists.
|
||||
/// Before Task 6 the mirror lived inside the per-RPC gRPC loop, so with no gRPC
|
||||
/// subscriber the dashboard saw nothing.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_ReceivesEvents_WithNoGrpcSubscriber()
|
||||
{
|
||||
@@ -59,6 +58,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// gRPC path is no longer the dashboard's source — both read independent leases fed by
|
||||
/// the single distributor pump.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_AndGrpcSubscriber_BothReceiveEvents()
|
||||
{
|
||||
@@ -108,11 +108,12 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 hazard guard: starting the pump at Ready with a fast-completing worker stream
|
||||
/// Hazard guard: starting the pump at Ready with a fast-completing worker stream
|
||||
/// and zero subscribers used to drain into nothing and leave a later subscriber hanging.
|
||||
/// Now the dashboard subscriber is registered BEFORE the pump starts, so even a worker
|
||||
/// stream that completes immediately delivers every event to the dashboard with no hang.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_FastCompletingWorkerStream_DeliversAllEventsWithoutHang()
|
||||
{
|
||||
@@ -134,6 +135,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The dashboard Publish must be never-throw at the seam too: a throwing broadcaster
|
||||
/// must not fault the session or stop the mirror from continuing past the failure.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_WhenBroadcasterThrows_DoesNotFaultSessionAndKeepsMirroring()
|
||||
{
|
||||
@@ -155,6 +157,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The internal dashboard subscriber must NOT count against the single-subscriber
|
||||
/// guard: a gRPC subscriber can still attach while the dashboard mirror is running.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_DoesNotCountAgainstSingleSubscriberGuard()
|
||||
{
|
||||
@@ -239,8 +242,10 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
{
|
||||
private int _publishAttempts;
|
||||
|
||||
/// <summary>Number of times <see cref="Publish"/> has been invoked.</summary>
|
||||
public int PublishAttempts => Volatile.Read(ref _publishAttempts);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string sessionId, MxEvent mxEvent)
|
||||
{
|
||||
Interlocked.Increment(ref _publishAttempts);
|
||||
@@ -250,49 +255,59 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
|
||||
private sealed class SingleSessionManager(GatewaySession session) : ISessionManager
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId,
|
||||
CancellationToken cancellationToken) => Task.FromResult(session);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, out GatewaySession gatewaySession)
|
||||
{
|
||||
gatewaySession = session;
|
||||
return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Events yielded by <see cref="ReadEventsAsync"/>, in order.</summary>
|
||||
public List<WorkerEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>When <see langword="true"/>, <see cref="ReadEventsAsync"/> completes after yielding <see cref="Events"/> instead of hanging.</summary>
|
||||
public bool CompleteAfterConfiguredEvents { get; set; }
|
||||
|
||||
// Gate that holds the event stream before it yields anything. Released by default, so
|
||||
@@ -308,26 +323,35 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>Re-arms the release gate so <see cref="ReadEventsAsync"/> blocks before yielding events until <see cref="ReleaseEvents"/> is called.</summary>
|
||||
public void HoldEventsUntilReleased() =>
|
||||
_releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Releases the gate set by <see cref="HoldEventsUntilReleased"/>, letting <see cref="ReadEventsAsync"/> proceed.</summary>
|
||||
public void ReleaseEvents() => _releaseGate.TrySetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-dashboard-mirror";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -348,12 +372,16 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>No-op dispose; the fake worker client owns no resources to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,21 +13,21 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
|
||||
/// Server-015 and Server-016 audited the split lock discipline between
|
||||
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
|
||||
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
|
||||
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>,
|
||||
/// covering the split lock discipline between <c>_syncRoot</c> (state transitions)
|
||||
/// and <c>_closeLock</c> (close serialization) and the gated <c>DisposeAsync</c>.
|
||||
/// </summary>
|
||||
public sealed class GatewaySessionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
|
||||
/// A <c>TransitionTo(Ready)</c> issued after
|
||||
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
|
||||
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
|
||||
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
|
||||
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
|
||||
/// window the audit identified.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
|
||||
{
|
||||
@@ -54,12 +54,13 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
|
||||
/// Once <see cref="GatewaySession.CloseAsync"/> finishes,
|
||||
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
|
||||
/// session out of <see cref="SessionState.Closed"/> either — the close path's
|
||||
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
|
||||
/// machine uses, so the existing "Closed is terminal" invariant holds.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
|
||||
{
|
||||
@@ -76,7 +77,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
|
||||
/// A <see cref="GatewaySession.MarkFaulted"/> issued
|
||||
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
|
||||
/// <c>Closing</c> and <c>Closed</c> writes must not break the close path's
|
||||
/// terminal contract: the in-flight close runs to <c>Closed</c>, the fault
|
||||
@@ -86,6 +87,7 @@ public sealed class GatewaySessionTests
|
||||
/// Faulted" — this test pins the resolved end state so a future tightening
|
||||
/// of <c>MarkFaulted</c> cannot silently regress it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
|
||||
{
|
||||
@@ -113,11 +115,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
|
||||
/// <see cref="GatewaySession.DisposeAsync"/> must wait
|
||||
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
|
||||
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
|
||||
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
|
||||
{
|
||||
@@ -149,6 +152,7 @@ public sealed class GatewaySessionTests
|
||||
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
|
||||
/// rather than propagating it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
|
||||
{
|
||||
@@ -164,7 +168,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue-1 regression. Concurrent <c>Dispose()</c> calls on the same
|
||||
/// Concurrent <c>Dispose()</c> calls on the same
|
||||
/// <see cref="IEventSubscriberLease"/> — as can happen when a gRPC stream
|
||||
/// completion and a client cancellation both fire at the same time — must
|
||||
/// decrement <c>_activeEventSubscriberCount</c> exactly once, never to −1.
|
||||
@@ -174,6 +178,7 @@ public sealed class GatewaySessionTests
|
||||
/// the count must be exactly 0 and a subsequent single-subscriber attach must
|
||||
/// succeed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EventSubscriberLease_ConcurrentDispose_DecrementsCountExactlyOnce()
|
||||
{
|
||||
@@ -222,10 +227,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8 regression. Single-subscriber mode rejects a SECOND concurrent external
|
||||
/// Single-subscriber mode rejects a SECOND concurrent external
|
||||
/// attach with <see cref="SessionManagerErrorCode.EventSubscriberAlreadyActive"/> —
|
||||
/// the legacy guard is preserved unchanged when multi-subscriber is disabled.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_SingleMode_SecondAttachThrowsAlreadyActive()
|
||||
{
|
||||
@@ -244,10 +250,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. Multi-subscriber mode allows exactly <c>cap</c> concurrent external
|
||||
/// Multi-subscriber mode allows exactly <c>cap</c> concurrent external
|
||||
/// subscribers; the (cap+1)-th attach throws
|
||||
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_AttachesUpToCapThenThrowsLimitReached()
|
||||
{
|
||||
@@ -280,10 +287,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
|
||||
/// The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
|
||||
/// budget: with the dashboard mirror running, the full cap of external subscribers is
|
||||
/// still attachable.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_DashboardMirrorDoesNotConsumeCap()
|
||||
{
|
||||
@@ -322,12 +330,13 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8 concurrency. Many concurrent attaches in multi-subscriber mode must never
|
||||
/// Many concurrent attaches in multi-subscriber mode must never
|
||||
/// exceed the cap: the count-check-and-increment is atomic under <c>_syncRoot</c>, so
|
||||
/// exactly <c>cap</c> attaches succeed and the rest throw
|
||||
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>. The observed
|
||||
/// count never goes above the cap.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_ConcurrentAttaches_NeverExceedCap()
|
||||
{
|
||||
@@ -396,9 +405,10 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
|
||||
/// Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
|
||||
/// double-dispose does not double-free the slot (count integrity preserved).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_DisposeFreesSlotAndDoubleDisposeIsIdempotent()
|
||||
{
|
||||
@@ -434,10 +444,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With a positive detach-grace, dropping the last external subscriber must
|
||||
/// With a positive detach-grace, dropping the last external subscriber must
|
||||
/// RETAIN the session (it stays <see cref="SessionState.Ready"/>, not Closed/Faulted)
|
||||
/// and record a detached timestamp so the lease monitor can age it out later.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_LastSubscriberDrops_RetainsSessionAndRecordsDetachedTimestamp()
|
||||
{
|
||||
@@ -461,9 +472,10 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Advancing the clock past the detach-grace window makes the retained,
|
||||
/// Advancing the clock past the detach-grace window makes the retained,
|
||||
/// detached session eligible for close (<see cref="GatewaySession.IsDetachGraceExpired"/>).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ClockAdvancesPastWindow_SessionBecomesEligibleForClose()
|
||||
{
|
||||
@@ -487,10 +499,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Re-attaching a subscriber before the window elapses cancels the grace:
|
||||
/// Re-attaching a subscriber before the window elapses cancels the grace:
|
||||
/// the detached timestamp clears and a subsequent clock advance does NOT make the
|
||||
/// session eligible for close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ReattachBeforeExpiry_CancelsGrace()
|
||||
{
|
||||
@@ -520,11 +533,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With detach-grace disabled (<c>0</c>), dropping the last subscriber must
|
||||
/// With detach-grace disabled (<c>0</c>), dropping the last subscriber must
|
||||
/// match today's behavior: the session stays <see cref="SessionState.Ready"/> with no
|
||||
/// detached timestamp and is never eligible for a detach-grace close — it lingers only
|
||||
/// until its normal lease expires.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_Disabled_MatchesTodaysBehavior()
|
||||
{
|
||||
@@ -546,13 +560,14 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-055 regression. A FAILED first attach (the distributor never registered a
|
||||
/// A FAILED first attach (the distributor never registered a
|
||||
/// subscriber) must NOT enter the detach-grace window: the catch path's
|
||||
/// <c>DetachEventSubscriber</c> rolls the reserved slot back to 0 but must not stamp
|
||||
/// <c>DetachedAtUtc</c>, because the "last subscriber dropped" semantics only apply once
|
||||
/// a subscriber was successfully registered. A freshly-Ready session whose first attach
|
||||
/// failed must therefore stay out of grace and never become sweep-eligible on that basis.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_FailedFirstAttach_DoesNotEnterGrace()
|
||||
{
|
||||
@@ -583,11 +598,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. The gateway-owned internal dashboard subscriber must NOT keep a session out
|
||||
/// The gateway-owned internal dashboard subscriber must NOT keep a session out
|
||||
/// of detach-grace: with only the dashboard mirror attached (and no external gRPC
|
||||
/// subscriber), dropping the last external subscriber still enters grace and the
|
||||
/// window still expires.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_DashboardMirrorAlone_DoesNotPreventGraceEntry()
|
||||
{
|
||||
@@ -615,11 +631,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
|
||||
/// Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
|
||||
/// subscriber has reattached before flipping to Closing. When the grace window has elapsed but
|
||||
/// a subscriber is attached by the time TryBeginCloseIfExpired runs, it returns false and the
|
||||
/// session remains Ready.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose()
|
||||
{
|
||||
@@ -749,16 +766,16 @@ public sealed class GatewaySessionTests
|
||||
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets or sets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the count of shutdown invocations.</summary>
|
||||
@@ -768,6 +785,7 @@ public sealed class GatewaySessionTests
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
/// <summary>Waits for shutdown to start.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForShutdownStartAsync()
|
||||
{
|
||||
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -811,7 +829,8 @@ public sealed class GatewaySessionTests
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases the fake worker resources and records the invocation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
@@ -821,16 +840,16 @@ public sealed class GatewaySessionTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the count of dispose invocations.</summary>
|
||||
@@ -861,7 +880,8 @@ public sealed class GatewaySessionTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases the fake worker resources and records the invocation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class SessionEventDistributorTests
|
||||
{
|
||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Two subscribers registered on the same distributor both receive every fanned event, in order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TwoSubscribers_BothReceiveFannedEventsInOrder()
|
||||
{
|
||||
@@ -40,6 +42,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b2.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Disposing one subscriber's lease stops delivery to it while the other subscriber keeps receiving events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposingOneLease_StopsItsDelivery_OtherKeepsReceiving()
|
||||
{
|
||||
@@ -65,6 +69,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b2.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>A subscriber registered after the pump has started only receives events emitted after its registration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscriberRegisteredAfterStart_ReceivesEventsEmittedAfterRegistration()
|
||||
{
|
||||
@@ -84,6 +90,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Disposing the distributor completes every subscriber channel and stops the pump.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposingDistributor_CompletesAllSubscriberChannels_AndStopsPump()
|
||||
{
|
||||
@@ -101,6 +109,8 @@ public sealed class SessionEventDistributorTests
|
||||
await AssertCompletedAsync(leaseB.Reader);
|
||||
}
|
||||
|
||||
/// <summary>Calling <c>Register</c> after the distributor has been disposed throws <see cref="ObjectDisposedException"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Register_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
@@ -113,6 +123,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Throws<ObjectDisposedException>(() => distributor.Register());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the nested-lock disposal behavior in <c>RegisterWithReplay</c>: calling it after the
|
||||
/// distributor has been disposed throws <see cref="ObjectDisposedException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
@@ -134,6 +149,8 @@ public sealed class SessionEventDistributorTests
|
||||
out _));
|
||||
}
|
||||
|
||||
/// <summary>When retained events exceed the replay buffer's capacity, the oldest entries are evicted first and the replay reports a gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_OverCapacity_EvictsOldestFirst_AndReportsGap()
|
||||
{
|
||||
@@ -168,6 +185,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
|
||||
}
|
||||
|
||||
/// <summary>Requesting replay from a sequence still inside the retained window returns only the newer events, with no gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_WithinRetainedWindow_ReturnsNewerEvents_NoGap()
|
||||
{
|
||||
@@ -194,6 +213,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
|
||||
}
|
||||
|
||||
/// <summary>Retained replay entries older than the retention window are evicted once that window elapses.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AgedEntries_AreEvictedAfterRetentionElapses()
|
||||
{
|
||||
@@ -228,6 +249,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.True(gap);
|
||||
}
|
||||
|
||||
/// <summary>Requesting replay from a sequence newer than everything retained returns an empty list with no gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AfterSequenceNewerThanAllRetained_ReturnsEmpty_NoGap()
|
||||
{
|
||||
@@ -254,6 +277,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With replay buffering disabled (capacity 0), a caller behind the highest-seen sequence
|
||||
/// is told there is a gap and gets no replayed events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_Capacity0_AfterSequenceBelowHighestSeen_ReportsGap_NoEvents()
|
||||
{
|
||||
@@ -281,6 +309,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>With replay buffering disabled (capacity 0), a caller already caught up sees no gap and no events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_Capacity0_AfterSequenceAtOrAboveHighestSeen_NoGap_NoEvents()
|
||||
{
|
||||
@@ -307,6 +337,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>When no events have ever been seen, any requested sequence reports no gap and no events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_NoEventsSeen_AnyAfterSequence_NoGap_NoEvents()
|
||||
{
|
||||
@@ -325,6 +357,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requesting replay from <see cref="ulong.MaxValue"/> with retained events present does not
|
||||
/// falsely report a gap from the wrap-around and yields no new events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AfterSequenceMaxValue_WithRetainedEvents_NoGap_NoNewEvents()
|
||||
{
|
||||
@@ -349,12 +386,15 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscriber backpressure isolation: one subscriber stops reading and overflows its
|
||||
/// own tiny channel; it is disconnected with an <c>EventQueueOverflow</c> fault while a
|
||||
/// second, healthy subscriber keeps receiving and the pump keeps pumping.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SlowSubscriberOverflow_DisconnectsOnlyThatSubscriber_PumpAndOtherKeepRunning()
|
||||
{
|
||||
// Per-subscriber backpressure isolation (Task 5): one subscriber stops reading and
|
||||
// overflows its own tiny channel; it is disconnected with an EventQueueOverflow fault
|
||||
// while a second, healthy subscriber keeps receiving and the pump keeps pumping.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int overflowCalls = 0;
|
||||
// Separate fields for the bool value and the "set" flag so both can use
|
||||
@@ -380,7 +420,7 @@ public sealed class SessionEventDistributorTests
|
||||
singleSubscriberMode: false);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Slow subscriber: registered but never read, so its capacity-2 channel fills.
|
||||
// Slow subscriber: registered but never read, so its channel (capacity 2) fills.
|
||||
using IEventSubscriberLease slow = distributor.Register();
|
||||
// Healthy subscriber: drains promptly throughout.
|
||||
using IEventSubscriberLease healthy = distributor.Register();
|
||||
@@ -399,7 +439,7 @@ public sealed class SessionEventDistributorTests
|
||||
async () => await DrainUntilFaultAsync(slow.Reader));
|
||||
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, fault.ErrorCode);
|
||||
|
||||
// Multi-subscriber mode, so isOnlySubscriber is always false (Task 8 mode-gating).
|
||||
// Multi-subscriber mode, so isOnlySubscriber is always false (mode-gating).
|
||||
// Use Interlocked.Read / Volatile.Read so the test-thread reads are ordered after the
|
||||
// pump-thread writes, avoiding a data race by the C# memory model.
|
||||
Assert.Equal(1, Volatile.Read(ref overflowCalls));
|
||||
@@ -413,16 +453,19 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(11ul, afterOverflow.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributor-level pin for "FailFast with multiple subscribers degrades to
|
||||
/// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
|
||||
/// always false (mode-gating), so a FailFast-wired handler must NOT fault the session.
|
||||
/// This test drives the distributor directly (without <c>GatewaySession</c>) in
|
||||
/// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
|
||||
/// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
|
||||
/// subscriber keeps receiving, and (c) the pump keeps running.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SlowSubscriberOverflow_WithMultipleSubscribers_HandlerSeesIsOnlySubscriberFalse_OtherKeepsReceiving()
|
||||
{
|
||||
// Distributor-level pin for "FailFast with multiple subscribers degrades to
|
||||
// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
|
||||
// always false (Task 8 mode-gating), so a FailFast-wired handler must NOT fault the
|
||||
// session. This test drives the distributor directly (without GatewaySession) in
|
||||
// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
|
||||
// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
|
||||
// subscriber keeps receiving, and (c) the pump keeps running.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
bool handlerFiredWithFalse = false;
|
||||
bool sessionFaultWouldBeCalled = false; // tracks if a FailFast path would fault
|
||||
@@ -450,7 +493,7 @@ public sealed class SessionEventDistributorTests
|
||||
singleSubscriberMode: false);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Slow subscriber: never reads, so capacity-2 channel overflows quickly.
|
||||
// Slow subscriber: never reads, so its channel (capacity 2) overflows quickly.
|
||||
using IEventSubscriberLease slow = distributor.Register();
|
||||
// Healthy subscriber: drains every event promptly.
|
||||
using IEventSubscriberLease healthy = distributor.Register();
|
||||
@@ -478,14 +521,17 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(11ul, afterOverflow.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>CountExternalSubscribers()</c> excludes the internal dashboard
|
||||
/// subscriber, so a FailFast policy would NOT fault the session even when the internal
|
||||
/// subscriber is the ONLY registered subscriber. The overflow handler receives
|
||||
/// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
|
||||
/// and is therefore excluded from the external-subscriber count.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse_ProvingCountExcludesInternal()
|
||||
{
|
||||
// Issue 3: verifies that CountExternalSubscribers() excludes the internal dashboard
|
||||
// subscriber, so a FailFast policy would NOT fault the session even when the internal
|
||||
// subscriber is the ONLY registered subscriber. The overflow handler receives
|
||||
// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
|
||||
// and is therefore excluded from the external-subscriber count.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int observedIsOnlySubscriberSet = 0;
|
||||
bool observedIsOnlySubscriberValue = false;
|
||||
@@ -509,7 +555,7 @@ public sealed class SessionEventDistributorTests
|
||||
// Register ONLY an internal subscriber — no external subscriber is attached.
|
||||
using IEventSubscriberLease internalLease = distributor.Register(isInternal: true);
|
||||
|
||||
// Push enough events to overflow the capacity-2 internal subscriber channel.
|
||||
// Push enough events to overflow the internal subscriber channel (capacity 2).
|
||||
for (ulong sequence = 1; sequence <= 10; sequence++)
|
||||
{
|
||||
source.Writer.TryWrite(Event(sequence));
|
||||
@@ -540,13 +586,16 @@ public sealed class SessionEventDistributorTests
|
||||
"isInternal must be true for a subscriber registered with isInternal: true.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode-gating: in single-subscriber mode a lone external subscriber that overflows
|
||||
/// reports isOnlySubscriber==true, so the legacy FailFast session-fault path is
|
||||
/// preserved. The decision is gated on the fixed session mode, NOT a live count, so it
|
||||
/// is race-free.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SingleSubscriberMode_LoneExternalOverflow_HandlerSeesIsOnlySubscriberTrue()
|
||||
{
|
||||
// Task 8 mode-gating: in single-subscriber mode a lone external subscriber that
|
||||
// overflows reports isOnlySubscriber==true, so the legacy FailFast session-fault path
|
||||
// is preserved. The decision is gated on the fixed session mode, NOT a live count, so
|
||||
// it is race-free.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int observedSet = 0;
|
||||
bool observedValue = false;
|
||||
@@ -593,6 +642,11 @@ public sealed class SessionEventDistributorTests
|
||||
"isOnlySubscriber must be true for a lone external subscriber in single-subscriber mode.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registering with replay from a sequence still inside the retained window returns the
|
||||
/// newer retained events with no gap, then continues to deliver live events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_WithinRetainedWindow_ReturnsNewerEvents_NoGap_ThenLive()
|
||||
{
|
||||
@@ -631,6 +685,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(6ul, live.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Registering with replay from a sequence below the oldest retained event reports a gap along with the oldest available sequence.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_BelowOldestRetained_ReportsGap_AndOldestAvailable()
|
||||
{
|
||||
@@ -662,6 +718,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(5ul, liveResume);
|
||||
}
|
||||
|
||||
/// <summary>When nothing retained is newer than the requested sequence, the live resume watermark equals the requested sequence and no gap is reported.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_NothingRetainedNewer_LiveResumeEqualsAfterSequence_NoGap()
|
||||
{
|
||||
@@ -733,6 +791,7 @@ public sealed class SessionEventDistributorTests
|
||||
/// <c>GatewaySessionDashboardMirrorTests</c>, where a gRPC subscriber attached after a
|
||||
/// fast-completing worker stream had already drained.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Register_AfterSourceCompletes_CompletesLateSubscriberInsteadOfHanging()
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Tests-013: per-method gateway-side coverage for every
|
||||
/// Per-method gateway-side coverage for every
|
||||
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
|
||||
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
|
||||
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
|
||||
@@ -21,6 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class SessionManagerBulkTests
|
||||
{
|
||||
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -50,6 +51,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -90,6 +93,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -103,6 +107,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -128,6 +133,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -141,6 +147,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -167,6 +174,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -180,6 +188,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -208,6 +217,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -221,6 +231,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -246,6 +257,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -259,6 +271,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -292,6 +305,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -308,6 +322,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
|
||||
{
|
||||
@@ -351,6 +366,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -376,6 +392,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
|
||||
{
|
||||
@@ -427,6 +444,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -452,7 +470,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-022: Pin mid-flight cancellation behaviour for at least one bulk
|
||||
/// Pin mid-flight cancellation behaviour for at least one bulk
|
||||
/// path. Unlike the pre-cancel <c>WriteSecuredBulkAsync_PropagatesCancellation</c>
|
||||
/// above, this fake's <see cref="MidFlightBulkWorkerClient.InvokeAsync"/>
|
||||
/// returns a <see cref="TaskCompletionSource"/>-backed task that does NOT
|
||||
@@ -463,6 +481,7 @@ public sealed class SessionManagerBulkTests
|
||||
/// client closing its stream would hit, which the pre-cancel pattern can't
|
||||
/// exercise.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_WhenCancelledMidFlight_ThrowsOperationCanceledForRequestToken()
|
||||
{
|
||||
@@ -499,6 +518,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
|
||||
{
|
||||
@@ -547,6 +567,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -573,6 +594,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -619,6 +641,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -779,12 +802,13 @@ public sealed class SessionManagerBulkTests
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason) => State = WorkerClientState.Faulted;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the fake worker client; this fake has no resources to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mid-flight cancellation fake for Tests-022.
|
||||
/// Mid-flight cancellation fake worker client.
|
||||
/// <see cref="InvokeAsync"/> signals <see cref="InvokeStarted"/>, registers
|
||||
/// a cancellation continuation on the caller's <see cref="CancellationToken"/>,
|
||||
/// and parks on a <see cref="TaskCompletionSource{TResult}"/> that completes
|
||||
@@ -858,7 +882,8 @@ public sealed class SessionManagerBulkTests
|
||||
_invokeCompletion.TrySetCanceled();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the fake worker client, cancelling any in-flight invoke.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_invokeCompletion.TrySetCanceled();
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class SessionManagerTests
|
||||
{
|
||||
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a session opened by an authenticated caller records that caller's API key id in OwnerKeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithOwnerKeyId_RecordsOwnerKeyIdOnSession()
|
||||
{
|
||||
@@ -52,6 +54,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a session opened without an owner key id records null in OwnerKeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithNullOwnerKeyId_RecordsNullOwnerKeyIdOnSession()
|
||||
{
|
||||
@@ -67,6 +70,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_SetsInitialDefaultLease()
|
||||
{
|
||||
@@ -83,6 +87,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that session generation creates client correlation ID from client name and session ID.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
|
||||
{
|
||||
@@ -99,6 +104,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
|
||||
{
|
||||
@@ -114,6 +120,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
|
||||
{
|
||||
@@ -131,6 +138,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a ready session refreshes its lease expiry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
|
||||
{
|
||||
@@ -159,6 +167,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -204,6 +213,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session write bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -269,6 +279,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session read bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -319,6 +330,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
|
||||
{
|
||||
@@ -338,12 +350,13 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-030 regression: when the gateway-side <c>SessionState</c> is
|
||||
/// When the gateway-side <c>SessionState</c> is
|
||||
/// <c>Ready</c> but the worker client's own state is not, the diagnostic
|
||||
/// must surface both states so the mismatch is actionable instead of
|
||||
/// producing a self-contradictory "Session ... is not ready. Current
|
||||
/// state is Ready." message.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates()
|
||||
{
|
||||
@@ -372,6 +385,7 @@ public sealed class SessionManagerTests
|
||||
/// <c>Handshaking</c> but flips to <c>Ready</c> within the timeout window must let the
|
||||
/// command through rather than fail fast.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds()
|
||||
{
|
||||
@@ -402,6 +416,7 @@ public sealed class SessionManagerTests
|
||||
/// A terminal worker state (<c>Faulted</c>) must fail fast even with a positive
|
||||
/// worker-ready wait timeout, surfacing both states without burning the timeout.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerFaulted_FailsFastWithBothStates()
|
||||
{
|
||||
@@ -431,6 +446,7 @@ public sealed class SessionManagerTests
|
||||
/// When the worker stays transiently not-ready for the whole (small) timeout window,
|
||||
/// the invoke fails after roughly the timeout with both states surfaced.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenTimeoutElapsesStillNotReady_FailsWithBothStates()
|
||||
{
|
||||
@@ -460,6 +476,7 @@ public sealed class SessionManagerTests
|
||||
/// Pins the default (timeout == 0) behavior: a transiently <c>Handshaking</c> worker
|
||||
/// fails fast immediately, byte-for-byte like the original fail-fast path.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenTimeoutZero_FailsFastUnchanged()
|
||||
{
|
||||
@@ -486,6 +503,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing a session removes it from the registry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_RemovesClosedSession()
|
||||
{
|
||||
@@ -507,6 +525,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
|
||||
{
|
||||
@@ -528,6 +547,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
|
||||
{
|
||||
@@ -567,15 +587,13 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(1, failingWorkerClient.KillCount);
|
||||
Assert.Equal(1, failingWorkerClient.DisposeCount);
|
||||
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
|
||||
// Server-046: a close-that-failed now accounts as SessionClosed (counter += 1) rather
|
||||
// than SessionRemoved (gauge -= 1, counter unchanged). The session is being removed
|
||||
// from the registry on this path, so it must show up in the closed count.
|
||||
Assert.Equal(1, snapshot.SessionsClosed);
|
||||
Assert.False(snapshot.EventsBySession.ContainsKey(firstSession.SessionId));
|
||||
Assert.Equal(1, snapshot.OpenSessions);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
|
||||
{
|
||||
@@ -626,10 +644,8 @@ public sealed class SessionManagerTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that killing a worker removes the session from the registry without calling shutdown.
|
||||
/// Tests-028: also pins the <c>reason</c> argument propagating through
|
||||
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
|
||||
/// <c>IWorkerClient.Kill(reason)</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_KillsWorkerAndRemovesSession()
|
||||
{
|
||||
@@ -651,11 +667,12 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-028: <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
|
||||
/// <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
|
||||
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/>. A blank or whitespace reason must throw
|
||||
/// <see cref="ArgumentException"/> before any session lookup or worker call runs.
|
||||
/// </summary>
|
||||
/// <param name="blankReason">A blank or whitespace reason string.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -674,10 +691,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-028: <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
|
||||
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
|
||||
/// <see cref="Theory"/> with <see cref="InlineDataAttribute"/> cannot carry <c>null</c> for a
|
||||
/// non-nullable string parameter on .NET 10, so the null case is its own fact.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WithNullReason_ThrowsArgumentNullException()
|
||||
{
|
||||
@@ -693,6 +711,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that killing the worker for an unknown session raises SessionNotFound.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound()
|
||||
{
|
||||
@@ -705,10 +724,10 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-044: when <c>session.KillWorker</c> throws, the catch path must still
|
||||
/// decrement <c>mxgateway.sessions.open</c> (parity with the Server-006 fix in
|
||||
/// <c>OpenSessionAsync</c>). Without the fix the gauge leaks one open session per failed kill.
|
||||
/// When <c>session.KillWorker</c> throws, the catch path must still
|
||||
/// decrement <c>mxgateway.sessions.open</c>. Without the fix the gauge leaks one open session per failed kill.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenSessionKillThrows_DecrementsOpenSessionGauge()
|
||||
{
|
||||
@@ -738,10 +757,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-045 / Server-048: concurrent kills on the same session must not
|
||||
/// Concurrent kills on the same session must not
|
||||
/// double-increment <c>mxgateway.sessions.closed</c>. The first kill wins, the second
|
||||
/// observes <c>wasClosed == true</c> (or a missing session after removal) and short-circuits.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_ConcurrentCallsOnSameSession_CountClosedExactlyOnce()
|
||||
{
|
||||
@@ -777,12 +797,13 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-046: <c>ShutdownAsync</c>'s graceful-close fallback (which calls
|
||||
/// <c>ShutdownAsync</c>'s graceful-close fallback (which calls
|
||||
/// <c>KillWorker</c> + <c>RemoveSessionAsync</c> when <c>CloseSessionCoreAsync</c> throws)
|
||||
/// must still account a successful close: both the open-session gauge must drop to zero AND
|
||||
/// the <c>mxgateway.sessions.closed</c> counter must increment. Without the fix, the
|
||||
/// graceful-close failure path under-counts the closed counter.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_WhenSessionCloseThrows_StillDecrementsOpenSessionGaugeAndIncrementsClosedCounter()
|
||||
{
|
||||
@@ -812,6 +833,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||
{
|
||||
@@ -832,6 +854,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
|
||||
{
|
||||
@@ -855,6 +878,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
|
||||
{
|
||||
@@ -873,10 +897,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With detach-grace enabled, a session whose last external subscriber dropped
|
||||
/// With detach-grace enabled, a session whose last external subscriber dropped
|
||||
/// and whose detach-grace window has elapsed is closed by the lease sweep exactly like an
|
||||
/// expired-lease session — even though its normal lease is still far in the future.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_ClosesSessionWhoseDetachGraceWindowExpired()
|
||||
{
|
||||
@@ -909,11 +934,12 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. TOCTOU race: a session whose detach-grace window has expired but that
|
||||
/// TOCTOU race: a session whose detach-grace window has expired but that
|
||||
/// reattaches an external subscriber before the sweeper calls CloseSessionCoreAsync is
|
||||
/// NOT closed — it remains Ready and usable. This validates that TryBeginCloseIfExpired
|
||||
/// re-checks eligibility atomically so a reconnect that wins the race cancels the close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses()
|
||||
{
|
||||
@@ -950,6 +976,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
|
||||
{
|
||||
@@ -1097,16 +1124,16 @@ public sealed class SessionManagerTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the session ID for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; init; } = "session-1";
|
||||
|
||||
/// <summary>Gets the process ID for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; init; } = 1234;
|
||||
|
||||
/// <summary>Gets or sets the state of the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the number of times invoke was called on the fake worker client.</summary>
|
||||
@@ -1118,13 +1145,7 @@ public sealed class SessionManagerTests
|
||||
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last reason argument observed by <see cref="Kill"/>. Tests-028:
|
||||
/// pins the reason-string propagation through
|
||||
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
|
||||
/// <c>IWorkerClient.Kill(reason)</c>. Without this, the chain could silently
|
||||
/// drop or substitute the reason argument and existing tests would still pass.
|
||||
/// </summary>
|
||||
/// <summary>Gets the last reason argument observed by <see cref="Kill"/>.</summary>
|
||||
public string? LastKillReason { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
|
||||
@@ -1222,7 +1243,8 @@ public sealed class SessionManagerTests
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records that the fake worker client was disposed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
@@ -1230,6 +1252,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForShutdownStartAsync()
|
||||
{
|
||||
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
+5
@@ -21,6 +21,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
/// Awaits every scripted worker task so an unhandled exception fails the owning test
|
||||
/// instead of surfacing later as an unobserved <see cref="TaskScheduler.UnobservedTaskException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IWorkerTaskLauncher launcher in _launchers)
|
||||
@@ -30,6 +31,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
|
||||
{
|
||||
@@ -83,6 +86,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
|
||||
{
|
||||
@@ -168,6 +172,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
/// teardown faults expected when the worker client kills or disposes the worker.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for the worker task.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ObserveWorkerTaskAsync(TimeSpan timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class SparseArrayExpanderTests
|
||||
return new MxValue { SparseArrayValue = sparse };
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding an Int32 sparse array fills unset indices with the default and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Int32_FillsDefaultsAndSetsElement()
|
||||
{
|
||||
@@ -43,6 +44,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Boolean sparse array with no elements fills every index with the default value <c>false</c>.</summary>
|
||||
[Fact]
|
||||
public void Expand_Boolean_EmptyElements_AllDefaultFalse()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { false, false, false }, value.ArrayValue.BoolValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a sparse array with a zero total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_ZeroTotalLength_Throws()
|
||||
{
|
||||
@@ -63,6 +66,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an element index at or beyond the total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_IndexOutOfRange_Throws()
|
||||
{
|
||||
@@ -75,6 +79,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that two sparse elements sharing the same index throw an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_DuplicateIndex_Throws()
|
||||
{
|
||||
@@ -88,6 +93,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unspecified element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_UnsupportedElementType_Throws()
|
||||
{
|
||||
@@ -97,6 +103,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an element value whose kind does not match the declared element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_ElementValueKindMismatch_Throws()
|
||||
{
|
||||
@@ -109,6 +116,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a String sparse array fills unset indices with an empty string default.</summary>
|
||||
[Fact]
|
||||
public void Expand_String_FillsEmptyStringDefault()
|
||||
{
|
||||
@@ -123,6 +131,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { "a", string.Empty }, value.ArrayValue.StringValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Time sparse array fills unset indices with the epoch timestamp default.</summary>
|
||||
[Fact]
|
||||
public void Expand_Time_FillsEpochDefault()
|
||||
{
|
||||
@@ -140,6 +149,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(5, value.ArrayValue.TimestampValues.Values[1].Seconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Double sparse array fills defaults and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Double_HappyPath()
|
||||
{
|
||||
@@ -154,6 +164,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0d, 0d, 1.5 }, value.ArrayValue.DoubleValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Float sparse array fills defaults and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Float_HappyPath()
|
||||
{
|
||||
@@ -168,6 +179,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 2.5f, 0f }, value.ArrayValue.FloatValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Integer sparse array whose element is a 64-bit value expands to the Int64 array representation.</summary>
|
||||
[Fact]
|
||||
public void Expand_Int64_WhenElementIsInt64()
|
||||
{
|
||||
@@ -182,6 +194,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0L, 0L, 9_000_000_000L }, value.ArrayValue.Int64Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a non-sparse-array value is a no-op, leaving the original value unchanged.</summary>
|
||||
[Fact]
|
||||
public void Expand_NonSparseValue_NoOps()
|
||||
{
|
||||
@@ -193,12 +206,14 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(42, value.Int32Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a null value throws <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_NullValue_ThrowsArgumentNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => SparseArrayExpander.Expand(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a total length exceeding the maximum supported array length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_TotalLengthExceedsMaxArrayLength_Throws()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Verifies that completing startup with hello and ready transitions the client to ready state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a protocol version mismatch during startup fails the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithProtocolMismatch_FailsStartup()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scripted reply completes a pending command invocation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithScriptedReply_CompletesCommand()
|
||||
{
|
||||
@@ -69,6 +72,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scripted events are yielded in order through the event stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents()
|
||||
{
|
||||
@@ -93,6 +97,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scripted fault from the worker faults the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WithScriptedFault_FaultsClient()
|
||||
{
|
||||
@@ -116,6 +121,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// <see cref="ManualTimeProvider"/> so the timestamp advance is deterministic rather
|
||||
/// than relying on a wall-clock <c>Task.Delay</c> exceeding clock resolution.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
|
||||
{
|
||||
@@ -138,6 +144,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a hung worker times out pending command invocations.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
|
||||
{
|
||||
@@ -159,6 +166,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed frame in the read loop faults the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WithMalformedFrame_FaultsClient()
|
||||
{
|
||||
@@ -176,6 +184,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a shutdown acknowledgment from the worker closes the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_WithShutdownAck_ClosesClient()
|
||||
{
|
||||
@@ -196,6 +205,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// Verifies that RespondToControlCommandAsync echoes the Ping message back
|
||||
/// in the DiagnosticMessage field, matching the real worker's ping reply shape.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RespondToControlCommandAsync_Ping_EchoesMessageInDiagnostic()
|
||||
{
|
||||
@@ -220,6 +230,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// Verifies that RespondToControlCommandAsync returns a SessionStateReply
|
||||
/// with state Ready for a GetSessionState command.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RespondToControlCommandAsync_GetSessionState_ReturnsReadyState()
|
||||
{
|
||||
@@ -245,6 +256,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// Verifies that RespondToControlCommandAsync returns a WorkerInfoReply
|
||||
/// with the fake worker's process ID, version, and MXAccess identifiers.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RespondToControlCommandAsync_GetWorkerInfo_ReturnsFakeWorkerInfo()
|
||||
{
|
||||
@@ -273,6 +285,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// Verifies that RespondToControlCommandAsync returns an empty DrainEventsReply
|
||||
/// for a DrainEvents command (the fake harness has no queued events).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RespondToControlCommandAsync_DrainEvents_ReturnsEmptyReply()
|
||||
{
|
||||
@@ -298,6 +311,7 @@ public sealed class FakeWorkerHarnessTests
|
||||
/// Verifies that RespondToControlCommandAsync for ShutdownWorker sends an OK
|
||||
/// reply followed by a WorkerShutdownAck, which closes the client.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RespondToControlCommandAsync_ShutdownWorker_SendsReplyThenAck()
|
||||
{
|
||||
|
||||
@@ -52,6 +52,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="protocolVersion">Protocol version for frame communication.</param>
|
||||
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>The connected fake worker harness.</returns>
|
||||
public static async Task<FakeWorkerHarness> CreateConnectedPairAsync(
|
||||
string sessionId = DefaultSessionId,
|
||||
string nonce = DefaultNonce,
|
||||
@@ -87,6 +88,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="protocolVersion">Protocol version for frame communication.</param>
|
||||
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>The connected fake worker harness.</returns>
|
||||
public static async Task<FakeWorkerHarness> ConnectToGatewayPipeAsync(
|
||||
string sessionId,
|
||||
string nonce,
|
||||
@@ -205,6 +207,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="workerProtocolVersion">Protocol version override.</param>
|
||||
/// <param name="nonce">Nonce override.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SendWorkerHelloAsync(
|
||||
int workerProcessId = DefaultWorkerProcessId,
|
||||
string workerVersion = "fake-worker",
|
||||
@@ -230,6 +233,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
|
||||
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SendWorkerReadyAsync(
|
||||
int workerProcessId = DefaultWorkerProcessId,
|
||||
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
|
||||
@@ -255,6 +259,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="statusMessage">Human-readable status message.</param>
|
||||
/// <param name="configureReply">Optional callback to customize the reply.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task ReplyToCommandAsync(
|
||||
WorkerEnvelope commandEnvelope,
|
||||
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
||||
@@ -296,6 +301,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="family">Family of the event to emit.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <param name="configureEvent">Optional callback to customize the event.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task EmitEventAsync(
|
||||
MxEventFamily family,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -325,6 +331,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="category">Category of the fault.</param>
|
||||
/// <param name="diagnosticMessage">Diagnostic message describing the fault.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task EmitFaultAsync(
|
||||
WorkerFaultCategory category,
|
||||
string diagnosticMessage,
|
||||
@@ -350,6 +357,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <param name="state">Current worker state.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <param name="configureHeartbeat">Optional callback to customize the heartbeat.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SendHeartbeatAsync(
|
||||
WorkerState state = WorkerState.Ready,
|
||||
CancellationToken cancellationToken = default,
|
||||
@@ -373,6 +381,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <summary>Sends a shutdown acknowledgment message to the gateway.</summary>
|
||||
/// <param name="statusCode">Protocol status code for the acknowledgment.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SendShutdownAckAsync(
|
||||
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -506,6 +515,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <summary>Writes a malformed payload directly to the worker stream.</summary>
|
||||
/// <param name="payload">Malformed payload bytes to write.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteMalformedPayloadAsync(
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -524,6 +534,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
/// <summary>Writes an oversized frame header to the worker stream for testing frame size limits.</summary>
|
||||
/// <param name="payloadLength">Length of the oversized payload in bytes.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteOversizedFrameHeaderAsync(
|
||||
uint payloadLength,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -542,6 +553,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Disposes the worker-side stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeWorkerSideAsync()
|
||||
{
|
||||
if (_workerSideDisposed)
|
||||
@@ -553,7 +565,8 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
_workerSideDisposed = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the worker-side and (if owned) gateway-side pipe streams.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeWorkerSideAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Server-002 regression: per <c>gateway.md</c> the gateway must terminate
|
||||
/// Per <c>gateway.md</c> the gateway must terminate
|
||||
/// orphaned worker processes on startup. These tests pin that the terminator
|
||||
/// kills leftover workers (matched by executable path, or by image name when
|
||||
/// the path is unreadable) without touching unrelated processes or itself.
|
||||
@@ -129,12 +129,10 @@ public sealed class OrphanWorkerTerminatorTests
|
||||
/// <summary>Gets or sets the process ID that should throw when killed.</summary>
|
||||
public int? ThrowOnKillProcessId { get; init; }
|
||||
|
||||
/// <summary>Gets the list of running processes by name.</summary>
|
||||
/// <param name="processName">The process name to search for.</param>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<RunningProcessInfo> GetProcessesByName(string processName) => processes;
|
||||
|
||||
/// <summary>Kills the specified process or records the kill attempt.</summary>
|
||||
/// <param name="processId">The process identifier to kill.</param>
|
||||
/// <inheritdoc />
|
||||
public void Kill(int processId)
|
||||
{
|
||||
if (ThrowOnKillProcessId == processId)
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class WorkerClientTests
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Verifies that StartAsync enters ready state after receiving worker hello and ready messages.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState()
|
||||
{
|
||||
@@ -29,6 +30,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InvokeAsync completes a pending command when a matching reply arrives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand()
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InvokeAsync ignores late replies and keeps the client ready.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady()
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadEventsAsync yields events in pipe order from the worker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder()
|
||||
{
|
||||
@@ -119,6 +123,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read loop faults the client when the event queue overflows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient()
|
||||
{
|
||||
@@ -154,6 +159,7 @@ public sealed class WorkerClientTests
|
||||
/// <c>Faulted</c> state before it calls <c>KillOwnedProcess</c>, so a state-based
|
||||
/// wait can observe <c>Faulted</c> while <c>KillCount</c> is still 0.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess()
|
||||
{
|
||||
@@ -192,6 +198,7 @@ public sealed class WorkerClientTests
|
||||
/// invoke task with a <see cref="WorkerClientException"/> carrying the
|
||||
/// pipe-disconnected error code rather than hanging until the command timeout.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenPipeDisconnectsMidCommand_FailsPendingInvokeWithPipeDisconnected()
|
||||
{
|
||||
@@ -223,6 +230,7 @@ public sealed class WorkerClientTests
|
||||
/// task with a <see cref="WorkerClientException"/> carrying the worker-faulted
|
||||
/// error code.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerFaultsMidCommand_FailsPendingInvokeWithWorkerFaulted()
|
||||
{
|
||||
@@ -248,6 +256,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pipe disconnect faults the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
|
||||
{
|
||||
@@ -265,6 +274,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read loop stops the running worker metric when the pipe disconnects.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric()
|
||||
{
|
||||
@@ -288,6 +298,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout()
|
||||
{
|
||||
@@ -304,7 +315,8 @@ public sealed class WorkerClientTests
|
||||
$"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
|
||||
/// <summary>Verifies that DisposeAsync kills the still-running owned worker process before disposing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhenOwnedWorkerStillRuns_KillsProcessBeforeDisposing()
|
||||
{
|
||||
@@ -325,6 +337,7 @@ public sealed class WorkerClientTests
|
||||
/// deterministic instead of relying on a wall-clock <c>Task.Delay</c> exceeding
|
||||
/// <see cref="DateTimeOffset.UtcNow"/> resolution.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
|
||||
{
|
||||
@@ -352,6 +365,7 @@ public sealed class WorkerClientTests
|
||||
/// <see cref="WorkerClientOptions.HeartbeatCheckInterval"/> timer stays on the real clock and
|
||||
/// observes the manually-advanced grace on its next tick.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
|
||||
{
|
||||
@@ -378,13 +392,14 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-031 regression: while a command is in flight on the
|
||||
/// While a command is in flight on the
|
||||
/// gateway↔worker pipe and the oldest pending command is younger
|
||||
/// than <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/>, the
|
||||
/// heartbeat watchdog must NOT fault on heartbeat-expired alone — the
|
||||
/// gap is more likely caused by pipe-write contention than by a hung
|
||||
/// worker. Mirrors Worker-023 on the worker side.
|
||||
/// worker.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HeartbeatMonitor_WhenCommandInFlightWithinCeiling_DoesNotFaultOnExpiredHeartbeat()
|
||||
{
|
||||
@@ -422,11 +437,12 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-031 regression: once the oldest pending command exceeds
|
||||
/// Once the oldest pending command exceeds
|
||||
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/>, the
|
||||
/// heartbeat watchdog fires anyway — a truly stuck COM call shouldn't
|
||||
/// keep the watchdog suppressed indefinitely.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HeartbeatMonitor_WhenPendingCommandExceedsStuckCeiling_FaultsClient()
|
||||
{
|
||||
@@ -463,7 +479,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-032 regression: a transient burst that exceeds
|
||||
/// A transient burst that exceeds
|
||||
/// <see cref="WorkerClientOptions.EventChannelCapacity"/> must be
|
||||
/// absorbed for up to <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
|
||||
/// (the channel is configured for <c>BoundedChannelFullMode.Wait</c>);
|
||||
@@ -471,6 +487,7 @@ public sealed class WorkerClientTests
|
||||
/// and the diagnostic must name the channel capacity, depth, and
|
||||
/// actionable remediation.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic()
|
||||
{
|
||||
@@ -722,6 +739,7 @@ public sealed class WorkerClientTests
|
||||
public WorkerFrameWriter WorkerWriter { get; }
|
||||
|
||||
/// <summary>Creates a connected pipe pair for testing.</summary>
|
||||
/// <returns>The connected <see cref="PipePair"/>.</returns>
|
||||
public static async Task<PipePair> CreateAsync()
|
||||
{
|
||||
string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}";
|
||||
@@ -745,6 +763,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Disposes the worker side of the pipe.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeWorkerSideAsync()
|
||||
{
|
||||
if (_workerSideDisposed)
|
||||
@@ -757,6 +776,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Disposes the duplex stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeWorkerSideAsync();
|
||||
|
||||
@@ -5,8 +5,8 @@ using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="WorkerExecutableValidator"/> PE-header architecture parsing
|
||||
/// (finding Server-013). The validator reads the DOS <c>MZ</c> stub, follows the PE
|
||||
/// Coverage for <see cref="WorkerExecutableValidator"/> PE-header architecture parsing.
|
||||
/// The validator reads the DOS <c>MZ</c> stub, follows the PE
|
||||
/// header offset at <c>0x3c</c>, checks the <c>PE\0\0</c> signature, and compares the
|
||||
/// machine field against the required <see cref="WorkerArchitecture"/>.
|
||||
/// </summary>
|
||||
@@ -129,7 +129,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Deletes the temporary PE fixture files created by this test class, best-effort.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string path in _tempFiles)
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
private const string SessionId = "session-1";
|
||||
|
||||
/// <summary>Verifies that writing and reading a valid envelope round-trips the frame correctly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame()
|
||||
{
|
||||
@@ -29,6 +30,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with partial reads reassembles the frame correctly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithPartialReads_ReassemblesFrame()
|
||||
{
|
||||
@@ -45,6 +47,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with zero length throws a malformed length exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithZeroLengthFrame_ThrowsMalformedLength()
|
||||
{
|
||||
@@ -60,6 +63,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with oversized length throws before allocating the payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithOversizedLength_ThrowsBeforePayloadAllocation()
|
||||
{
|
||||
@@ -77,6 +81,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with wrong protocol version throws a protocol version mismatch exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch()
|
||||
{
|
||||
@@ -94,6 +99,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with wrong session ID throws a session mismatch exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch()
|
||||
{
|
||||
@@ -111,6 +117,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with malformed payload throws an invalid envelope exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
|
||||
{
|
||||
@@ -127,6 +134,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a frame with missing envelope body throws an invalid envelope exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMissingEnvelopeBody_ThrowsInvalidEnvelope()
|
||||
{
|
||||
@@ -144,6 +152,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing an oversized envelope throws a message too large exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithOversizedEnvelope_ThrowsMessageTooLarge()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
private const string Nonce = "super-secret-nonce";
|
||||
|
||||
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WithValidWorker_StartsProcessWithBootstrapArgumentsAndNonceEnvironment()
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed startup probe kills and disposes the worker process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenStartupProbeFails_KillsAndDisposesWorker()
|
||||
{
|
||||
@@ -76,6 +78,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker()
|
||||
{
|
||||
@@ -103,6 +106,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenStartupTimesOut_KillsAndDisposesWorker()
|
||||
{
|
||||
@@ -129,6 +133,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing worker executable fails before attempting to start the process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenExecutableDoesNotExist_FailsBeforeStartingProcess()
|
||||
{
|
||||
@@ -146,6 +151,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a worker executable with mismatched architecture fails before attempting to start.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenExecutableArchitectureDoesNotMatch_FailsBeforeStartingProcess()
|
||||
{
|
||||
@@ -163,6 +169,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a worker that has already exited fails and disposes without additional killing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WhenWorkerAlreadyExited_FailsAndDisposesWorkerWithoutKill()
|
||||
{
|
||||
@@ -308,7 +315,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
|
||||
public bool DisposeCalled { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records that the pipe reservation was released.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalled = true;
|
||||
@@ -327,6 +334,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>Creates a new temporary directory for testing.</summary>
|
||||
/// <returns>The created <see cref="TestDirectory"/> wrapper.</returns>
|
||||
public static TestDirectory Create()
|
||||
{
|
||||
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"mxgateway-tests-{Guid.NewGuid():N}");
|
||||
@@ -355,7 +363,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Deletes the temporary test directory and its contents.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class GatewayMetricsTests
|
||||
/// Verifies that <see cref="GatewayMetrics.AlarmProviderSwitched"/> increments
|
||||
/// <c>mxgateway.alarms.provider_switches</c> by one with the expected from/to/reason tags.
|
||||
/// The listener filters by the specific <see cref="System.Diagnostics.Metrics.Meter"/> instance
|
||||
/// to avoid cross-talk between parallel tests (Tests-027).
|
||||
/// to avoid cross-talk between parallel tests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmProviderSwitched_IncrementsCounterWithExpectedTags()
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the Task 2.3 canonical audit plumbing: the gateway-owned
|
||||
/// Tests the canonical audit plumbing: the gateway-owned
|
||||
/// <see cref="SqliteCanonicalAuditStore"/> (round-trips canonical
|
||||
/// <see cref="AuditEvent"/>s through the new <c>audit_event</c> table), the best-effort
|
||||
/// <see cref="CanonicalAuditWriter"/>, and the <see cref="CanonicalForwardingApiKeyAuditStore"/>
|
||||
@@ -21,6 +21,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
|
||||
/// <summary>A canonical event with all fields populated round-trips through the store.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Store_InsertThenListRecent_RoundTripsAllFields()
|
||||
{
|
||||
@@ -61,6 +62,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Nullable canonical fields round-trip as null, not empty string.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Store_InsertWithNullOptionalFields_RoundTripsAsNull()
|
||||
{
|
||||
@@ -86,6 +88,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>ListRecent returns newest-first.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Store_ListRecent_ReturnsNewestFirst()
|
||||
{
|
||||
@@ -100,6 +103,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>The writer is best-effort: a faulting store does not surface to the caller.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Writer_WhenStoreFails_DoesNotThrow()
|
||||
{
|
||||
@@ -123,6 +127,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
/// A library event with a KeyId maps to a canonical Success event under category ApiKey,
|
||||
/// and the adapter maps it back to the original entry for the dashboard view.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Adapter_KeyedEvent_RoundTripsThroughCanonicalStore()
|
||||
{
|
||||
@@ -158,6 +163,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>The keyless library <c>init-db</c> event maps to Actor "system" and back to a null KeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Adapter_InitDbKeylessEvent_MapsToSystemActor()
|
||||
{
|
||||
@@ -184,6 +190,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Any other keyless library event maps to Actor "cli" and back to a null KeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Adapter_OtherKeylessEvent_MapsToCliActor()
|
||||
{
|
||||
@@ -207,6 +214,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>A <c>constraint-denied</c> library event maps to Outcome.Denied.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Adapter_ConstraintDeniedEvent_MapsToDeniedOutcome()
|
||||
{
|
||||
@@ -234,6 +242,7 @@ public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
/// The adapter does NOT throw when the underlying write fails (it forwards through the
|
||||
/// best-effort writer), preserving the IApiKeyAuditStore caller's flow.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Adapter_WhenWriterFails_DoesNotThrow()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
{
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
/// <summary>Verifies that CreateKeyAsync creates an authenticating key and audits the action.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits()
|
||||
{
|
||||
@@ -52,6 +53,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ListKeysAsync does not print the raw secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ListKeysAsync_DoesNotPrintRawSecret()
|
||||
{
|
||||
@@ -82,6 +84,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits()
|
||||
{
|
||||
@@ -117,6 +120,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret()
|
||||
{
|
||||
@@ -154,6 +158,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CreateKeyAsync prints the raw secret exactly once.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce()
|
||||
{
|
||||
@@ -182,6 +187,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that API key constraints are persisted correctly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
|
||||
{
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-004 regression: a create-key command with a non-canonical scope
|
||||
/// A create-key command with a non-canonical scope
|
||||
/// string (e.g. CLAUDE.md's stale <c>invoke</c> instead of <c>invoke:read</c>)
|
||||
/// must be rejected at parse time rather than silently persisting an
|
||||
/// unusable scope the authorization resolver never matches.
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class ApiKeyVerifierTests
|
||||
private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" };
|
||||
|
||||
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
|
||||
{
|
||||
@@ -38,6 +39,7 @@ public sealed class ApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync does not expose the raw secret in the result.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public sealed class ApiKeyVerifierTests
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails as missing/malformed for a malformed key.</summary>
|
||||
/// <param name="authorizationHeader">Authorization header value to test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Bearer mxgw_operator01")]
|
||||
@@ -72,6 +75,7 @@ public sealed class ApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnknownKey_Fails()
|
||||
{
|
||||
@@ -86,6 +90,7 @@ public sealed class ApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_Fails()
|
||||
{
|
||||
@@ -102,6 +107,7 @@ public sealed class ApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails for a revoked key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RevokedKey_Fails()
|
||||
{
|
||||
@@ -118,6 +124,7 @@ public sealed class ApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that VerifyAsync fails closed when the pepper is missing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingPepper_Fails()
|
||||
{
|
||||
@@ -160,6 +167,7 @@ public sealed class ApiKeyVerifierTests
|
||||
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
||||
{
|
||||
/// <summary>Returns the configured pepper (or null to simulate an unavailable pepper).</summary>
|
||||
/// <returns>The configured pepper, or null to simulate an unavailable pepper.</returns>
|
||||
public string? GetPepper() => pepper;
|
||||
}
|
||||
|
||||
@@ -169,13 +177,19 @@ public sealed class ApiKeyVerifierTests
|
||||
/// <summary>Gets whether the key was marked as used.</summary>
|
||||
public bool MarkedUsed { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Returns the fake's single stored key if its ID matches, regardless of revocation.</summary>
|
||||
/// <param name="keyId">Key ID to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The matching stored key record, or null if no key matches.</returns>
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Returns the fake's single stored key if its ID matches and it has not been revoked.</summary>
|
||||
/// <param name="keyId">Key ID to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The matching, non-revoked stored key record, or null otherwise.</returns>
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(
|
||||
@@ -184,7 +198,11 @@ public sealed class ApiKeyVerifierTests
|
||||
: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records that the stored key was used, exposed via <see cref="MarkedUsed"/> for assertions.</summary>
|
||||
/// <param name="keyId">Key ID that was used.</param>
|
||||
/// <param name="whenUtc">Timestamp of use (unused by the fake).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
MarkedUsed = storedKey?.KeyId == keyId;
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
||||
{
|
||||
@@ -41,6 +42,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies that MigrateAsync migrates and is idempotent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
|
||||
{
|
||||
@@ -61,6 +63,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies that gateway startup fails with a newer schema version.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartAsync_NewerSchemaVersion_BlocksStartup()
|
||||
{
|
||||
@@ -83,6 +86,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// Verifies that FindActiveByKeyIdAsync returns an active key, reading a row whose columns match
|
||||
/// the donor schema (peppered secret_hash BLOB, ordinal-sorted scopes JSON).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
||||
{
|
||||
@@ -106,6 +110,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies that FindActiveByKeyIdAsync returns null for a revoked key.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
|
||||
{
|
||||
@@ -129,6 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies that the audit store persists audit events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
|
||||
{
|
||||
@@ -163,6 +169,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
/// the auth database in WAL journal mode so concurrent readers and writers degrade
|
||||
/// gracefully instead of surfacing <c>SQLITE_BUSY</c> on the request path.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ internal sealed class TempDatabaseDirectory : IDisposable
|
||||
|
||||
/// <summary>Creates a new uniquely named temporary directory under the given prefix.</summary>
|
||||
/// <param name="prefix">Folder name placed under <c>%TEMP%</c> to group related test directories.</param>
|
||||
/// <returns>The created temporary directory handle.</returns>
|
||||
public static TempDatabaseDirectory Create(string prefix)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
@@ -35,12 +36,13 @@ internal sealed class TempDatabaseDirectory : IDisposable
|
||||
|
||||
/// <summary>Returns a database file path inside this temporary directory.</summary>
|
||||
/// <param name="fileName">Database file name; defaults to the gateway auth database name.</param>
|
||||
/// <returns>The full path to the database file.</returns>
|
||||
public string DatabasePath(string fileName = "gateway-auth.db")
|
||||
{
|
||||
return System.IO.Path.Combine(Path, fileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Clears pooled SQLite connections and deletes the temporary directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||
public sealed class ConstraintEnforcerTests
|
||||
{
|
||||
/// <summary>Verifies that read outside allowed subtree returns failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure()
|
||||
{
|
||||
@@ -35,6 +36,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write with high classification returns failure and audits.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
|
||||
{
|
||||
@@ -84,6 +86,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>A denial carrying a parseable correlation id stores it on the audit record.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RecordDenialAsync_WithGuidCorrelationId_StoresCorrelationId()
|
||||
{
|
||||
@@ -106,6 +109,7 @@ public sealed class ConstraintEnforcerTests
|
||||
/// A denial with a non-GUID correlation id leaves the typed audit correlation id null but
|
||||
/// still preserves the raw client correlation id in DetailsJson so it is not lost.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RecordDenialAsync_WithNonGuidCorrelationId_LeavesCorrelationIdNullButPreservesRawInDetails()
|
||||
{
|
||||
@@ -130,6 +134,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>A denial with no identity records the canonical "anonymous" actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor()
|
||||
{
|
||||
@@ -150,6 +155,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
|
||||
{
|
||||
@@ -169,6 +175,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm-only constraint requires alarm attribute.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
|
||||
{
|
||||
@@ -188,6 +195,7 @@ public sealed class ConstraintEnforcerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that attribute-only constraint fails closed for object tag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
|
||||
{
|
||||
@@ -212,6 +220,7 @@ public sealed class ConstraintEnforcerTests
|
||||
/// Without the <c>[]</c> fallback in ResolveTarget the bare name misses the index and a
|
||||
/// read-constrained key gets a spurious tag_metadata denial for an AddItem it should allow.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithBareArrayName_ResolvesViaArraySuffixFallback()
|
||||
{
|
||||
@@ -236,6 +245,7 @@ public sealed class ConstraintEnforcerTests
|
||||
/// A bare non-array name that is genuinely absent from the index still resolves to null:
|
||||
/// the <c>[]</c> probe must not manufacture a false positive for a scalar/missing tag.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithMissingNonArrayName_StillFailsToResolve()
|
||||
{
|
||||
@@ -265,8 +275,9 @@ public sealed class ConstraintEnforcerTests
|
||||
/// <summary>
|
||||
/// The <c>[]</c>-suffix fallback widened *resolution* of a bare array name, not *authorization*:
|
||||
/// a bare array attribute that resolves through the fallback but is outside the key's read scope
|
||||
/// must still be denied with a <c>read_scope</c> failure (Server-058).
|
||||
/// must still be denied with a <c>read_scope</c> failure.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithBareArrayName_OutOfScope_StillDeniedReadScope()
|
||||
{
|
||||
@@ -289,8 +300,9 @@ public sealed class ConstraintEnforcerTests
|
||||
/// <summary>
|
||||
/// A write against an array handle whose registration carries the suffixed form ("Pump_001.Levels[]")
|
||||
/// resolves through <c>ResolveTarget</c> and is denied with a <c>write_scope</c> failure when the
|
||||
/// array attribute is outside the key's write scope (Server-058).
|
||||
/// array attribute is outside the key's write scope.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_OutOfScope_StillDeniedWriteScope()
|
||||
{
|
||||
@@ -331,9 +343,9 @@ public sealed class ConstraintEnforcerTests
|
||||
/// <summary>
|
||||
/// A write against an in-scope array handle whose registration carries the suffixed form is still
|
||||
/// denied when the array attribute's <c>SecurityClassification</c> exceeds the key's
|
||||
/// <c>MaxWriteClassification</c>, resolved through <c>ResolveTarget</c> via the suffixed address
|
||||
/// (Server-058).
|
||||
/// <c>MaxWriteClassification</c>, resolved through <c>ResolveTarget</c> via the suffixed address.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_ClassificationTooHigh_StillDenied()
|
||||
{
|
||||
@@ -490,10 +502,14 @@ public sealed class ConstraintEnforcerTests
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op refresh; the stub's <see cref="Current"/> entry never changes.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op wait; the stub's <see cref="Current"/> entry is already loaded.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -502,7 +518,10 @@ public sealed class ConstraintEnforcerTests
|
||||
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||
public List<AuditEvent> Events { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records the audit event in <see cref="Events"/> for later assertions.</summary>
|
||||
/// <param name="auditEvent">The audit event to record.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(auditEvent);
|
||||
|
||||
+14
@@ -23,6 +23,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||
public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
{
|
||||
/// <summary>Verifies that missing API key returns unauthenticated status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
|
||||
{
|
||||
@@ -41,6 +42,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid API key error does not expose raw credentials.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
|
||||
{
|
||||
@@ -59,6 +61,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that valid key without required scope returns permission denied.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -77,6 +80,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that valid key with scope sets request identity for the handler.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity()
|
||||
{
|
||||
@@ -103,6 +107,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that server stream handler requires proper scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -122,6 +127,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that server stream handler allows streams with proper scope.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream()
|
||||
{
|
||||
@@ -147,6 +153,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disabled authentication skips API key verification.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
|
||||
{
|
||||
@@ -173,6 +180,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
/// that lacks the <c>session:open</c> scope, and asserts the interceptor denies the
|
||||
/// call with <see cref="StatusCode.PermissionDenied"/> before the service runs.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InterceptorComposedWithService_OpenSessionMissingScope_DeniesBeforeServiceRuns()
|
||||
{
|
||||
@@ -200,6 +208,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
/// that holds <c>session:open</c>, and asserts the service runs and observes the
|
||||
/// interceptor-supplied identity.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InterceptorComposedWithService_OpenSessionWithScope_RunsServiceWithIdentity()
|
||||
{
|
||||
@@ -226,6 +235,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
/// because the wrapped command is a write, confirming command-scope mapping is
|
||||
/// enforced through the full composition.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InterceptorComposedWithService_InvokeWriteCommandWithReadScope_DeniesBeforeServiceRuns()
|
||||
{
|
||||
@@ -261,6 +271,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
/// <see cref="GatewayScopes.InvokeWrite"/>. Ack is a write-shaped mutation against
|
||||
/// alarm state, so it carries the same scope as <c>MxCommandKind.Write</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -279,6 +290,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an API key holding <c>invoke:write</c> may call <c>AcknowledgeAlarm</c>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler()
|
||||
{
|
||||
@@ -305,6 +317,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
/// lack <see cref="GatewayScopes.EventsRead"/>. Active-alarm snapshots are part of the
|
||||
/// alarm/event surface and share the same scope as <c>StreamEvents</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
@@ -324,6 +337,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an API key holding <c>events:read</c> may call <c>QueryActiveAlarms</c>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler()
|
||||
{
|
||||
|
||||
@@ -11,16 +11,19 @@ public sealed class KestrelTlsInspectorTests
|
||||
.AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
|
||||
.Build();
|
||||
|
||||
/// <summary>An HTTPS endpoint with no certificate configured requires a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenHttpsEndpointHasNoCertificate()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"))));
|
||||
|
||||
/// <summary>When every configured endpoint is plaintext, no generated certificate is required.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenAllEndpointsPlaintext()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5120"))));
|
||||
|
||||
/// <summary>An HTTPS endpoint with its own certificate path configured does not require a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasOwnCertificate()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
@@ -28,10 +31,12 @@ public sealed class KestrelTlsInspectorTests
|
||||
("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Http:Certificate:Path", @"C:\certs\real.pfx"))));
|
||||
|
||||
/// <summary>With no endpoints configured at all, no generated certificate is required.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config()));
|
||||
|
||||
/// <summary>An HTTPS endpoint configured with only a certificate thumbprint does not require a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasThumbprintOnly()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
@@ -39,6 +44,7 @@ public sealed class KestrelTlsInspectorTests
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Https:Certificate:Thumbprint", "AABBCCDDEEFF00112233445566778899AABBCCDD"))));
|
||||
|
||||
/// <summary>An HTTPS endpoint configured with only a certificate subject does not require a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasSubjectOnly()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
@@ -46,11 +52,13 @@ public sealed class KestrelTlsInspectorTests
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Https:Certificate:Subject", "CN=myserver"))));
|
||||
|
||||
/// <summary>An uppercase HTTPS URL scheme is still recognized as HTTPS and requires a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenHttpsUrlIsUppercase()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120"))));
|
||||
|
||||
/// <summary>An HTTPS endpoint covered by the Kestrel default certificate does not require a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenKestrelDefaultCertificateConfigured()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
@@ -58,6 +66,7 @@ public sealed class KestrelTlsInspectorTests
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Certificates:Default:Path", @"C:\certs\default.pfx"))));
|
||||
|
||||
/// <summary>Among mixed endpoints, one HTTPS endpoint without a certificate still requires a generated certificate.</summary>
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time)
|
||||
=> new(options, NullLogger<SelfSignedCertificateProvider>.Instance, time);
|
||||
|
||||
/// <summary>Verifies that a generated certificate has the expected validity window, SANs (localhost, machine name, additional DNS names, loopback IPs), and the serverAuth EKU.</summary>
|
||||
[Fact]
|
||||
public void GenerateCertificate_HasExpectedSansEkuAndValidity()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadOrCreate generates and persists a certificate on first call, then reuses the same persisted certificate (same thumbprint) on a subsequent call.</summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
|
||||
{
|
||||
@@ -58,6 +60,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadOrCreate regenerates the certificate (a new thumbprint) once the persisted certificate's validity window has elapsed.</summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
|
||||
{
|
||||
@@ -77,6 +80,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadOrCreate regenerates a valid certificate when the persisted PFX file is corrupt or unreadable.</summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
|
||||
{
|
||||
@@ -92,6 +96,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> for an expired persisted certificate when regeneration is disabled.</summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
|
||||
{
|
||||
@@ -108,6 +113,7 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> when <c>SelfSignedCertPath</c> is blank.</summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank()
|
||||
{
|
||||
|
||||
@@ -23,13 +23,13 @@ public sealed class FakeWorkerProcess(int processId) : IWorkerProcess
|
||||
{
|
||||
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int Id { get; } = processId;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
|
||||
/// <inheritdoc />
|
||||
public bool HasExited { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the exit code of the process, or <see langword="null"/> if it has not exited.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
/// <summary>Gets the number of times <see cref="Kill"/> was called.</summary>
|
||||
@@ -62,7 +62,7 @@ public sealed class FakeWorkerProcess(int processId) : IWorkerProcess
|
||||
MarkExited(-1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Marks this fake as disposed so tests can assert <see cref="Dispose"/> was called.</summary>
|
||||
public void Dispose() => IsDisposed = true;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
/// <see cref="IDashboardEventBroadcaster"/> that drops every event on the floor.
|
||||
/// Used by tests that need to wire up <c>EventStreamService</c> but do not care
|
||||
/// about the dashboard fan-out side-channel. The singleton <see cref="Instance"/>
|
||||
/// mirrors the Tests-007 / Tests-021 shared-fake pattern under
|
||||
/// mirrors the shared-fake pattern under
|
||||
/// <c>src/ZB.MOM.WW.MxGateway.Tests/TestSupport/</c>.
|
||||
/// </summary>
|
||||
public sealed class NullDashboardEventBroadcaster : IDashboardEventBroadcaster
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
/// <see cref="IDashboardEventBroadcaster"/> test double that captures every
|
||||
/// <c>Publish(sessionId, mxEvent)</c> call into a thread-safe list, so tests
|
||||
/// can prove the gRPC producer loop actually mirrors events to the dashboard
|
||||
/// fan-out seam. Tests-026.
|
||||
/// fan-out seam.
|
||||
/// </summary>
|
||||
public sealed class RecordingDashboardEventBroadcaster : IDashboardEventBroadcaster
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user