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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user