docs+ui: backfill XML doc comments and finish dashboard layout pass

Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
This commit is contained in:
Joseph Doherty
2026-05-27 14:20:10 -04:00
parent 382861c602
commit 615b487a77
110 changed files with 1473 additions and 192 deletions
@@ -667,6 +667,7 @@ public sealed class ProtobufContractRoundTripTests
/// Verifies that a DiscoverHierarchyRequest round-trips through every
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
/// </summary>
/// <param name="rootArm">The oneof arm selector (0=RootGobjectId, 1=RootTagName, 2=RootContainedPath).</param>
[Theory]
[InlineData(0)]
[InlineData(1)]
@@ -1165,6 +1166,8 @@ public sealed class ProtobufContractRoundTripTests
/// expected value. Pins every new oneof case added by the bulk
/// write/read extension.
/// </summary>
/// <param name="kind">The command kind to test.</param>
/// <param name="expectedPayloadCase">The expected payload oneof case.</param>
[Theory]
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
@@ -44,6 +44,7 @@ public sealed class GalaxyFilterInputSafetyTests
"Pump'001",
];
/// <summary>Returns adversarial input cases for theory tests.</summary>
public static TheoryData<string> AdversarialInputCases()
{
TheoryData<string> data = [];
@@ -60,6 +61,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// <c>LIKE</c>-wildcards as literals — a glob equal to the literal value matches,
/// and the same glob does not spuriously match an unrelated value.
/// </summary>
/// <param name="input">An adversarial input containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input)
@@ -159,6 +161,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// treats an adversarial glob as a literal: it never wildcard-matches the whole
/// hierarchy and never throws.
/// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob)
@@ -180,6 +183,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// literal — an exact-match lookup that finds nothing and surfaces NotFound,
/// never matching unrelated objects or throwing an unexpected exception.
/// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName)
@@ -198,6 +202,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// Verifies an adversarial <c>TemplateChainContains</c> filter is a literal
/// substring test — it never matches unrelated template chains and never throws.
/// </summary>
/// <param name="filter">An adversarial filter containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter)
@@ -216,6 +221,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// handles an adversarial <c>TagNameGlob</c> end-to-end: the request succeeds with
/// zero matches rather than returning the whole hierarchy or faulting.
/// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
@@ -235,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// maps an adversarial <c>RootTagName</c> to NotFound rather than executing it as
/// a query fragment or matching unrelated objects.
/// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
@@ -319,10 +326,13 @@ public sealed class GalaxyFilterInputSafetyTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -78,6 +78,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
/// <summary>Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata.</summary>
[Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{
@@ -357,19 +358,24 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Releases the blocking task.</summary>
public void Release() => _release.TrySetResult();
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
/// <inheritdoc />
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
await _release.Task.WaitAsync(ct).ConfigureAwait(false);
throw new InvalidOperationException("Galaxy repository unreachable");
}
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached");
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
}
@@ -377,9 +383,11 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
{
/// <inheritdoc />
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
=> Task.FromResult<GalaxyHierarchySnapshot?>(null);
/// <inheritdoc />
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
{
cts.Cancel();
@@ -391,13 +399,17 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
/// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
private sealed class RecordingLogger<T> : ILogger<T>
{
/// <summary>Gets the list of recorded log entries.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc />
public IDisposable BeginScope<TState>(TState state)
where TState : notnull => NullScope.Instance;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -412,6 +424,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{
public static readonly NullScope Instance = new();
/// <inheritdoc />
public void Dispose()
{
}
@@ -427,20 +440,26 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];
/// <summary>Gets the count of calls to <see cref="GetHierarchyAsync"/>.</summary>
public int GetHierarchyCount { get; private set; }
/// <summary>Gets the count of calls to <see cref="GetAttributesAsync"/>.</summary>
public int GetAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
GetHierarchyCount++;
return Task.FromResult(_hierarchy);
}
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
GetAttributesCount++;
@@ -448,6 +467,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
}
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempPaths)
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary>
public sealed class GalaxyHierarchyProjectorTests
{
/// <summary>Verifies that paging across a hierarchy returns every object exactly once.</summary>
[Fact]
public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce()
{
@@ -43,6 +44,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal("Object_025", collected[^1]);
}
/// <summary>Verifies that distinct filters on the same entry do not share memoized view list.</summary>
[Fact]
public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList()
{
@@ -60,6 +62,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal(10, unfiltered.TotalObjectCount);
}
/// <summary>Verifies that the same filter repeated returns identical totals.</summary>
[Fact]
public void Project_SameFilterRepeated_ReturnsIdenticalTotals()
{
@@ -85,6 +88,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName);
}
/// <summary>Verifies that distinct cache entries project against their own data.</summary>
[Fact]
public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary>
public sealed class GalaxyHierarchyRefreshServiceTests
{
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
[Fact]
public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService()
{
@@ -62,13 +63,17 @@ public sealed class GalaxyHierarchyRefreshServiceTests
private readonly TaskCompletionSource firstRefreshAttempted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the number of refresh calls.</summary>
public int RefreshCallCount { get; private set; }
/// <summary>Completes once <see cref="RefreshAsync"/> has been invoked at least once.</summary>
/// <summary>Gets a task that completes once refresh has been invoked at least once.</summary>
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
/// <summary>Refreshes the cache asynchronously and throws the configured exception.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task RefreshAsync(CancellationToken cancellationToken)
{
RefreshCallCount++;
@@ -76,6 +81,8 @@ public sealed class GalaxyHierarchyRefreshServiceTests
throw toThrow;
}
/// <summary>Waits for the first load and completes immediately.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -12,6 +12,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
{
private readonly List<string> _tempPaths = [];
/// <summary>Verifies that snapshots are correctly saved to and loaded from disk.</summary>
[Fact]
public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows()
{
@@ -39,6 +40,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(loaded.Attributes[1].ArrayDimension);
}
/// <summary>Verifies that loading returns null when no snapshot file exists.</summary>
[Fact]
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
{
@@ -47,6 +49,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that save writes nothing when persistence is disabled.</summary>
[Fact]
public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing()
{
@@ -59,6 +62,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that loading returns null when the file contains invalid JSON.</summary>
[Fact]
public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull()
{
@@ -69,6 +73,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that loading returns null when the schema version is unrecognized.</summary>
[Fact]
public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull()
{
@@ -79,6 +84,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that saving overwrites an earlier snapshot.</summary>
[Fact]
public async Task SaveAsync_OverwritesAnEarlierSnapshot()
{
@@ -159,6 +165,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
return path;
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempPaths)
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests
{
/// <summary>Verifies that CanManage returns true for authenticated admin user.</summary>
[Fact]
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{
@@ -14,6 +15,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.True(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for anonymous user.</summary>
[Fact]
public void CanManage_AnonymousUser_ReturnsFalse()
{
@@ -23,6 +25,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.False(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for authenticated viewer user.</summary>
[Fact]
public void CanManage_AuthenticatedViewer_ReturnsFalse()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
{
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -25,6 +26,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.CreateCount);
}
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
{
@@ -54,6 +56,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.KeyId == "operator01");
}
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -69,6 +72,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.RevokeCount);
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
@@ -89,6 +93,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "revoked");
}
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
{
@@ -112,6 +117,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated");
}
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -127,6 +133,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.DeleteCount);
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
@@ -181,6 +188,7 @@ public sealed class DashboardApiKeyManagementServiceTests
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
/// shared "API key id is required." message before any store or audit call runs.
/// </summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -269,26 +277,37 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
CreateCount++;
@@ -296,11 +315,13 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -311,6 +332,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RevokeResult);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -321,6 +343,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RotateResult);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
@@ -331,14 +354,17 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
@@ -349,8 +375,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret)
{
LastSecret = secret;
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests
{
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
[Fact]
public void EscapeLdapFilter_EscapesSpecialCharacters()
{
@@ -15,6 +16,9 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
}
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
[Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
@@ -42,6 +46,7 @@ public sealed class DashboardAuthenticatorTests
}
}
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
[Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
{
@@ -59,6 +64,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Contains(DashboardRoles.Viewer, roles);
}
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
[Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
{
@@ -68,6 +74,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Gateway Admins", result);
}
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
[Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// </summary>
public sealed class DashboardBrowseAndAlarmModelTests
{
/// <summary>Verifies that the tree builder links children to parents and promotes orphans to roots.</summary>
[Fact]
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
{
@@ -27,6 +28,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Contains(roots, node => node.Object.GobjectId == 3);
}
/// <summary>Verifies that the tree builder sorts areas before non-area objects.</summary>
[Fact]
public void BuildTree_SortsAreasBeforeObjects()
{
@@ -41,6 +43,9 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(roots[1].IsArea);
}
/// <summary>Verifies that the formatter renders boolean values correctly.</summary>
/// <param name="input">The boolean input value.</param>
/// <param name="expected">The expected formatted output.</param>
[Theory]
[InlineData(true, "true")]
[InlineData(false, "false")]
@@ -50,6 +55,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
}
/// <summary>Verifies that the formatter renders numbers and strings correctly.</summary>
[Fact]
public void FormatValue_FormatsNumbersAndStrings()
{
@@ -57,6 +63,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
}
/// <summary>Verifies that the formatter handles null payloads and null references.</summary>
[Fact]
public void FormatValue_HandlesNullPayloadAndNullReference()
{
@@ -64,6 +71,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
}
/// <summary>Verifies that tag values from successful reads mark good quality.</summary>
[Fact]
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
{
@@ -83,6 +91,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Null(value.Error);
}
/// <summary>Verifies that tag values from failed reads carry the error message.</summary>
[Fact]
public void TagValue_FromFailedReadResult_CarriesError()
{
@@ -101,6 +110,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("invalid handle", value.Error);
}
/// <summary>Verifies that active alarms parse provider and acknowledgement state from snapshots.</summary>
[Fact]
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
{
@@ -127,6 +137,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(ackedRow.IsUnacknowledged);
}
/// <summary>Verifies that the formatter renders array elements and element type correctly.</summary>
[Fact]
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardConnectionStringDisplayTests
{
/// <summary>Verifies that Galaxy connection strings strip SQL credentials and keep only non-secret fields.</summary>
[Fact]
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
{
@@ -30,4 +30,23 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal("/logout", options.LogoutPath);
Assert.Equal("/denied", options.AccessDeniedPath);
}
/// <summary>
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev.
/// </summary>
[Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
}
}
@@ -9,6 +9,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>
[Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
{
@@ -25,6 +26,7 @@ public sealed class DashboardHubsRegistrationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
}
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSessionAdminServiceTests
{
/// <summary>Verifies that a viewer cannot close a session.</summary>
[Fact]
public async Task CloseSessionAsync_ViewerCannotManage()
{
@@ -24,6 +25,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that an admin can close a session.</summary>
[Fact]
public async Task CloseSessionAsync_AdminClosesSession()
{
@@ -40,6 +42,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("session-1", sessionManager.LastClosedSessionId);
}
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
{
@@ -58,6 +61,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_ViewerCannotManage()
{
@@ -73,6 +77,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.KillCount);
}
/// <summary>Verifies that an admin can kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_AdminKillsWorker()
{
@@ -95,6 +100,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
}
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
[Fact]
public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
{
@@ -130,6 +136,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that CanManage rejects unauthenticated users and viewers.</summary>
[Fact]
public void CanManage_RejectsUnauthenticatedAndViewer()
{
@@ -209,22 +216,31 @@ public sealed class DashboardSessionAdminServiceTests
private sealed class FakeSessionManager : ISessionManager
{
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
public int CloseCount { get; private set; }
/// <summary>Gets the number of times KillWorkerAsync was invoked.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the last session ID passed to CloseSessionAsync.</summary>
public string? LastClosedSessionId { get; private set; }
/// <summary>Gets the last session ID passed to KillWorkerAsync.</summary>
public string? LastKilledSessionId { get; private set; }
/// <summary>Gets the last reason string passed to KillWorkerAsync.</summary>
public string? LastKillReason { get; private set; }
/// <summary>Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.</summary>
public bool CloseThrowsNotFound { get; init; }
/// <summary>Gets the exception CloseSessionAsync should throw unexpectedly.</summary>
public Exception? CloseThrowsUnexpected { get; init; }
/// <summary>Gets the exception KillWorkerAsync should throw unexpectedly.</summary>
public Exception? KillThrowsUnexpected { get; init; }
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -233,6 +249,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -241,6 +258,7 @@ public sealed class DashboardSessionAdminServiceTests
return false;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -249,6 +267,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -256,6 +275,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -277,6 +297,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
@@ -293,6 +314,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -300,6 +322,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -110,6 +110,7 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>
@@ -120,13 +121,17 @@ public sealed class DashboardSnapshotPublisherTests
/// </summary>
public DateTimeOffset? FirstThrowAt { get; private set; }
/// <summary>Gets the wall-clock instant of the second subscription attempt.</summary>
public DateTimeOffset? SecondSubscribeAt { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
return null!;
}
/// <summary>Watches for snapshot changes and yields them asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -159,13 +164,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
return null!;
}
/// <summary>Watches for snapshot changes and completes immediately.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
#pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -182,24 +191,55 @@ public sealed class DashboardSnapshotPublisherTests
{
private readonly RecordingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => _clients.AllProxy.SendCount;
}
private sealed class RecordingHubClients : IHubClients
{
/// <summary>Gets the recording client proxy for all clients.</summary>
public RecordingClientProxy AllProxy { get; } = new();
/// <summary>Gets a client proxy targeting all clients.</summary>
public IClientProxy All => AllProxy;
/// <summary>Gets a client proxy excluding specified connections.</summary>
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
/// <summary>Gets a client proxy for a specific connection.</summary>
/// <param name="connectionId">The connection identifier.</param>
public IClientProxy Client(string connectionId) => AllProxy;
/// <summary>Gets a client proxy for specified connections.</summary>
/// <param name="connectionIds">The connection identifiers.</param>
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
/// <summary>Gets a client proxy for a group.</summary>
/// <param name="groupName">The group name.</param>
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>
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>
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
/// <summary>Gets a client proxy for a specific user.</summary>
/// <param name="userId">The user identifier.</param>
public IClientProxy User(string userId) => AllProxy;
/// <summary>Gets a client proxy for specified users.</summary>
/// <param name="userIds">The user identifiers.</param>
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
@@ -207,8 +247,13 @@ public sealed class DashboardSnapshotPublisherTests
{
private int _sendCount;
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => Volatile.Read(ref _sendCount);
/// <summary>Records a send call and completes asynchronously.</summary>
/// <param name="method">The SignalR method name.</param>
/// <param name="args">The method arguments.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _sendCount);
@@ -218,9 +263,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class NoopGroupManager : IGroupManager
{
/// <summary>Completes immediately without performing group addition.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <summary>Completes immediately without performing group removal.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
@@ -267,6 +267,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
@@ -303,6 +304,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
@@ -346,6 +348,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -421,16 +424,19 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -439,6 +445,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -448,6 +455,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(false);
@@ -456,8 +464,10 @@ public sealed class DashboardSnapshotServiceTests
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
@@ -467,8 +477,10 @@ public sealed class DashboardSnapshotServiceTests
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
public bool FailNext { get; set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
@@ -89,6 +89,7 @@ public sealed class HubTokenServiceTests
Assert.True(result.IsInRole(DashboardRoles.Viewer));
}
/// <summary>Verifies that a null token returns null.</summary>
[Fact]
public void Validate_NullToken_ReturnsNull()
{
@@ -97,6 +98,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(null));
}
/// <summary>Verifies that an empty token returns null.</summary>
[Fact]
public void Validate_EmptyToken_ReturnsNull()
{
@@ -105,6 +107,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(string.Empty));
}
/// <summary>Verifies that an invalid token returns null.</summary>
[Fact]
public void Validate_GarbageToken_ReturnsNull()
{
@@ -102,6 +102,7 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>Verifies that dashboard routes are registered at root when enabled.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
{
@@ -126,6 +127,7 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>Verifies that dashboard routes are not mapped when disabled.</summary>
[Fact]
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
@@ -355,8 +355,12 @@ public sealed class EventStreamServiceTests
private sealed class ThrowingDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster
{
/// <summary>Gets the count of publish attempts.</summary>
public int PublishAttempts { get; private set; }
/// <summary>Increments the attempt count and throws a simulated failure.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The event to publish.</param>
public void Publish(string sessionId, MxEvent mxEvent)
{
PublishAttempts++;
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests
{
/// <summary>Verifies that DiscoverHierarchy returns the requested page and totals.</summary>
[Fact]
public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals()
{
@@ -31,6 +32,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with a page token returns remaining objects.</summary>
[Fact]
public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects()
{
@@ -56,6 +58,9 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument.</summary>
/// <param name="pageToken">The page token to test.</param>
/// <param name="pageSize">The page size to test.</param>
[Theory]
[InlineData("-1", 1)]
[InlineData("not-an-offset", 1)]
@@ -80,6 +85,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies that DiscoverHierarchy with subtree root and depth filters descendants.</summary>
[Fact]
public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants()
{
@@ -98,6 +104,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy applies server-side filters and omits attributes.</summary>
[Fact]
public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes()
{
@@ -123,6 +130,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(1, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with filtered paging returns post-filter total.</summary>
[Fact]
public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal()
{
@@ -154,6 +162,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.NotEqual(firstObject.TagName, secondObject.TagName);
}
/// <summary>Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument()
{
@@ -180,6 +189,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that DiscoverHierarchy with missing root returns NotFound.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
{
@@ -315,10 +325,13 @@ public sealed class GalaxyRepositoryGrpcServiceTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -827,12 +827,16 @@ public sealed class MxAccessGatewayServiceConstraintTests
{
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
/// <summary>Gets a value indicating whether only seeded sessions should be resolved.</summary>
public bool ResolveOnlySeededSessions { get; init; }
/// <summary>Gets the last worker command that was invoked.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>Gets the count of invoke calls made.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets or sets the default invoke reply to return.</summary>
public WorkerCommandReply InvokeReply { get; set; } = new()
{
Reply = new MxCommandReply
@@ -843,16 +847,26 @@ public sealed class MxAccessGatewayServiceConstraintTests
},
};
/// <summary>Gets the collection of events to stream.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Seeds a test session into the fake manager.</summary>
/// <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="cancellationToken">Token to observe for cancellation.</param>
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
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>
public bool TryGetSession(string sessionId, out GatewaySession session)
{
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
@@ -871,6 +885,10 @@ 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>
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -881,6 +899,9 @@ 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>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -893,21 +914,33 @@ 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>
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>
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>
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>
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static GatewaySession CreateFallbackSession(string sessionId)
@@ -932,6 +965,9 @@ 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>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -947,21 +983,33 @@ public sealed class MxAccessGatewayServiceConstraintTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the test session identifier.</summary>
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
/// <summary>Gets the test worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets the test worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat time.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Starts the test worker client asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
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>
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>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -969,12 +1017,18 @@ 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>
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>
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -187,35 +187,46 @@ public sealed class GatewaySessionTests
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets or sets the worker client state.</summary>
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of shutdown invocations.</summary>
public int ShutdownCount { get; private set; }
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; }
/// <summary>Waits for shutdown to start.</summary>
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Releases the shutdown block.</summary>
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
/// <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)
{
@@ -223,6 +234,7 @@ public sealed class GatewaySessionTests
yield break;
}
/// <inheritdoc />
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
@@ -231,11 +243,13 @@ public sealed class GatewaySessionTests
State = WorkerClientState.Closed;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -245,23 +259,31 @@ public sealed class GatewaySessionTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; }
/// <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)
{
@@ -269,12 +291,15 @@ public sealed class GatewaySessionTests
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -20,6 +20,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// </summary>
public sealed class SessionManagerBulkTests
{
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{
@@ -48,6 +49,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("invalid tag", results[1].ErrorMessage);
}
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task AddItemBulkAsync_PropagatesCancellation()
{
@@ -60,6 +62,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
}
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{
@@ -86,6 +89,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation()
{
@@ -98,6 +102,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
}
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{
@@ -122,6 +127,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation()
{
@@ -134,6 +140,7 @@ public sealed class SessionManagerBulkTests
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
}
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{
@@ -159,6 +166,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("not advised", results[1].ErrorMessage);
}
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{
@@ -171,6 +179,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
}
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{
@@ -198,6 +207,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
}
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
[Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation()
{
@@ -210,6 +220,7 @@ public sealed class SessionManagerBulkTests
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
}
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{
@@ -234,6 +245,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
[Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{
@@ -246,6 +258,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
}
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{
@@ -278,6 +291,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
}
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteBulkAsync_PropagatesCancellation()
{
@@ -293,6 +307,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
[Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{
@@ -335,6 +350,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
[Fact]
public async Task Write2BulkAsync_PropagatesCancellation()
{
@@ -359,6 +375,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
[Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{
@@ -409,6 +426,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
}
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{
@@ -480,6 +498,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal(1, workerClient.InvokeCount);
}
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
[Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{
@@ -527,6 +546,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{
@@ -552,6 +572,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{
@@ -597,6 +618,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
}
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
[Fact]
public async Task ReadBulkAsync_PropagatesCancellation()
{
@@ -50,6 +50,7 @@ public sealed class SessionManagerTests
Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt);
}
/// <summary>Verifies that session generation creates client correlation ID from client name and session ID.</summary>
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -124,6 +125,7 @@ public sealed class SessionManagerTests
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
}
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -168,6 +170,7 @@ public sealed class SessionManagerTests
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
}
/// <summary>Verifies that gateway session write bulk forwards one bulk command and returns results.</summary>
[Fact]
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -232,6 +235,7 @@ public sealed class SessionManagerTests
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
}
/// <summary>Verifies that gateway session read bulk forwards one bulk command and returns results.</summary>
[Fact]
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -497,6 +501,7 @@ public sealed class SessionManagerTests
/// <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>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -710,6 +715,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, workerClient.ShutdownCount);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
@@ -15,6 +15,7 @@ public sealed class OrphanWorkerTerminatorTests
{
private const string WorkerExecutablePath = @"C:\app\src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe";
/// <summary>Verifies that orphan worker processes matching the configured executable path are killed.</summary>
[Fact]
public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath()
{
@@ -31,6 +32,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Equal([101, 102], inspector.KilledProcessIds.Order());
}
/// <summary>Verifies that orphan workers are killed when executable path is unreadable but image name matches.</summary>
[Fact]
public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable()
{
@@ -49,6 +51,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Equal([201], inspector.KilledProcessIds);
}
/// <summary>Verifies that unrelated processes with the same image name are not killed.</summary>
[Fact]
public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName()
{
@@ -66,6 +69,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds);
}
/// <summary>Verifies that the current process is not killed even if path matches.</summary>
[Fact]
public void TerminateOrphans_DoesNotKillCurrentProcess()
{
@@ -81,6 +85,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds);
}
/// <summary>Verifies that termination continues when one process kill fails.</summary>
[Fact]
public void TerminateOrphans_ContinuesWhenOneKillThrows()
{
@@ -118,12 +123,18 @@ public sealed class OrphanWorkerTerminatorTests
private sealed class FakeProcessInspector(IReadOnlyList<RunningProcessInfo> processes)
: IRunningProcessInspector
{
/// <summary>Gets the list of killed process IDs.</summary>
public List<int> KilledProcessIds { get; } = [];
/// <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>
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>
public void Kill(int processId)
{
if (ThrowOnKillProcessId == processId)
@@ -247,6 +247,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that pipe disconnect faults the client.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -767,23 +768,32 @@ public sealed class WorkerClientTests
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the process ID.</summary>
public int Id { get; } = WorkerProcessId;
/// <summary>Gets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; }
/// <summary>Gets the process exit code.</summary>
public int? ExitCode { get; private set; }
/// <summary>Gets the number of times kill was called.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the last kill request's entire process tree flag.</summary>
public bool KillEntireProcessTree { get; private set; }
/// <summary>Gets a value indicating whether dispose was called.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
}
/// <summary>Records a kill request.</summary>
/// <param name="entireProcessTree">Whether to kill the entire process tree.</param>
public void Kill(bool entireProcessTree)
{
KillCount++;
@@ -793,6 +803,7 @@ public sealed class WorkerClientTests
_exited.TrySetResult();
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
@@ -17,6 +17,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
private readonly List<string> _tempFiles = [];
/// <summary>Verifies that x86 executable matching required architecture does not throw.</summary>
[Fact]
public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{
@@ -25,6 +26,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86);
}
/// <summary>Verifies that x64 executable matching required architecture does not throw.</summary>
[Fact]
public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{
@@ -33,6 +35,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64);
}
/// <summary>Verifies that x64 executable when x86 required throws invalid executable.</summary>
[Fact]
public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable()
{
@@ -45,6 +48,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that x86 executable when x64 required throws invalid executable.</summary>
[Fact]
public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable()
{
@@ -56,6 +60,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
}
/// <summary>Verifies that file without MZ header throws invalid executable.</summary>
[Fact]
public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable()
{
@@ -70,6 +75,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("MZ", exception.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that file too small for PE header throws invalid executable.</summary>
[Fact]
public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable()
{
@@ -81,6 +87,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
}
/// <summary>Verifies that file without PE signature throws invalid executable.</summary>
[Fact]
public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable()
{
@@ -122,6 +129,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
return path;
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempFiles)
@@ -177,6 +177,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
}
/// <summary>Verifies that API key constraints are persisted correctly.</summary>
[Fact]
public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
{
@@ -141,6 +141,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.True(constraints.ReadHistorizedOnly);
}
/// <summary>Verifies that create-key command without display name returns error.</summary>
[Fact]
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests
{
/// <summary>Verifies that read outside allowed subtree returns failure.</summary>
[Fact]
public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure()
{
@@ -28,6 +29,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_scope", failure.ConstraintName);
}
/// <summary>Verifies that write with high classification returns failure and audits.</summary>
[Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{
@@ -70,6 +72,7 @@ public sealed class ConstraintEnforcerTests
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
}
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
[Fact]
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
{
@@ -88,6 +91,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_historized_only", failure.ConstraintName);
}
/// <summary>Verifies that alarm-only constraint requires alarm attribute.</summary>
[Fact]
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
{
@@ -106,6 +110,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_alarm_only", failure.ConstraintName);
}
/// <summary>Verifies that attribute-only constraint fails closed for object tag.</summary>
[Fact]
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
{
@@ -222,23 +227,29 @@ public sealed class ConstraintEnforcerTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the recorded audit entries.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);