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:
@@ -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()
|
||||
{
|
||||
|
||||
+28
@@ -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()
|
||||
{
|
||||
|
||||
+1
@@ -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()
|
||||
{
|
||||
|
||||
+1
@@ -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>>([]);
|
||||
|
||||
Reference in New Issue
Block a user