using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using StellaOps.Gateway.WebService.Authorization; using StellaOps.Router.Common.Models; using Xunit; namespace StellaOps.Gateway.WebService.Tests; /// /// Unit tests for . /// public sealed class AuthorityClaimsRefreshServiceTests { private readonly Mock _claimsProviderMock; private readonly Mock _claimsStoreMock; private readonly AuthorityConnectionOptions _options; public AuthorityClaimsRefreshServiceTests() { _claimsProviderMock = new Mock(); _claimsStoreMock = new Mock(); _options = new AuthorityConnectionOptions { AuthorityUrl = "http://authority.local", Enabled = true, RefreshInterval = TimeSpan.FromMilliseconds(100), WaitForAuthorityOnStartup = false, StartupTimeout = TimeSpan.FromSeconds(1) }; _claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny())) .ReturnsAsync(new Dictionary>()); } private AuthorityClaimsRefreshService CreateService() { return new AuthorityClaimsRefreshService( _claimsProviderMock.Object, _claimsStoreMock.Object, Options.Create(_options), NullLogger.Instance); } #region ExecuteAsync Tests - Disabled [Fact] public async Task ExecuteAsync_WhenDisabled_DoesNotFetchClaims() { // Arrange _options.Enabled = false; var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(50); await service.StopAsync(cts.Token); // Assert _claimsProviderMock.Verify( p => p.GetOverridesAsync(It.IsAny()), Times.Never); } [Fact] public async Task ExecuteAsync_WhenNoAuthorityUrl_DoesNotFetchClaims() { // Arrange _options.AuthorityUrl = string.Empty; var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(50); await service.StopAsync(cts.Token); // Assert _claimsProviderMock.Verify( p => p.GetOverridesAsync(It.IsAny()), Times.Never); } #endregion #region ExecuteAsync Tests - Enabled [Fact] public async Task ExecuteAsync_WhenEnabled_FetchesClaims() { // Arrange var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(50); await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert _claimsProviderMock.Verify( p => p.GetOverridesAsync(It.IsAny()), Times.AtLeastOnce); } [Fact] public async Task ExecuteAsync_UpdatesStoreWithOverrides() { // Arrange var key = EndpointKey.Create("service", "GET", "/api/test"); var overrides = new Dictionary> { [key] = [new ClaimRequirement { Type = "role", Value = "admin" }] }; _claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny())) .ReturnsAsync(overrides); var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(50); await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert _claimsStoreMock.Verify( s => s.UpdateFromAuthority(It.Is>>( d => d.ContainsKey(key))), Times.AtLeastOnce); } #endregion #region ExecuteAsync Tests - Wait for Authority [Fact] public async Task ExecuteAsync_WaitForAuthority_FetchesOnStartup() { // Arrange _options.WaitForAuthorityOnStartup = true; _options.StartupTimeout = TimeSpan.FromMilliseconds(500); // Authority is immediately available _claimsProviderMock.Setup(p => p.IsAvailable).Returns(true); var fetchCalled = false; _claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny())) .Callback(() => fetchCalled = true) .ReturnsAsync(new Dictionary>()); var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(100); await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert - fetch was called during startup fetchCalled.Should().BeTrue(); } [Fact] public async Task ExecuteAsync_WaitForAuthority_StopsAfterTimeout() { // Arrange _options.WaitForAuthorityOnStartup = true; _options.StartupTimeout = TimeSpan.FromMilliseconds(100); _claimsProviderMock.Setup(p => p.IsAvailable).Returns(false); var service = CreateService(); using var cts = new CancellationTokenSource(); // Act - should not block forever var startTask = service.StartAsync(cts.Token); await Task.Delay(300); await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert - should complete even if Authority never becomes available startTask.IsCompleted.Should().BeTrue(); } #endregion #region Push Notification Tests [Fact] public async Task ExecuteAsync_WithPushNotifications_SubscribesToEvent() { // Arrange _options.UseAuthorityPushNotifications = true; var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(50); await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert - verify event subscription by checking it doesn't throw _claimsProviderMock.VerifyAdd( p => p.OverridesChanged += It.IsAny>(), Times.Once); } [Fact] public async Task Dispose_WithPushNotifications_UnsubscribesFromEvent() { // Arrange _options.UseAuthorityPushNotifications = true; var service = CreateService(); using var cts = new CancellationTokenSource(); await service.StartAsync(cts.Token); await Task.Delay(50); // Act await cts.CancelAsync(); service.Dispose(); // Assert _claimsProviderMock.VerifyRemove( p => p.OverridesChanged -= It.IsAny>(), Times.Once); } #endregion #region Error Handling Tests [Fact] public async Task ExecuteAsync_ProviderThrows_ContinuesRefreshLoop() { // Arrange var callCount = 0; _claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny())) .ReturnsAsync(() => { callCount++; if (callCount == 1) { throw new HttpRequestException("Test error"); } return new Dictionary>(); }); var service = CreateService(); using var cts = new CancellationTokenSource(); // Act await service.StartAsync(cts.Token); await Task.Delay(250); // Wait for at least 2 refresh cycles await cts.CancelAsync(); await service.StopAsync(CancellationToken.None); // Assert - should have continued after error callCount.Should().BeGreaterThan(1); } #endregion }