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
}