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

Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
This commit is contained in:
Joseph Doherty
2026-05-27 14:20:10 -04:00
parent 382861c602
commit 615b487a77
110 changed files with 1473 additions and 192 deletions
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests
{
/// <summary>Verifies that CanManage returns true for authenticated admin user.</summary>
[Fact]
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{
@@ -14,6 +15,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.True(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for anonymous user.</summary>
[Fact]
public void CanManage_AnonymousUser_ReturnsFalse()
{
@@ -23,6 +25,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.False(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for authenticated viewer user.</summary>
[Fact]
public void CanManage_AuthenticatedViewer_ReturnsFalse()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
{
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -25,6 +26,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.CreateCount);
}
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
{
@@ -54,6 +56,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.KeyId == "operator01");
}
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -69,6 +72,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.RevokeCount);
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
@@ -89,6 +93,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "revoked");
}
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
{
@@ -112,6 +117,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated");
}
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -127,6 +133,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.DeleteCount);
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
@@ -181,6 +188,7 @@ public sealed class DashboardApiKeyManagementServiceTests
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
/// shared "API key id is required." message before any store or audit call runs.
/// </summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -269,26 +277,37 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
CreateCount++;
@@ -296,11 +315,13 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -311,6 +332,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RevokeResult);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -321,6 +343,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RotateResult);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
@@ -331,14 +354,17 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
@@ -349,8 +375,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret)
{
LastSecret = secret;
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests
{
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
[Fact]
public void EscapeLdapFilter_EscapesSpecialCharacters()
{
@@ -15,6 +16,9 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
}
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
[Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
@@ -42,6 +46,7 @@ public sealed class DashboardAuthenticatorTests
}
}
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
[Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
{
@@ -59,6 +64,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Contains(DashboardRoles.Viewer, roles);
}
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
[Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
{
@@ -68,6 +74,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Gateway Admins", result);
}
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
[Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// </summary>
public sealed class DashboardBrowseAndAlarmModelTests
{
/// <summary>Verifies that the tree builder links children to parents and promotes orphans to roots.</summary>
[Fact]
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
{
@@ -27,6 +28,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Contains(roots, node => node.Object.GobjectId == 3);
}
/// <summary>Verifies that the tree builder sorts areas before non-area objects.</summary>
[Fact]
public void BuildTree_SortsAreasBeforeObjects()
{
@@ -41,6 +43,9 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(roots[1].IsArea);
}
/// <summary>Verifies that the formatter renders boolean values correctly.</summary>
/// <param name="input">The boolean input value.</param>
/// <param name="expected">The expected formatted output.</param>
[Theory]
[InlineData(true, "true")]
[InlineData(false, "false")]
@@ -50,6 +55,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
}
/// <summary>Verifies that the formatter renders numbers and strings correctly.</summary>
[Fact]
public void FormatValue_FormatsNumbersAndStrings()
{
@@ -57,6 +63,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
}
/// <summary>Verifies that the formatter handles null payloads and null references.</summary>
[Fact]
public void FormatValue_HandlesNullPayloadAndNullReference()
{
@@ -64,6 +71,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
}
/// <summary>Verifies that tag values from successful reads mark good quality.</summary>
[Fact]
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
{
@@ -83,6 +91,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Null(value.Error);
}
/// <summary>Verifies that tag values from failed reads carry the error message.</summary>
[Fact]
public void TagValue_FromFailedReadResult_CarriesError()
{
@@ -101,6 +110,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("invalid handle", value.Error);
}
/// <summary>Verifies that active alarms parse provider and acknowledgement state from snapshots.</summary>
[Fact]
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
{
@@ -127,6 +137,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(ackedRow.IsUnacknowledged);
}
/// <summary>Verifies that the formatter renders array elements and element type correctly.</summary>
[Fact]
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardConnectionStringDisplayTests
{
/// <summary>Verifies that Galaxy connection strings strip SQL credentials and keep only non-secret fields.</summary>
[Fact]
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
{
@@ -30,4 +30,23 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal("/logout", options.LogoutPath);
Assert.Equal("/denied", options.AccessDeniedPath);
}
/// <summary>
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev.
/// </summary>
[Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
}
}
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardHubsRegistrationTests
{
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
{
@@ -25,6 +26,7 @@ public sealed class DashboardHubsRegistrationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
}
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSessionAdminServiceTests
{
/// <summary>Verifies that a viewer cannot close a session.</summary>
[Fact]
public async Task CloseSessionAsync_ViewerCannotManage()
{
@@ -24,6 +25,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that an admin can close a session.</summary>
[Fact]
public async Task CloseSessionAsync_AdminClosesSession()
{
@@ -40,6 +42,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("session-1", sessionManager.LastClosedSessionId);
}
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
{
@@ -58,6 +61,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_ViewerCannotManage()
{
@@ -73,6 +77,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.KillCount);
}
/// <summary>Verifies that an admin can kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_AdminKillsWorker()
{
@@ -95,6 +100,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
}
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
[Fact]
public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
{
@@ -130,6 +136,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that CanManage rejects unauthenticated users and viewers.</summary>
[Fact]
public void CanManage_RejectsUnauthenticatedAndViewer()
{
@@ -209,22 +216,31 @@ public sealed class DashboardSessionAdminServiceTests
private sealed class FakeSessionManager : ISessionManager
{
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
public int CloseCount { get; private set; }
/// <summary>Gets the number of times KillWorkerAsync was invoked.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the last session ID passed to CloseSessionAsync.</summary>
public string? LastClosedSessionId { get; private set; }
/// <summary>Gets the last session ID passed to KillWorkerAsync.</summary>
public string? LastKilledSessionId { get; private set; }
/// <summary>Gets the last reason string passed to KillWorkerAsync.</summary>
public string? LastKillReason { get; private set; }
/// <summary>Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.</summary>
public bool CloseThrowsNotFound { get; init; }
/// <summary>Gets the exception CloseSessionAsync should throw unexpectedly.</summary>
public Exception? CloseThrowsUnexpected { get; init; }
/// <summary>Gets the exception KillWorkerAsync should throw unexpectedly.</summary>
public Exception? KillThrowsUnexpected { get; init; }
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -233,6 +249,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -241,6 +258,7 @@ public sealed class DashboardSessionAdminServiceTests
return false;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -249,6 +267,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -256,6 +275,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -277,6 +297,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
@@ -293,6 +314,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -300,6 +322,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -110,6 +110,7 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>
@@ -120,13 +121,17 @@ public sealed class DashboardSnapshotPublisherTests
/// </summary>
public DateTimeOffset? FirstThrowAt { get; private set; }
/// <summary>Gets the wall-clock instant of the second subscription attempt.</summary>
public DateTimeOffset? SecondSubscribeAt { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
return null!;
}
/// <summary>Watches for snapshot changes and yields them asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -159,13 +164,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
return null!;
}
/// <summary>Watches for snapshot changes and completes immediately.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
#pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -182,24 +191,55 @@ public sealed class DashboardSnapshotPublisherTests
{
private readonly RecordingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => _clients.AllProxy.SendCount;
}
private sealed class RecordingHubClients : IHubClients
{
/// <summary>Gets the recording client proxy for all clients.</summary>
public RecordingClientProxy AllProxy { get; } = new();
/// <summary>Gets a client proxy targeting all clients.</summary>
public IClientProxy All => AllProxy;
/// <summary>Gets a client proxy excluding specified connections.</summary>
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
/// <summary>Gets a client proxy for a specific connection.</summary>
/// <param name="connectionId">The connection identifier.</param>
public IClientProxy Client(string connectionId) => AllProxy;
/// <summary>Gets a client proxy for specified connections.</summary>
/// <param name="connectionIds">The connection identifiers.</param>
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
/// <summary>Gets a client proxy for a group.</summary>
/// <param name="groupName">The group name.</param>
public IClientProxy Group(string groupName) => AllProxy;
/// <summary>Gets a client proxy for a group excluding specified connections.</summary>
/// <param name="groupName">The group name.</param>
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
/// <summary>Gets a client proxy for specified groups.</summary>
/// <param name="groupNames">The group names.</param>
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
/// <summary>Gets a client proxy for a specific user.</summary>
/// <param name="userId">The user identifier.</param>
public IClientProxy User(string userId) => AllProxy;
/// <summary>Gets a client proxy for specified users.</summary>
/// <param name="userIds">The user identifiers.</param>
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
@@ -207,8 +247,13 @@ public sealed class DashboardSnapshotPublisherTests
{
private int _sendCount;
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => Volatile.Read(ref _sendCount);
/// <summary>Records a send call and completes asynchronously.</summary>
/// <param name="method">The SignalR method name.</param>
/// <param name="args">The method arguments.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _sendCount);
@@ -218,9 +263,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class NoopGroupManager : IGroupManager
{
/// <summary>Completes immediately without performing group addition.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <summary>Completes immediately without performing group removal.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
@@ -267,6 +267,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
@@ -303,6 +304,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
@@ -346,6 +348,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -421,16 +424,19 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -439,6 +445,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -448,6 +455,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(false);
@@ -456,8 +464,10 @@ public sealed class DashboardSnapshotServiceTests
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
@@ -467,8 +477,10 @@ public sealed class DashboardSnapshotServiceTests
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
public bool FailNext { get; set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
@@ -89,6 +89,7 @@ public sealed class HubTokenServiceTests
Assert.True(result.IsInRole(DashboardRoles.Viewer));
}
/// <summary>Verifies that a null token returns null.</summary>
[Fact]
public void Validate_NullToken_ReturnsNull()
{
@@ -97,6 +98,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(null));
}
/// <summary>Verifies that an empty token returns null.</summary>
[Fact]
public void Validate_EmptyToken_ReturnsNull()
{
@@ -105,6 +107,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(string.Empty));
}
/// <summary>Verifies that an invalid token returns null.</summary>
[Fact]
public void Validate_GarbageToken_ReturnsNull()
{